From 2da41cc720d4a59f669859da732e9b6af5370d44 Mon Sep 17 00:00:00 2001 From: naps62-yolo Date: Mon, 25 May 2026 14:30:03 +0000 Subject: [PATCH] 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. --- CLAUDE.md | 3 + Cargo.lock | 10 + crates/maestro-installer/Cargo.toml | 23 ++ crates/maestro-installer/src/install.rs | 178 ++++++++++++++ crates/maestro-installer/src/lib.rs | 17 ++ crates/maestro-installer/src/main.rs | 301 ++++++++++++++++++++++++ crates/maestro-installer/src/systemd.rs | 102 ++++++++ docs/INSTALL.md | 77 ++++++ 8 files changed, 711 insertions(+) create mode 100644 crates/maestro-installer/Cargo.toml create mode 100644 crates/maestro-installer/src/install.rs create mode 100644 crates/maestro-installer/src/lib.rs create mode 100644 crates/maestro-installer/src/main.rs create mode 100644 crates/maestro-installer/src/systemd.rs create mode 100644 docs/INSTALL.md diff --git a/CLAUDE.md b/CLAUDE.md index ba48727..152ebbd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,10 +22,13 @@ crates/ ├── 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-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) web/ # browser client (NAP-218, plan 2) ``` +See `docs/INSTALL.md` for end-user install instructions. + ## Conventions - **TDD by default.** Failing test first, then minimal impl. Commit test + impl together. diff --git a/Cargo.lock b/Cargo.lock index 6051d2a..1a7cbd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -949,6 +949,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "maestro-installer" +version = "0.1.0" +dependencies = [ + "clap", + "dirs", + "tempfile", + "thiserror 1.0.69", +] + [[package]] name = "maestro-protocol" version = "0.1.0" diff --git a/crates/maestro-installer/Cargo.toml b/crates/maestro-installer/Cargo.toml new file mode 100644 index 0000000..98ffce0 --- /dev/null +++ b/crates/maestro-installer/Cargo.toml @@ -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 diff --git a/crates/maestro-installer/src/install.rs b/crates/maestro-installer/src/install.rs new file mode 100644 index 0000000..5b38b86 --- /dev/null +++ b/crates/maestro-installer/src/install.rs @@ -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 [--branch ] `. + Git { url: String, branch: Option }, + /// `cargo install --path -p `. + 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 ` (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 { + let mut args: Vec = 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 [unit]`. +pub fn systemctl_user_args(action: SystemctlAction, unit: &str) -> Vec { + 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"), + } + } +} diff --git a/crates/maestro-installer/src/lib.rs b/crates/maestro-installer/src/lib.rs new file mode 100644 index 0000000..9412eb8 --- /dev/null +++ b/crates/maestro-installer/src/lib.rs @@ -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}; diff --git a/crates/maestro-installer/src/main.rs b/crates/maestro-installer/src/main.rs new file mode 100644 index 0000000..3033c6c --- /dev/null +++ b/crates/maestro-installer/src/main.rs @@ -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, + + /// Branch to pin when installing via --git. + #[arg(long, requires = "git")] + branch: Option, + + /// Install from a local workspace checkout instead of git. + #[arg(long, value_name = "DIR")] + path: Option, + + /// 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, +} + +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> { + 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> { + 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> { + // 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> { + 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> { + 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> { + 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 { + 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 `"); +} diff --git a/crates/maestro-installer/src/systemd.rs b/crates/maestro-installer/src/systemd.rs new file mode 100644 index 0000000..a0b8592 --- /dev/null +++ b/crates/maestro-installer/src/systemd.rs @@ -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 { + 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 { + 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:?}"); + } +} diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..475d955 --- /dev/null +++ b/docs/INSTALL.md @@ -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 ` | Install from a different git repo (default: this one). | +| `--branch ` | Pin a branch with `--git`. | +| `--path ` | Install from a local workspace checkout (`--path` + `-p `). | +| `--no-locked` | Drop `--locked` from `cargo install` (resolves a fresh dep tree). | +| `--exec-start ` | 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.