This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-installer/src/main.rs
T
Miguel Palhas c856333e4a
ci / rust (push) Successful in 2m40s
docs(maestro): describe the single-host architecture
Rewrites CLAUDE.md as the current-architecture reference, drops the
registry from INSTALL/PRODUCT/DESIGN and the web README, deletes the
registry Dockerfile, and marks the dated design specs superseded where
they describe the registry, JWT auth, or cross-host attach.

The installer loses its configure step — every config field now has a
default, so a daemon starts with no config file at all.
2026-08-22 10:26:49 +01:00

496 lines
16 KiB
Rust

//! `maestro-install` — interactive wizard that installs the unified `maestro`
//! binary via `cargo install` and optionally drops a systemd user unit.
//!
//! Intended bootstrap path:
//!
//! ```text
//! cargo install --git https://git.naps.pt/naps62/maestro maestro-installer
//! maestro-install
//! ```
use std::io::{self, BufRead, IsTerminal, Write};
use std::path::{Path, PathBuf};
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, 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";
/// Unified `maestro` crate, built with the full host feature set.
const MAESTRO_CRATE: &str = "maestro";
/// Features installed for a host: CLI + per-host daemon + TUI.
const MAESTRO_HOST_FEATURES: &str = "cli,daemon,tui";
const WEB_CRATE: &str = "maestro-web";
const SYSTEMD_PROBE: &str = "/run/systemd/system";
#[derive(Debug, Parser)]
#[command(
name = "maestro-install",
version,
about = "Install the unified `maestro` binary, and (optionally) a systemd user unit"
)]
struct Args {
/// Git URL to install from (default: this repo).
#[arg(long, conflicts_with = "path")]
git: Option<String>,
/// Branch to pin when installing via --git.
#[arg(long, requires = "git")]
branch: Option<String>,
/// Install from a local workspace checkout instead of git.
#[arg(long, value_name = "DIR")]
path: Option<PathBuf>,
/// Skip `--locked` when invoking `cargo install` (lets cargo resolve a
/// fresh dependency tree; useful for development).
#[arg(long)]
no_locked: bool,
/// Don't prompt — accept all defaults (install + systemd).
#[arg(long, short)]
yes: bool,
/// Skip the systemd user-unit step even if systemd is detected.
#[arg(long)]
no_systemd: bool,
/// Override the ExecStart path written to the systemd unit.
/// Default: `~/.cargo/bin/maestro` (invoked as `maestro daemon`).
#[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 {
let args = Args::parse();
match run(args) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("maestro-install: {e}");
ExitCode::FAILURE
}
}
}
fn run(args: Args) -> Result<(), Box<dyn std::error::Error>> {
let source = build_source(&args);
let interactive = !args.yes && io::stdin().is_terminal();
print_banner(&source);
if interactive && !confirm("Proceed?", true)? {
eprintln!("aborted");
return Ok(());
}
// 1. Install the unified binary (NAP-245).
let locked = !args.no_locked;
install_crate(&source, MAESTRO_CRATE, locked, Some(MAESTRO_HOST_FEATURES))?;
// 2. systemd integration.
let systemd_detected = is_systemd_host(Path::new(SYSTEMD_PROBE));
if args.no_systemd {
eprintln!("\nSkipping systemd step (--no-systemd).");
} else if !systemd_detected {
eprintln!("\nNo systemd detected ({SYSTEMD_PROBE} missing). Skipping unit install.");
} else {
handle_systemd(&args, interactive)?;
}
// 3. 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(systemd_detected && !args.no_systemd, web_installed);
Ok(())
}
fn build_source(args: &Args) -> CargoSource {
if let Some(path) = &args.path {
CargoSource::Path(path.clone())
} else {
CargoSource::Git {
url: args.git.clone().unwrap_or_else(|| DEFAULT_GIT_URL.into()),
branch: args.branch.clone(),
}
}
}
fn print_banner(source: &CargoSource) {
eprintln!("maestro installer");
eprintln!();
eprintln!("This wizard will:");
eprintln!(" 1. cargo install `{MAESTRO_CRATE}` (CLI + daemon + TUI)");
eprintln!(" 2. (optional) install a systemd user unit and enable it");
eprintln!(" 3. (optional) install `{WEB_CRATE}` + its systemd unit (browser UI)");
eprintln!();
match source {
CargoSource::Git { url, branch } => {
eprintln!("Source: --git {url}");
if let Some(b) = branch {
eprintln!(" --branch {b}");
}
}
CargoSource::Path(p) => eprintln!("Source: --path {}", p.display()),
}
eprintln!();
}
fn install_crate(
source: &CargoSource,
crate_name: &str,
locked: bool,
features: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let cargo = cargo_bin();
let args = cargo_install_args(source, crate_name, locked, features);
eprintln!("\n==> cargo install {}", args.join(" "));
let status = Command::new(&cargo).arg("install").args(&args).status()?;
if !status.success() {
return Err(format!("cargo install {crate_name} failed (exit {status})").into());
}
Ok(())
}
fn handle_systemd(args: &Args, interactive: bool) -> Result<(), Box<dyn std::error::Error>> {
let unit_path = user_unit_path().ok_or("could not resolve ~/.config/systemd/user/")?;
let exec_start = match &args.exec_start {
Some(p) => p.clone(),
None => default_exec_start()?,
};
eprintln!();
let already_installed = unit_path.exists();
if already_installed {
eprintln!(
"systemd unit already present at {} — refreshing.",
unit_path.display()
);
} else {
eprintln!("systemd detected.");
}
// First-install: ask before dropping the unit. Update: just refresh it.
let want_unit = if already_installed || !interactive {
true
} else {
confirm(
&format!("Install user unit at {}?", unit_path.display()),
true,
)?
};
if !want_unit {
return Ok(());
}
if let Some(parent) = unit_path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = render_user_unit(&exec_start);
std::fs::write(&unit_path, body)?;
eprintln!("wrote {}", unit_path.display());
run_systemctl(SystemctlAction::DaemonReload, UNIT_FILE_NAME)?;
if !already_installed {
let want_enable = if !interactive {
true
} else {
confirm("Enable maestro.service (start on login)?", true)?
};
if want_enable {
run_systemctl(SystemctlAction::Enable, UNIT_FILE_NAME)?;
}
}
// Update path: restart so the freshly-installed binary actually runs.
// First-install path: only start if the user has a config to start with.
let (action, want) = if already_installed {
let want = if !interactive {
true
} else {
confirm("Restart maestro.service to pick up the new binary?", true)?
};
(SystemctlAction::Restart, want)
} else {
let want = if !interactive {
false
} else {
confirm("Start maestro.service now?", true)?
};
(SystemctlAction::Start, want)
};
if want {
run_systemctl(action, UNIT_FILE_NAME)?;
}
Ok(())
}
fn default_exec_start() -> Result<PathBuf, Box<dyn std::error::Error>> {
maestro_installer::systemd::default_maestro_path()
.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())
}
/// What to do at the (opt-in) web step given whether pnpm + node are present.
#[derive(Debug, PartialEq, Eq)]
enum WebPrereq {
/// Toolchain present — install maestro-web normally.
Ok,
/// Missing and we can ask: prompt the operator to abort or skip.
PromptMissing,
/// Missing and we can't prompt (non-interactive refresh): warn + skip.
SkipMissing,
}
fn classify_web_prereq(toolchain_present: bool, interactive: bool) -> WebPrereq {
if toolchain_present {
WebPrereq::Ok
} else if interactive {
WebPrereq::PromptMissing
} else {
WebPrereq::SkipMissing
}
}
/// True iff both `pnpm` and `node` respond to `--version` on PATH. maestro-web's
/// build.rs needs them to build the embedded SPA bundle.
fn frontend_toolchain_present() -> bool {
["pnpm", "node"].iter().all(|tool| {
Command::new(tool)
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
})
}
/// 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 the local maestro daemon 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 on your LAN/VPN address."
);
confirm("Install maestro-web?", true)?
};
if !want_web {
return Ok(false);
}
// maestro-web's build.rs requires pnpm + node. Check before cargo install so
// a missing toolchain surfaces a clear choice instead of a raw build panic.
match classify_web_prereq(frontend_toolchain_present(), interactive) {
WebPrereq::Ok => {}
WebPrereq::PromptMissing => {
eprintln!("\nmaestro-web needs pnpm + node, which aren't installed.");
if confirm("Abort the whole install? (No = skip web UI)", false)? {
return Err("aborted: maestro-web prerequisites (pnpm + node) are missing".into());
}
eprintln!("Skipping maestro-web install.");
return Ok(false);
}
WebPrereq::SkipMissing => {
eprintln!(
"\nSkipping maestro-web refresh: pnpm + node not installed. \
Existing unit left in place."
);
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 daemon 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, None) {
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(" "));
let status = Command::new("systemctl").args(&args).status()?;
if !status.success() {
return Err(format!("systemctl {} failed (exit {status})", action.verb()).into());
}
Ok(())
}
fn confirm(prompt: &str, default_yes: bool) -> io::Result<bool> {
let suffix = if default_yes { "[Y/n]" } else { "[y/N]" };
print!("{prompt} {suffix} ");
io::stdout().flush()?;
let mut line = String::new();
io::stdin().lock().read_line(&mut line)?;
let trimmed = line.trim().to_ascii_lowercase();
Ok(match trimmed.as_str() {
"" => default_yes,
"y" | "yes" => true,
_ => false,
})
}
fn print_summary(systemd_enabled: bool, web_installed: bool) {
eprintln!();
eprintln!("Done.");
eprintln!();
eprintln!("Next steps:");
if systemd_enabled {
eprintln!(" • Check status: `systemctl --user status maestro`");
eprintln!(" • Logs: `journalctl --user -u maestro -f`");
} else {
eprintln!(" • Start the daemon: `maestro daemon`");
}
eprintln!(" • Open the TUI: `maestro tui`");
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/"
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prereq_ok_when_toolchain_present() {
assert_eq!(classify_web_prereq(true, true), WebPrereq::Ok);
assert_eq!(classify_web_prereq(true, false), WebPrereq::Ok);
}
#[test]
fn prereq_prompts_when_missing_and_interactive() {
assert_eq!(classify_web_prereq(false, true), WebPrereq::PromptMissing);
}
#[test]
fn prereq_skips_when_missing_and_noninteractive() {
assert_eq!(classify_web_prereq(false, false), WebPrereq::SkipMissing);
}
}