ci / rust (push) Successful in 1m35s
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.
302 lines
9.4 KiB
Rust
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>`");
|
|
}
|