diff --git a/crates/maestro-installer/src/lib.rs b/crates/maestro-installer/src/lib.rs index 9412eb8..8ff32c4 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_unit, user_unit_path, user_unit_path_for, + user_web_unit_path, UNIT_FILE_NAME, WEB_UNIT_FILE_NAME, +}; diff --git a/crates/maestro-installer/src/main.rs b/crates/maestro-installer/src/main.rs index b966b88..f7af18f 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_unit, systemctl_user_args, + user_unit_path, user_web_unit_path, CargoSource, SystemctlAction, 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 WEB_CRATE: &str = "maestro-web"; const SYSTEMD_PROBE: &str = "/run/systemd/system"; #[derive(Debug, Parser)] @@ -65,6 +67,16 @@ struct Args { /// Default: `~/.cargo/bin/maestrod`. #[arg(long, value_name = "PATH")] exec_start: Option, + + /// Skip the optional `maestro-web` (browser UI) install step. + /// Useful for headless hosts. + #[arg(long)] + no_web: bool, + + /// Override the ExecStart path for the `maestro-web` systemd unit. + /// Default: `~/.cargo/bin/maestro-web`. + #[arg(long, value_name = "PATH")] + web_exec_start: Option, } fn main() -> ExitCode { @@ -126,7 +138,15 @@ fn run(args: Args) -> Result<(), Box> { handle_systemd(&args, interactive, configured)?; } - print_summary(configured, systemd_detected && !args.no_systemd); + // 4. Optional: maestro-web (NAP-249). Re-runs auto-detect an existing + // install and refresh rather than re-prompting, matching the daemon flow. + let web_installed = handle_web(&args, interactive, systemd_detected)?; + + print_summary( + configured, + systemd_detected && !args.no_systemd, + web_installed, + ); Ok(()) } @@ -148,6 +168,7 @@ fn print_banner(source: &CargoSource) { 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!(" 4. (optional) install `{WEB_CRATE}` + its systemd unit (browser UI)"); eprintln!(); match source { CargoSource::Git { url, branch } => { @@ -318,6 +339,128 @@ 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_web_path() + .ok_or_else(|| "could not resolve $HOME (needed for web ExecStart)".into()) +} + +/// Optional step: install `maestro-web` + its systemd user unit (NAP-249). +/// Returns `true` iff the binary was installed (with or without systemd). +fn handle_web( + args: &Args, + interactive: bool, + systemd_detected: bool, +) -> Result> { + if args.no_web { + eprintln!("\nSkipping web UI step (--no-web)."); + return Ok(false); + } + let web_unit_path = user_web_unit_path(); + let unit_present = web_unit_path.as_ref().map(|p| p.exists()).unwrap_or(false); + + // On re-runs the unit is already there — refresh silently. On first run + // ask interactively (default yes), or skip on --yes / non-TTY unless the + // unit is present (refresh path). + let want_web = if unit_present { + eprintln!( + "\nDetected existing maestro-web unit at {} — refreshing.", + web_unit_path.as_ref().unwrap().display() + ); + true + } else if !interactive { + // Non-interactive (--yes or piped) with no existing unit: skip by + // default. Operators opt in by re-running once interactively. + eprintln!( + "\nSkipping maestro-web install (non-interactive). \ + Re-run without --yes to set it up, or pass --no-web to silence." + ); + return Ok(false); + } else { + eprintln!( + "\nOptional: install the browser UI (`maestro-web`)?\n \ + Reverse-proxies a local maestrod and serves the SPA at \ + http://127.0.0.1:8080/ by default.\n \ + Edit [web].bind_addr in ~/.config/maestro/config.toml \ + to expose it to your perimeter (e.g. WireGuard IP)." + ); + confirm("Install maestro-web?", true)? + }; + if !want_web { + return Ok(false); + } + + // Install the binary. Even if cargo-install fails we keep going so the + // operator can fix the cause and re-run; the existing maestrod work + // doesn't need to be undone. + let source = build_source(args); + let locked = !args.no_locked; + if let Err(e) = install_crate(&source, WEB_CRATE, locked) { + eprintln!("maestro-web install failed: {e}"); + eprintln!("(skipping systemd unit; re-run after fixing the build.)"); + return Ok(false); + } + + if args.no_systemd || !systemd_detected { + eprintln!( + "\nmaestro-web installed. Start it manually with `maestro-web` \ + (no systemd unit written)." + ); + return Ok(true); + } + + let unit_path = web_unit_path.expect("user_web_unit_path resolves under config_dir"); + 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_unit(&exec_start); + std::fs::write(&unit_path, body)?; + eprintln!("wrote {}", unit_path.display()); + + run_systemctl(SystemctlAction::DaemonReload, WEB_UNIT_FILE_NAME)?; + + if !unit_present { + 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)?; + } + } + + // Update path → restart; first-install → start (always safe; web only + // needs the daemon's HTTP surface, which exists from NAP-243). + let (action, want) = if unit_present { + 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(" ")); @@ -342,7 +485,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) { eprintln!(); eprintln!("Done."); eprintln!(); @@ -357,4 +500,16 @@ 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 { + if systemd_enabled { + eprintln!( + " • Browser UI: open http://127.0.0.1:8080/ \ + (or your [web].bind_addr from config.toml)" + ); + } else { + eprintln!( + " • Browser UI: start with `maestro-web` and open http://127.0.0.1:8080/" + ); + } + } } diff --git a/crates/maestro-installer/src/systemd.rs b/crates/maestro-installer/src/systemd.rs index a0b8592..07511c4 100644 --- a/crates/maestro-installer/src/systemd.rs +++ b/crates/maestro-installer/src/systemd.rs @@ -8,24 +8,38 @@ use std::path::{Path, PathBuf}; /// Standard probe path. Present on hosts booted under systemd. pub const SYSTEMD_PROBE_PATH: &str = "/run/systemd/system"; -/// Filename written under `~/.config/systemd/user/`. +/// Filename written under `~/.config/systemd/user/` for the daemon. pub const UNIT_FILE_NAME: &str = "maestrod.service"; +/// Filename for the optional `maestro-web` browser UI unit (NAP-249). +pub const WEB_UNIT_FILE_NAME: &str = "maestro-web.service"; + /// `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 { probe.exists() } -/// `~/.config/systemd/user/maestrod.service`. `None` if `$HOME` / config dir -/// is unresolvable. -pub fn user_unit_path() -> Option { +/// `~/.config/systemd/user/`. `None` if `$HOME` / config dir is +/// unresolvable. +pub fn user_unit_path_for(unit_name: &str) -> Option { let base = dirs::config_dir()?; - Some(base.join("systemd").join("user").join(UNIT_FILE_NAME)) + Some(base.join("systemd").join("user").join(unit_name)) } -/// Render the unit file body. `exec_start` is the absolute path to `maestrod` -/// — we default to `~/.cargo/bin/maestrod` because that's where `cargo install` +/// Convenience: path for `maestrod.service`. Kept as a free function so +/// existing callers don't need to thread the unit name around. +pub fn user_unit_path() -> Option { + user_unit_path_for(UNIT_FILE_NAME) +} + +/// Convenience: path for `maestro-web.service` (NAP-249). +pub fn user_web_unit_path() -> Option { + user_unit_path_for(WEB_UNIT_FILE_NAME) +} + +/// Render the daemon unit. `exec_start` is the absolute path to `maestrod` — +/// we default to `~/.cargo/bin/maestrod` because that's where `cargo install` /// lands binaries; systemd doesn't inherit the user's PATH, so we can't rely /// on a bare `maestrod`. pub fn render_user_unit(exec_start: &Path) -> String { @@ -47,6 +61,30 @@ pub fn render_user_unit(exec_start: &Path) -> String { ) } +/// Render the `maestro-web` user unit (NAP-249). Orders after `maestrod` +/// so the proxy doesn't return 502s on startup; `Wants=` keeps it best-effort +/// — operators with daemon-less hosts can still install web alone if they +/// later remove the dependency. +pub fn render_web_unit(exec_start: &Path) -> String { + format!( + "[Unit]\n\ + Description=Maestro web UI (maestro-web)\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\ + ExecStart={exec}\n\ + Restart=on-failure\n\ + RestartSec=5\n\ + \n\ + [Install]\n\ + WantedBy=default.target\n", + exec = exec_start.display() + ) +} + /// Default `ExecStart` target: `~/.cargo/bin/maestrod`. `None` if `$HOME` is /// unresolvable. pub fn default_maestrod_path() -> Option { @@ -58,6 +96,16 @@ pub fn default_maestrod_path() -> Option { ) } +/// Default `ExecStart` target for the web unit: `~/.cargo/bin/maestro-web`. +pub fn default_maestro_web_path() -> Option { + Some( + dirs::home_dir()? + .join(".cargo") + .join("bin") + .join("maestro-web"), + ) +} + #[cfg(test)] mod tests { use super::*; @@ -99,4 +147,26 @@ 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 user_web_unit_path_points_under_config_dir() { + let p = user_web_unit_path().expect("config dir resolvable in test env"); + assert!(p.ends_with("systemd/user/maestro-web.service"), "got {p:?}"); + } + + #[test] + fn web_unit_renders_with_dependency_on_daemon() { + let body = render_web_unit(Path::new("/home/alice/.cargo/bin/maestro-web")); + assert!(body.contains("ExecStart=/home/alice/.cargo/bin/maestro-web")); + assert!(body.contains("After=network-online.target maestrod.service")); + assert!(body.contains("Wants=maestrod.service")); + assert!(body.contains("WantedBy=default.target")); + assert!(body.ends_with('\n')); + } + + #[test] + fn default_maestro_web_path_under_cargo_bin() { + let p = default_maestro_web_path().expect("HOME resolvable in test env"); + assert!(p.ends_with(".cargo/bin/maestro-web"), "got {p:?}"); + } } diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 5058a31..7d165ec 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -51,26 +51,48 @@ Both binaries land in `~/.cargo/bin` — make sure that's on your `$PATH`. ## Adding the browser UI (optional) `maestro-web` is a separate binary that serves the SPA bundle and reverse- -proxies API calls to a co-located `maestrod`. Install it where you want a -browser UI: +proxies API calls to a co-located `maestrod`. + +**Interactive install (default path).** The wizard now offers it as an +optional step right after the daemon flow: + +```text +Optional: install the browser UI (`maestro-web`)? +Install maestro-web? [Y/n] +``` + +Press `Y` and it `cargo install`s `maestro-web`, drops a +`~/.config/systemd/user/maestro-web.service` unit (ordered after +`maestrod.service`), reloads systemd, and offers to enable + start it. Re-runs +of `./install.sh` detect the existing unit and refresh + restart silently. + +Pass `--no-web` to skip the prompt entirely (headless hosts), or `--yes` +with `--no-web` for non-interactive daemon-only installs. (Bare `--yes` +without `--no-web` *skips* the web step too — opt in by re-running once +interactively.) + +**Frontend build prerequisite.** `maestro-web` embeds the SPA bundle at +compile time, so before running the installer: ```sh pnpm -C web install --frozen-lockfile pnpm -C web build -cargo install --git https://git.naps.pt/naps62/maestro --locked maestro-web ``` -…or, from a checkout, `just web` then `cargo install --path crates/maestro-web --locked`. +Or `just web` from a workspace checkout. -It defaults to binding `127.0.0.1:8080` and proxying to `http://127.0.0.1:7070` -(the daemon's default `bind_addr`). The `[web]` section of +**Defaults.** `bind_addr = 127.0.0.1:8080`, `daemon_url = +http://127.0.0.1:7070`. The `[web]` section of `~/.config/maestro/config.toml` overrides both — see -`crates/maestro-web/README.md`. **Trust model:** anyone who can reach the bind -address can use it; pick `127.0.0.1` for local-only, a WireGuard/LAN address -for one-segment access, never a public IP without adding auth first. +`crates/maestro-web/README.md`. -Headless hosts (servers, dev VMs) install only `maestrod`. Hosts where you -want browser access install both. +**Trust model.** Anyone who can reach the bind address can use it. Pick +`127.0.0.1` for local-only, a WireGuard/LAN address for one-segment access, +never a public IP without adding auth first. + +Headless hosts (servers, dev VMs) skip the prompt with `--no-web`. Hosts +where you want browser access answer yes — both binaries end up in +`~/.cargo/bin`. ## Flags @@ -83,7 +105,9 @@ want browser access install both. | `--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. | -| `-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). | +| `--no-web` | Skip the optional `maestro-web` install step. | +| `--web-exec-start ` | Override the web unit's `ExecStart` (default `~/.cargo/bin/maestro-web`). | +| `-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). Also skips the web step unless an existing `maestro-web.service` is being refreshed. | ## What the systemd unit looks like