feat(maestro): installer drops optional maestro-web systemd unit (NAP-249)
ci / rust (push) Failing after 7m18s

New helpers in maestro-installer::systemd:

* `render_web_unit` — same shape as the daemon unit, ordered after
  `maestrod.service` so the proxy doesn't 502 on startup.
* `user_web_unit_path` / `default_maestro_web_path` — under `~/.config/`
  and `~/.cargo/bin/` respectively.

New wizard step (NAP-241 deferred this; phone-dogfood on 2026-05-26
surfaced it): after the daemon+systemd flow, optionally
`cargo install maestro-web` and lay down
`~/.config/systemd/user/maestro-web.service`. Re-runs detect the existing
unit and refresh + restart silently.

New flags:

* `--no-web` — skip the prompt entirely (headless hosts).
* `--web-exec-start <path>` — override the unit's ExecStart.

Non-interactive (`--yes` / piped) skips the web step unless an existing
unit is being refreshed — operators opt in by re-running once
interactively, then subsequent unattended re-runs refresh automatically.

docs/INSTALL.md updated with the new flow + flags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 21:02:30 +00:00
co-authored by Claude Opus 4.7
parent 53cc008e3b
commit cc6e04ca5a
4 changed files with 276 additions and 24 deletions
+4 -1
View File
@@ -14,4 +14,7 @@ pub mod install;
pub mod systemd; pub mod systemd;
pub use install::{cargo_install_args, systemctl_user_args, CargoSource, SystemctlAction}; 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,
};
+159 -4
View File
@@ -16,13 +16,15 @@ use std::process::{Command, ExitCode};
use clap::Parser; use clap::Parser;
use maestro_installer::install::cargo_bin; use maestro_installer::install::cargo_bin;
use maestro_installer::{ use maestro_installer::{
cargo_install_args, is_systemd_host, render_user_unit, systemctl_user_args, user_unit_path, cargo_install_args, is_systemd_host, render_user_unit, render_web_unit, systemctl_user_args,
CargoSource, SystemctlAction, UNIT_FILE_NAME, 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 DEFAULT_GIT_URL: &str = "https://git.naps.pt/naps62/maestro";
const DAEMON_CRATE: &str = "maestro-daemon"; const DAEMON_CRATE: &str = "maestro-daemon";
const CLI_CRATE: &str = "maestro-cli"; const CLI_CRATE: &str = "maestro-cli";
const WEB_CRATE: &str = "maestro-web";
const SYSTEMD_PROBE: &str = "/run/systemd/system"; const SYSTEMD_PROBE: &str = "/run/systemd/system";
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
@@ -65,6 +67,16 @@ struct Args {
/// Default: `~/.cargo/bin/maestrod`. /// Default: `~/.cargo/bin/maestrod`.
#[arg(long, value_name = "PATH")] #[arg(long, value_name = "PATH")]
exec_start: Option<PathBuf>, exec_start: Option<PathBuf>,
/// 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<PathBuf>,
} }
fn main() -> ExitCode { fn main() -> ExitCode {
@@ -126,7 +138,15 @@ fn run(args: Args) -> Result<(), Box<dyn std::error::Error>> {
handle_systemd(&args, interactive, configured)?; 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(()) Ok(())
} }
@@ -148,6 +168,7 @@ fn print_banner(source: &CargoSource) {
eprintln!(" 1. cargo install both `{DAEMON_CRATE}` and `{CLI_CRATE}`"); eprintln!(" 1. cargo install both `{DAEMON_CRATE}` and `{CLI_CRATE}`");
eprintln!(" 2. (optional) run `maestrod configure` to bootstrap the config file"); 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 a systemd user unit and enable it");
eprintln!(" 4. (optional) install `{WEB_CRATE}` + its systemd unit (browser UI)");
eprintln!(); eprintln!();
match source { match source {
CargoSource::Git { url, branch } => { CargoSource::Git { url, branch } => {
@@ -318,6 +339,128 @@ fn default_exec_start() -> Result<PathBuf, Box<dyn std::error::Error>> {
.ok_or_else(|| "could not resolve $HOME (needed for ExecStart)".into()) .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_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<bool, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> { fn run_systemctl(action: SystemctlAction, unit: &str) -> Result<(), Box<dyn std::error::Error>> {
let args = systemctl_user_args(action, unit); let args = systemctl_user_args(action, unit);
eprintln!("\n==> systemctl {}", args.join(" ")); eprintln!("\n==> systemctl {}", args.join(" "));
@@ -342,7 +485,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) {
eprintln!(); eprintln!();
eprintln!("Done."); eprintln!("Done.");
eprintln!(); eprintln!();
@@ -357,4 +500,16 @@ fn print_summary(configured: bool, systemd_enabled: bool) {
eprintln!(" • Start the daemon: `maestrod`"); eprintln!(" • Start the daemon: `maestrod`");
} }
eprintln!(" • Set the CLI's registry: `maestro set-registry <URL>`"); eprintln!(" • Set the CLI's registry: `maestro set-registry <URL>`");
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/"
);
}
}
} }
+77 -7
View File
@@ -8,24 +8,38 @@ use std::path::{Path, PathBuf};
/// Standard probe path. Present on hosts booted under systemd. /// Standard probe path. Present on hosts booted under systemd.
pub const SYSTEMD_PROBE_PATH: &str = "/run/systemd/system"; 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"; 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 /// `true` when the given path exists. Production callers should pass
/// [`SYSTEMD_PROBE_PATH`]; tests inject a temp path so they're hermetic. /// [`SYSTEMD_PROBE_PATH`]; tests inject a temp path so they're hermetic.
pub fn is_systemd_host(probe: &Path) -> bool { pub fn is_systemd_host(probe: &Path) -> bool {
probe.exists() probe.exists()
} }
/// `~/.config/systemd/user/maestrod.service`. `None` if `$HOME` / config dir /// `~/.config/systemd/user/<unit>`. `None` if `$HOME` / config dir is
/// is unresolvable. /// unresolvable.
pub fn user_unit_path() -> Option<PathBuf> { pub fn user_unit_path_for(unit_name: &str) -> Option<PathBuf> {
let base = dirs::config_dir()?; 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` /// Convenience: path for `maestrod.service`. Kept as a free function so
/// — we default to `~/.cargo/bin/maestrod` because that's where `cargo install` /// existing callers don't need to thread the unit name around.
pub fn user_unit_path() -> Option<PathBuf> {
user_unit_path_for(UNIT_FILE_NAME)
}
/// Convenience: path for `maestro-web.service` (NAP-249).
pub fn user_web_unit_path() -> Option<PathBuf> {
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 /// lands binaries; systemd doesn't inherit the user's PATH, so we can't rely
/// on a bare `maestrod`. /// on a bare `maestrod`.
pub fn render_user_unit(exec_start: &Path) -> String { 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 /// Default `ExecStart` target: `~/.cargo/bin/maestrod`. `None` if `$HOME` is
/// unresolvable. /// unresolvable.
pub fn default_maestrod_path() -> Option<PathBuf> { pub fn default_maestrod_path() -> Option<PathBuf> {
@@ -58,6 +96,16 @@ pub fn default_maestrod_path() -> Option<PathBuf> {
) )
} }
/// Default `ExecStart` target for the web unit: `~/.cargo/bin/maestro-web`.
pub fn default_maestro_web_path() -> Option<PathBuf> {
Some(
dirs::home_dir()?
.join(".cargo")
.join("bin")
.join("maestro-web"),
)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -99,4 +147,26 @@ mod tests {
let p = user_unit_path().expect("config dir resolvable in test env"); let p = user_unit_path().expect("config dir resolvable in test env");
assert!(p.ends_with("systemd/user/maestrod.service"), "got {p:?}"); 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:?}");
}
} }
+36 -12
View File
@@ -51,26 +51,48 @@ Both binaries land in `~/.cargo/bin` — make sure that's on your `$PATH`.
## Adding the browser UI (optional) ## Adding the browser UI (optional)
`maestro-web` is a separate binary that serves the SPA bundle and reverse- `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 proxies API calls to a co-located `maestrod`.
browser UI:
**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 ```sh
pnpm -C web install --frozen-lockfile pnpm -C web install --frozen-lockfile
pnpm -C web build 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` **Defaults.** `bind_addr = 127.0.0.1:8080`, `daemon_url =
(the daemon's default `bind_addr`). The `[web]` section of http://127.0.0.1:7070`. The `[web]` section of
`~/.config/maestro/config.toml` overrides both — see `~/.config/maestro/config.toml` overrides both — see
`crates/maestro-web/README.md`. **Trust model:** anyone who can reach the bind `crates/maestro-web/README.md`.
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) install only `maestrod`. Hosts where you **Trust model.** Anyone who can reach the bind address can use it. Pick
want browser access install both. `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 ## Flags
@@ -83,7 +105,9 @@ want browser access install both.
| `--exec-start <path>` | Override the systemd unit's `ExecStart` (default `~/.cargo/bin/maestrod`). | | `--exec-start <path>` | Override the systemd unit's `ExecStart` (default `~/.cargo/bin/maestrod`). |
| `--no-systemd` | Skip the systemd step even if detected. | | `--no-systemd` | Skip the systemd step even if detected. |
| `--no-configure` | Skip the `maestrod configure` step. | | `--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 <path>` | 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 ## What the systemd unit looks like