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
naps62-yolo 2da41cc720
ci / rust (push) Successful in 1m35s
feat(maestro): add maestro-install wizard (cargo install + systemd)
New `maestro-installer` crate exposing the `maestro-install` binary. Bootstrap
flow: `cargo install --git ... maestro-installer && maestro-install` runs
`cargo install` for `maestro-daemon` and `maestro-cli`, optionally drives
`maestrod configure`, and on systemd hosts writes a user unit at
`~/.config/systemd/user/maestrod.service` plus reload/enable/start. Pure
builders (cargo install args, systemctl args, unit body) live in the lib and
are unit-tested; main.rs is the prompt glue.

Refs NAP-225.
2026-05-25 14:30:03 +00:00

302 lines
9.4 KiB
Rust

//! `maestro-install` — interactive wizard that installs `maestrod` + `maestro`
//! via `cargo install`, optionally drops a systemd user unit, and optionally
//! kicks off `maestrod configure`.
//!
//! 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, systemctl_user_args, user_unit_path,
CargoSource, SystemctlAction, 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 SYSTEMD_PROBE: &str = "/run/systemd/system";
#[derive(Debug, Parser)]
#[command(
name = "maestro-install",
version,
about = "Install maestrod + maestro CLI, 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 + configure).
#[arg(long, short)]
yes: bool,
/// Skip the systemd user-unit step even if systemd is detected.
#[arg(long)]
no_systemd: bool,
/// Skip the `maestrod configure` step.
#[arg(long)]
no_configure: bool,
/// Override the ExecStart path written to the systemd unit.
/// Default: `~/.cargo/bin/maestrod`.
#[arg(long, value_name = "PATH")]
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 both binaries.
let locked = !args.no_locked;
install_crate(&source, DAEMON_CRATE, locked)?;
install_crate(&source, CLI_CRATE, locked)?;
// 2. Offer to run `maestrod configure` now (must happen before systemd
// start, since the daemon refuses to run without registry_url / token).
// In non-interactive mode (--yes / no TTY) we skip — `maestrod configure`
// would prompt for required fields and we don't want to surprise scripts.
let want_configure = interactive
&& !args.no_configure
&& confirm(
"Run `maestrod configure` now (writes ~/.config/maestrod/config.toml)?",
true,
)?;
let configured = if want_configure {
run_configure_passthrough()?
} else {
false
};
// 3. 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, configured)?;
}
print_summary(configured, systemd_detected && !args.no_systemd);
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 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!();
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,
) -> Result<(), Box<dyn std::error::Error>> {
let cargo = cargo_bin();
let args = cargo_install_args(source, crate_name, locked);
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 run_configure_passthrough() -> Result<bool, Box<dyn std::error::Error>> {
// Run the freshly-installed `maestrod configure` interactively. We don't
// hard-code the path; rely on it being on PATH after `cargo install`.
eprintln!("\n==> maestrod configure");
let status = Command::new("maestrod").arg("configure").status();
match status {
Ok(s) if s.success() => Ok(true),
Ok(s) => {
eprintln!("`maestrod configure` exited {s}");
Ok(false)
}
Err(e) => {
eprintln!(
"couldn't launch `maestrod`: {e}. Make sure ~/.cargo/bin is on PATH, \
then run `maestrod configure` manually."
);
Ok(false)
}
}
}
fn handle_systemd(
args: &Args,
interactive: bool,
configured: 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!();
eprintln!("systemd detected.");
let want_unit = if !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)?;
let want_enable = if !interactive {
true
} else {
confirm("Enable maestrod.service (start on login)?", true)?
};
if want_enable {
run_systemctl(SystemctlAction::Enable, UNIT_FILE_NAME)?;
}
// Auto-start only when the user actually ran configure this session;
// otherwise the daemon will fail immediately for lack of credentials.
let want_start = if !interactive {
false
} else if !configured {
eprintln!(
"Note: starting now will fail until you run `maestrod configure` \
(no registry_url / token yet)."
);
confirm("Start maestrod.service now anyway?", false)?
} else {
confirm("Start maestrod.service now?", true)?
};
if want_start {
run_systemctl(SystemctlAction::Start, UNIT_FILE_NAME)?;
}
Ok(())
}
fn default_exec_start() -> Result<PathBuf, Box<dyn std::error::Error>> {
maestro_installer::systemd::default_maestrod_path()
.ok_or_else(|| "could not resolve $HOME (needed for ExecStart)".into())
}
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(configured: bool, systemd_enabled: bool) {
eprintln!();
eprintln!("Done.");
eprintln!();
eprintln!("Next steps:");
if !configured {
eprintln!(" • Run `maestrod configure` to bootstrap your config file.");
}
if systemd_enabled {
eprintln!(" • Check status: `systemctl --user status maestrod`");
eprintln!(" • Logs: `journalctl --user -u maestrod -f`");
} else {
eprintln!(" • Start the daemon: `maestrod`");
}
eprintln!(" • Set the CLI's registry: `maestro set-registry <URL>`");
}