ci / rust (push) Successful in 2m40s
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.
496 lines
16 KiB
Rust
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);
|
|
}
|
|
}
|