diff --git a/crates/maestro-installer/src/lib.rs b/crates/maestro-installer/src/lib.rs index 9412eb8..5ee2128 100644 --- a/crates/maestro-installer/src/lib.rs +++ b/crates/maestro-installer/src/lib.rs @@ -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, +}; diff --git a/crates/maestro-installer/src/main.rs b/crates/maestro-installer/src/main.rs index 2cf12f1..f7fbcd2 100644 --- a/crates/maestro-installer/src/main.rs +++ b/crates/maestro-installer/src/main.rs @@ -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, + + /// Override the ExecStart path for `maestro-web.service`. + /// Default: `~/.cargo/bin/maestro-registry`. + #[arg(long, value_name = "PATH")] + web_exec_start: Option, + + /// 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> { // 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> { 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> { .ok_or_else(|| "could not resolve $HOME (needed for ExecStart)".into()) } +fn default_web_exec_start() -> Result> { + 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> { + 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> { 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 { }) } -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 `"); + if web_installed { + eprintln!(" • Open the local web UI: http://{web_bind}"); + eprintln!(" • Web status: `systemctl --user status maestro-web`"); + } } diff --git a/crates/maestro-installer/src/systemd.rs b/crates/maestro-installer/src/systemd.rs index a0b8592..8b82b6d 100644 --- a/crates/maestro-installer/src/systemd.rs +++ b/crates/maestro-installer/src/systemd.rs @@ -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 { ) } +/// `~/.config/systemd/user/maestro-web.service`. `None` if `$HOME` / config +/// dir is unresolvable. +pub fn web_user_unit_path() -> Option { + 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 { + 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:?}"); + } } diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 267dcaf..ca059aa 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -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 ` | 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 ` | Loopback bind for the web UI (default `127.0.0.1:8888`). | +| `--web-exec-start ` | 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: