feat(maestro): add maestro-install wizard (cargo install + systemd)
ci / rust (push) Successful in 1m35s
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:
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
@@ -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>`");
|
||||||
|
}
|
||||||
@@ -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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user