feat(maestro): optional maestro-web systemd unit in installer (NAP-231)
ci / rust (push) Successful in 2m9s

After the daemon-unit step, the wizard now offers a second user unit
(`maestro-web.service`) that runs `maestro-registry` on 127.0.0.1:8888
so the web UI is reachable in a browser locally.

Decisions:

- The unit runs `maestro-registry` directly — the web bundle is already
  embedded in that binary, so a separate `maestro-web` binary would be
  pure cost. `Description=` and the unit filename make the user-facing
  framing clear.
- `Wants=maestrod.service` + `After=` (not `Requires=`): the registry
  process doesn't actually need the daemon to start; the web UI just
  has nothing to show without one. Soft dependency expresses that.
- Default loopback bind is `127.0.0.1:8888` to make it obvious which
  port is the local UI vs a separately deployed registry on `:8080`.

Wizard surface:

- New prompt (default *no*) after the daemon-systemd step.
- New flags: `--no-web`, `--web-bind <addr>`, `--web-exec-start <path>`.
- Skipped automatically when systemd is absent or `--no-systemd` is set.
- The re-run/update path mirrors the daemon flow: refresh the unit,
  daemon-reload, restart instead of start.
- `maestro-registry` is only `cargo install`ed when the user opts in,
  keeping the no-web install footprint unchanged.

docs/INSTALL.md gets the new flags + a full sample unit body.
This commit is contained in:
2026-05-26 08:47:33 +00:00
parent 91f252b4e2
commit 2aa2d3e20d
4 changed files with 252 additions and 7 deletions
+4 -1
View File
@@ -14,4 +14,7 @@ pub mod install;
pub mod systemd;
pub use install::{cargo_install_args, systemctl_user_args, CargoSource, SystemctlAction};
pub use systemd::{is_systemd_host, render_user_unit, user_unit_path, UNIT_FILE_NAME};
pub use systemd::{
is_systemd_host, render_user_unit, render_web_user_unit, user_unit_path, web_user_unit_path,
DEFAULT_WEB_BIND_ADDR, UNIT_FILE_NAME, WEB_UNIT_FILE_NAME,
};
+136 -5
View File
@@ -16,13 +16,15 @@ use std::process::{Command, ExitCode};
use clap::Parser;
use maestro_installer::install::cargo_bin;
use maestro_installer::{
cargo_install_args, is_systemd_host, render_user_unit, systemctl_user_args, user_unit_path,
CargoSource, SystemctlAction, UNIT_FILE_NAME,
cargo_install_args, is_systemd_host, render_user_unit, render_web_user_unit,
systemctl_user_args, user_unit_path, web_user_unit_path, CargoSource, SystemctlAction,
DEFAULT_WEB_BIND_ADDR, UNIT_FILE_NAME, WEB_UNIT_FILE_NAME,
};
const DEFAULT_GIT_URL: &str = "https://git.naps.pt/naps62/maestro";
const DAEMON_CRATE: &str = "maestro-daemon";
const CLI_CRATE: &str = "maestro-cli";
const REGISTRY_CRATE: &str = "maestro-registry";
const SYSTEMD_PROBE: &str = "/run/systemd/system";
#[derive(Debug, Parser)]
@@ -61,10 +63,24 @@ struct Args {
#[arg(long)]
no_configure: bool,
/// Skip the optional local web-UI systemd unit (no
/// `maestro-registry` install, no `maestro-web.service`).
#[arg(long)]
no_web: bool,
/// Override the ExecStart path written to the systemd unit.
/// Default: `~/.cargo/bin/maestrod`.
#[arg(long, value_name = "PATH")]
exec_start: Option<PathBuf>,
/// Override the ExecStart path for `maestro-web.service`.
/// Default: `~/.cargo/bin/maestro-registry`.
#[arg(long, value_name = "PATH")]
web_exec_start: Option<PathBuf>,
/// Loopback `host:port` the local web UI binds to.
#[arg(long, value_name = "ADDR", default_value = DEFAULT_WEB_BIND_ADDR)]
web_bind: String,
}
fn main() -> ExitCode {
@@ -117,6 +133,7 @@ fn run(args: Args) -> Result<(), Box<dyn std::error::Error>> {
// 3. systemd integration.
let systemd_detected = is_systemd_host(Path::new(SYSTEMD_PROBE));
let systemd_active = systemd_detected && !args.no_systemd;
if args.no_systemd {
eprintln!("\nSkipping systemd step (--no-systemd).");
} else if !systemd_detected {
@@ -125,7 +142,17 @@ fn run(args: Args) -> Result<(), Box<dyn std::error::Error>> {
handle_systemd(&args, interactive, configured)?;
}
print_summary(configured, systemd_detected && !args.no_systemd);
// 4. Optional local web UI (maestro-registry on loopback).
let web_installed = if systemd_active && !args.no_web {
handle_web_unit(&source, &args, interactive)?
} else {
if args.no_web {
eprintln!("\nSkipping local web UI step (--no-web).");
}
false
};
print_summary(configured, systemd_active, web_installed, &args.web_bind);
Ok(())
}
@@ -146,7 +173,8 @@ fn print_banner(source: &CargoSource) {
eprintln!("This wizard will:");
eprintln!(" 1. cargo install both `{DAEMON_CRATE}` and `{CLI_CRATE}`");
eprintln!(" 2. (optional) run `maestrod configure` to bootstrap the config file");
eprintln!(" 3. (optional) install a systemd user unit and enable it");
eprintln!(" 3. (optional) install the maestrod systemd user unit");
eprintln!(" 4. (optional) install a local web UI systemd unit (`maestro-web.service`)");
eprintln!();
match source {
CargoSource::Git { url, branch } => {
@@ -304,6 +332,105 @@ fn default_exec_start() -> Result<PathBuf, Box<dyn std::error::Error>> {
.ok_or_else(|| "could not resolve $HOME (needed for ExecStart)".into())
}
fn default_web_exec_start() -> Result<PathBuf, Box<dyn std::error::Error>> {
maestro_installer::systemd::default_maestro_registry_path()
.ok_or_else(|| "could not resolve $HOME (needed for web ExecStart)".into())
}
/// Drop the web-UI unit (and install `maestro-registry` if the user opts in).
/// Returns `true` if the unit ended up on disk after this call.
fn handle_web_unit(
source: &CargoSource,
args: &Args,
interactive: bool,
) -> Result<bool, Box<dyn std::error::Error>> {
let unit_path = web_user_unit_path().ok_or("could not resolve ~/.config/systemd/user/")?;
let already_installed = unit_path.exists();
eprintln!();
if already_installed {
eprintln!(
"local web UI unit already present at {} — refreshing.",
unit_path.display()
);
} else {
eprintln!("Local web UI: an optional `maestro-web.service` runs `maestro-registry`");
eprintln!(
"on {bind} so you can open it in a browser. Useful for single-machine setups;",
bind = args.web_bind
);
eprintln!("skip if you already have a remote registry deployed.");
}
let want_unit = if already_installed || !interactive {
true
} else {
confirm(
&format!(
"Install local web UI unit at {} (also runs cargo install maestro-registry)?",
unit_path.display()
),
false,
)?
};
if !want_unit {
return Ok(false);
}
// Install (or update) the registry binary first — the unit's ExecStart
// points at it.
let locked = !args.no_locked;
install_crate(source, REGISTRY_CRATE, locked)?;
let exec_start = match &args.web_exec_start {
Some(p) => p.clone(),
None => default_web_exec_start()?,
};
if let Some(parent) = unit_path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = render_web_user_unit(&exec_start, &args.web_bind);
std::fs::write(&unit_path, body)?;
eprintln!("wrote {}", unit_path.display());
run_systemctl(SystemctlAction::DaemonReload, WEB_UNIT_FILE_NAME)?;
if !already_installed {
let want_enable = if !interactive {
true
} else {
confirm("Enable maestro-web.service (start on login)?", true)?
};
if want_enable {
run_systemctl(SystemctlAction::Enable, WEB_UNIT_FILE_NAME)?;
}
}
let (action, want) = if already_installed {
let want = if !interactive {
true
} else {
confirm(
"Restart maestro-web.service to pick up the new binary?",
true,
)?
};
(SystemctlAction::Restart, want)
} else {
let want = if !interactive {
true
} else {
confirm("Start maestro-web.service now?", true)?
};
(SystemctlAction::Start, want)
};
if want {
run_systemctl(action, WEB_UNIT_FILE_NAME)?;
}
Ok(true)
}
fn run_systemctl(action: SystemctlAction, unit: &str) -> Result<(), Box<dyn std::error::Error>> {
let args = systemctl_user_args(action, unit);
eprintln!("\n==> systemctl {}", args.join(" "));
@@ -328,7 +455,7 @@ fn confirm(prompt: &str, default_yes: bool) -> io::Result<bool> {
})
}
fn print_summary(configured: bool, systemd_enabled: bool) {
fn print_summary(configured: bool, systemd_enabled: bool, web_installed: bool, web_bind: &str) {
eprintln!();
eprintln!("Done.");
eprintln!();
@@ -343,4 +470,8 @@ fn print_summary(configured: bool, systemd_enabled: bool) {
eprintln!(" • Start the daemon: `maestrod`");
}
eprintln!(" • Set the CLI's registry: `maestro set-registry <URL>`");
if web_installed {
eprintln!(" • Open the local web UI: http://{web_bind}");
eprintln!(" • Web status: `systemctl --user status maestro-web`");
}
}
+78
View File
@@ -11,6 +11,16 @@ pub const SYSTEMD_PROBE_PATH: &str = "/run/systemd/system";
/// Filename written under `~/.config/systemd/user/`.
pub const UNIT_FILE_NAME: &str = "maestrod.service";
/// Optional second unit: a local `maestro-registry` instance that serves the
/// web UI on loopback. Independent of the daemon process-wise, but useless on
/// its own — `Wants=maestrod.service` + `After=` express that softly.
pub const WEB_UNIT_FILE_NAME: &str = "maestro-web.service";
/// Default loopback bind for the local web UI. Picked outside the registry
/// binary's own default (8080) to make it obvious which is which on hosts
/// where both might coexist with a separately-deployed registry.
pub const DEFAULT_WEB_BIND_ADDR: &str = "127.0.0.1:8888";
/// `true` when the given path exists. Production callers should pass
/// [`SYSTEMD_PROBE_PATH`]; tests inject a temp path so they're hermetic.
pub fn is_systemd_host(probe: &Path) -> bool {
@@ -58,6 +68,53 @@ pub fn default_maestrod_path() -> Option<PathBuf> {
)
}
/// `~/.config/systemd/user/maestro-web.service`. `None` if `$HOME` / config
/// dir is unresolvable.
pub fn web_user_unit_path() -> Option<PathBuf> {
let base = dirs::config_dir()?;
Some(base.join("systemd").join("user").join(WEB_UNIT_FILE_NAME))
}
/// Render the web-UI unit body. `exec_start` is the absolute path to
/// `maestro-registry`; `bind_addr` is the `host:port` the registry listens
/// on (set via the `MAESTRO_REGISTRY_BIND_ADDR` env var).
///
/// `Wants=` rather than `Requires=`: the registry binary doesn't actually
/// need maestrod to start — they're decoupled at the process level. But the
/// web UI has nothing to show without a daemon connected, so we order this
/// after maestrod and politely express the dependency.
pub fn render_web_user_unit(exec_start: &Path, bind_addr: &str) -> String {
format!(
"[Unit]\n\
Description=Maestro local web UI (maestro-registry on {bind})\n\
Documentation=https://git.naps.pt/naps62/maestro\n\
After=network-online.target maestrod.service\n\
Wants=maestrod.service\n\
\n\
[Service]\n\
Type=simple\n\
Environment=MAESTRO_REGISTRY_BIND_ADDR={bind}\n\
ExecStart={exec}\n\
Restart=on-failure\n\
RestartSec=5\n\
\n\
[Install]\n\
WantedBy=default.target\n",
exec = exec_start.display(),
bind = bind_addr,
)
}
/// Default `ExecStart` for the web unit: `~/.cargo/bin/maestro-registry`.
pub fn default_maestro_registry_path() -> Option<PathBuf> {
Some(
dirs::home_dir()?
.join(".cargo")
.join("bin")
.join("maestro-registry"),
)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -99,4 +156,25 @@ mod tests {
let p = user_unit_path().expect("config dir resolvable in test env");
assert!(p.ends_with("systemd/user/maestrod.service"), "got {p:?}");
}
#[test]
fn web_unit_renders_with_bind_and_exec() {
let body = render_web_user_unit(
Path::new("/home/alice/.cargo/bin/maestro-registry"),
"127.0.0.1:8888",
);
assert!(body.contains("ExecStart=/home/alice/.cargo/bin/maestro-registry"));
assert!(body.contains("Environment=MAESTRO_REGISTRY_BIND_ADDR=127.0.0.1:8888"));
assert!(body.contains("After=network-online.target maestrod.service"));
assert!(body.contains("Wants=maestrod.service"));
assert!(body.contains("WantedBy=default.target"));
assert!(body.contains("Restart=on-failure"));
assert!(body.ends_with('\n'));
}
#[test]
fn web_unit_path_uses_correct_filename() {
let p = web_user_unit_path().expect("config dir resolvable in test env");
assert!(p.ends_with("systemd/user/maestro-web.service"), "got {p:?}");
}
}
+34 -1
View File
@@ -43,8 +43,13 @@ The wizard:
3. On systemd hosts, optionally writes a user unit at
`~/.config/systemd/user/maestrod.service`, reloads systemd, and offers to
enable + start it.
4. On systemd hosts, also offers a **local web UI** unit
(`~/.config/systemd/user/maestro-web.service`). When accepted, this also
`cargo install`s `maestro-registry` and runs it bound to `127.0.0.1:8888`
(override with `--web-bind`). Skip with `--no-web` (or just answer "n"); a
remote registry deployment is the alternative.
Both binaries land in `~/.cargo/bin` — make sure that's on your `$PATH`.
Binaries land in `~/.cargo/bin` — make sure that's on your `$PATH`.
## Flags
@@ -57,6 +62,9 @@ Both binaries land in `~/.cargo/bin` — make sure that's on your `$PATH`.
| `--exec-start <path>` | Override the systemd unit's `ExecStart` (default `~/.cargo/bin/maestrod`). |
| `--no-systemd` | Skip the systemd step even if detected. |
| `--no-configure` | Skip the `maestrod configure` step. |
| `--no-web` | Skip the optional local web-UI unit + `maestro-registry` install. |
| `--web-bind <addr>` | Loopback bind for the web UI (default `127.0.0.1:8888`). |
| `--web-exec-start <path>` | Override the web unit's `ExecStart` (default `~/.cargo/bin/maestro-registry`). |
| `-y`, `--yes` | Non-interactive: install binaries + drop systemd unit + reload + enable, but **don't** auto-run `configure` or auto-start the unit (those need real config). |
## What the systemd unit looks like
@@ -87,6 +95,31 @@ systemctl --user status maestrod
journalctl --user -u maestrod -f
```
The optional `maestro-web.service` looks like:
```ini
[Unit]
Description=Maestro local web UI (maestro-registry on 127.0.0.1:8888)
Documentation=https://git.naps.pt/naps62/maestro
After=network-online.target maestrod.service
Wants=maestrod.service
[Service]
Type=simple
Environment=MAESTRO_REGISTRY_BIND_ADDR=127.0.0.1:8888
ExecStart=%h/.cargo/bin/maestro-registry
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
```
`Wants=maestrod.service` (not `Requires=`) — the registry process is
technically independent of the daemon, but the web UI has nothing to show
without one connected, so we order this after `maestrod` and politely
encourage it.
## Running the wizard without the bootstrap script
If you'd rather skip the script: