feat(maestro): add maestro-install wizard (cargo install + systemd)
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.
This commit is contained in:
2026-05-25 14:30:03 +00:00
parent 50f24a2cf9
commit 2da41cc720
8 changed files with 711 additions and 0 deletions
+3
View File
@@ -22,10 +22,13 @@ crates/
├── maestro-daemon/ # per-host tmux wrapper + WS endpoints (NAP-212, NAP-214) ├── maestro-daemon/ # per-host tmux wrapper + WS endpoints (NAP-212, NAP-214)
├── maestro-registry/ # always-on phone book + HTTP API (NAP-213, NAP-215) ├── maestro-registry/ # always-on phone book + HTTP API (NAP-213, NAP-215)
├── maestro-cli/ # `maestro list/spawn/attach/kill` (NAP-216) ├── maestro-cli/ # `maestro list/spawn/attach/kill` (NAP-216)
├── maestro-installer/ # `maestro-install` wizard: cargo install + systemd (NAP-225)
└── maestro-e2e/ # cross-process integration tests (NAP-217) └── maestro-e2e/ # cross-process integration tests (NAP-217)
web/ # browser client (NAP-218, plan 2) web/ # browser client (NAP-218, plan 2)
``` ```
See `docs/INSTALL.md` for end-user install instructions.
## Conventions ## Conventions
- **TDD by default.** Failing test first, then minimal impl. Commit test + impl together. - **TDD by default.** Failing test first, then minimal impl. Commit test + impl together.
Generated
+10
View File
@@ -949,6 +949,16 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "maestro-installer"
version = "0.1.0"
dependencies = [
"clap",
"dirs",
"tempfile",
"thiserror 1.0.69",
]
[[package]] [[package]]
name = "maestro-protocol" name = "maestro-protocol"
version = "0.1.0" version = "0.1.0"
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "maestro-installer"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
description = "Interactive install wizard for maestrod + maestro: `cargo install` both binaries and (on systemd hosts) drop a user unit"
[lib]
path = "src/lib.rs"
[[bin]]
name = "maestro-install"
path = "src/main.rs"
[dependencies]
clap.workspace = true
dirs.workspace = true
thiserror.workspace = true
[dev-dependencies]
tempfile.workspace = true
+178
View File
@@ -0,0 +1,178 @@
//! Builders for the external commands the wizard shells out to.
//!
//! Kept pure so unit tests can assert the exact argv we'd hand to
//! `std::process::Command` without ever exec'ing `cargo` or `systemctl`.
use std::path::PathBuf;
/// Where to fetch the maestro crates from when running `cargo install`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CargoSource {
/// `cargo install --git <url> [--branch <branch>] <crate>`.
Git { url: String, branch: Option<String> },
/// `cargo install --path <workspace_root> -p <crate>`.
Path(PathBuf),
}
/// `systemctl --user` verbs the wizard runs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SystemctlAction {
DaemonReload,
Enable,
Start,
}
impl SystemctlAction {
pub fn verb(self) -> &'static str {
match self {
Self::DaemonReload => "daemon-reload",
Self::Enable => "enable",
Self::Start => "start",
}
}
fn takes_unit_arg(self) -> bool {
!matches!(self, Self::DaemonReload)
}
}
/// Build the args for `cargo install <args...>` (excluding the `cargo` and
/// `install` words themselves). `locked` adds `--locked` so the install uses
/// the committed `Cargo.lock`.
pub fn cargo_install_args(source: &CargoSource, crate_name: &str, locked: bool) -> Vec<String> {
let mut args: Vec<String> = Vec::new();
match source {
CargoSource::Git { url, branch } => {
args.push("--git".into());
args.push(url.clone());
if let Some(b) = branch {
args.push("--branch".into());
args.push(b.clone());
}
args.push(crate_name.to_string());
}
CargoSource::Path(root) => {
args.push("--path".into());
args.push(root.display().to_string());
args.push("-p".into());
args.push(crate_name.to_string());
}
}
if locked {
args.push("--locked".into());
}
args
}
/// Build the args for `systemctl --user <verb> [unit]`.
pub fn systemctl_user_args(action: SystemctlAction, unit: &str) -> Vec<String> {
let mut args = vec!["--user".to_string(), action.verb().to_string()];
if action.takes_unit_arg() {
args.push(unit.to_string());
}
args
}
/// `cargo` binary path. We prefer `$CARGO` (set by `cargo install` itself when
/// it invokes child processes, and by `rustup`), then `~/.cargo/bin/cargo`, and
/// finally a bare `cargo` so PATH lookup applies.
pub fn cargo_bin() -> PathBuf {
if let Ok(p) = std::env::var("CARGO") {
return PathBuf::from(p);
}
if let Some(home) = dirs::home_dir() {
let in_cargo_home = home.join(".cargo").join("bin").join("cargo");
if in_cargo_home.exists() {
return in_cargo_home;
}
}
PathBuf::from("cargo")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn git_args_no_branch() {
let src = CargoSource::Git {
url: "https://git.naps.pt/naps62/maestro".into(),
branch: None,
};
let args = cargo_install_args(&src, "maestro-daemon", false);
assert_eq!(
args,
vec![
"--git",
"https://git.naps.pt/naps62/maestro",
"maestro-daemon",
]
);
}
#[test]
fn git_args_with_branch_and_locked() {
let src = CargoSource::Git {
url: "https://git.example/x".into(),
branch: Some("main".into()),
};
let args = cargo_install_args(&src, "maestro-cli", true);
assert_eq!(
args,
vec![
"--git",
"https://git.example/x",
"--branch",
"main",
"maestro-cli",
"--locked",
]
);
}
#[test]
fn path_args_use_p_flag() {
let src = CargoSource::Path(PathBuf::from("/repos/maestro"));
let args = cargo_install_args(&src, "maestro-daemon", true);
assert_eq!(
args,
vec![
"--path",
"/repos/maestro",
"-p",
"maestro-daemon",
"--locked",
]
);
}
#[test]
fn systemctl_daemon_reload_omits_unit() {
let args = systemctl_user_args(SystemctlAction::DaemonReload, "maestrod.service");
assert_eq!(args, vec!["--user", "daemon-reload"]);
}
#[test]
fn systemctl_enable_includes_unit() {
let args = systemctl_user_args(SystemctlAction::Enable, "maestrod.service");
assert_eq!(args, vec!["--user", "enable", "maestrod.service"]);
}
#[test]
fn systemctl_start_includes_unit() {
let args = systemctl_user_args(SystemctlAction::Start, "maestrod.service");
assert_eq!(args, vec!["--user", "start", "maestrod.service"]);
}
#[test]
fn cargo_bin_honors_env_var() {
// Save & restore $CARGO to keep the test hermetic.
let prev = std::env::var("CARGO").ok();
std::env::set_var("CARGO", "/opt/rust/bin/cargo");
assert_eq!(cargo_bin(), PathBuf::from("/opt/rust/bin/cargo"));
match prev {
Some(v) => std::env::set_var("CARGO", v),
None => std::env::remove_var("CARGO"),
}
}
}
+17
View File
@@ -0,0 +1,17 @@
//! `maestro-installer` — install wizard for maestrod + maestro.
//!
//! See `docs/INSTALL.md` for the user-facing flow. This crate exposes the
//! testable pieces:
//!
//! - [`systemd`] — detect a systemd host and render a user unit.
//! - [`install`] — build the `cargo install` and `systemctl --user` commands.
//!
//! The binary in `src/main.rs` glues these together with stdin prompts.
#![deny(missing_debug_implementations)]
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};
+301
View File
@@ -0,0 +1,301 @@
//! `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>`");
}
+102
View File
@@ -0,0 +1,102 @@
//! systemd detection + user-unit rendering.
//!
//! The wizard only ever writes a *user* unit (`~/.config/systemd/user/`), since
//! `maestrod` runs per-user and uses tmux sockets in the user's runtime dir.
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/`.
pub const UNIT_FILE_NAME: &str = "maestrod.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> {
let base = dirs::config_dir()?;
Some(base.join("systemd").join("user").join(UNIT_FILE_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`
/// 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 {
format!(
"[Unit]\n\
Description=Maestro per-host daemon (maestrod)\n\
Documentation=https://git.naps.pt/naps62/maestro\n\
After=network-online.target\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> {
Some(
dirs::home_dir()?
.join(".cargo")
.join("bin")
.join("maestrod"),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_systemd_host_true_when_probe_exists() {
let dir = tempfile::tempdir().unwrap();
let probe = dir.path().join("system");
std::fs::create_dir(&probe).unwrap();
assert!(is_systemd_host(&probe));
}
#[test]
fn is_systemd_host_false_when_probe_missing() {
let dir = tempfile::tempdir().unwrap();
let probe = dir.path().join("not-here");
assert!(!is_systemd_host(&probe));
}
#[test]
fn unit_renders_with_absolute_exec_start() {
let body = render_user_unit(Path::new("/home/alice/.cargo/bin/maestrod"));
assert!(body.contains("ExecStart=/home/alice/.cargo/bin/maestrod"));
assert!(body.contains("[Unit]"));
assert!(body.contains("[Service]"));
assert!(body.contains("[Install]"));
assert!(body.contains("WantedBy=default.target"));
assert!(body.contains("Restart=on-failure"));
}
#[test]
fn unit_ends_with_newline() {
let body = render_user_unit(Path::new("/x"));
assert!(body.ends_with('\n'), "unit must end with newline");
}
#[test]
fn user_unit_path_points_under_config_dir() {
let p = user_unit_path().expect("config dir resolvable in test env");
assert!(p.ends_with("systemd/user/maestrod.service"), "got {p:?}");
}
}
+77
View File
@@ -0,0 +1,77 @@
# Installing Maestro
The fastest way to get `maestrod` (per-host daemon) and `maestro` (CLI) onto a
host is the install wizard:
```sh
cargo install --git https://git.naps.pt/naps62/maestro maestro-installer
maestro-install
```
The wizard:
1. Runs `cargo install` for `maestro-daemon` and `maestro-cli` (pinned to the
committed `Cargo.lock` via `--locked`).
2. Optionally runs `maestrod configure` to drop a `~/.config/maestrod/config.toml`.
3. On systemd hosts, optionally writes a user unit at
`~/.config/systemd/user/maestrod.service`, reloads systemd, and offers to
enable + start it.
Both binaries land in `~/.cargo/bin` — make sure that's on your `$PATH`.
## Flags
| Flag | Purpose |
|---|---|
| `--git <url>` | Install from a different git repo (default: this one). |
| `--branch <name>` | Pin a branch with `--git`. |
| `--path <dir>` | Install from a local workspace checkout (`--path` + `-p <crate>`). |
| `--no-locked` | Drop `--locked` from `cargo install` (resolves a fresh dep tree). |
| `--exec-start <path>` | Override the systemd unit's `ExecStart` (default `~/.cargo/bin/maestrod`). |
| `--no-systemd` | Skip the systemd step even if detected. |
| `--no-configure` | Skip the `maestrod configure` step. |
| `-y`, `--yes` | Non-interactive: install binaries + drop systemd unit + reload + enable, but **don't** auto-run `configure` or auto-start the unit (those need real config). |
## What the systemd unit looks like
The wizard writes a *user* unit (`systemctl --user`), since `maestrod` runs
per-user and uses tmux sockets under `$XDG_RUNTIME_DIR`. Body:
```ini
[Unit]
Description=Maestro per-host daemon (maestrod)
Documentation=https://git.naps.pt/naps62/maestro
After=network-online.target
[Service]
Type=simple
ExecStart=%h/.cargo/bin/maestrod
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
```
Check status / logs:
```sh
systemctl --user status maestrod
journalctl --user -u maestrod -f
```
## Manual install (no wizard)
```sh
cargo install --git https://git.naps.pt/naps62/maestro --locked maestro-daemon
cargo install --git https://git.naps.pt/naps62/maestro --locked maestro-cli
maestrod configure
maestrod # foreground; or wire your own service manager
```
## Non-Linux hosts
The wizard works on macOS for the `cargo install` + `maestrod configure` parts.
The systemd step is automatically skipped (probe of `/run/systemd/system`).
launchd integration is not yet supported — start `maestrod` from your shell or
your own service manager.