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 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 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<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 {
@@ -126,7 +138,15 @@ 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: 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<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_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>> {
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<bool> {
})
}
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 <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.
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<PathBuf> {
/// `~/.config/systemd/user/<unit>`. `None` if `$HOME` / config dir is
/// unresolvable.
pub fn user_unit_path_for(unit_name: &str) -> Option<PathBuf> {
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<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
/// 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<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)]
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:?}");
}
}