feat(maestro): make installer re-run safe (auto-detect = update)
ci / rust (push) Successful in 1m28s

The wizard now treats existing state as "we're updating, not installing":

- If `~/.config/maestrod/config.toml` exists, skip the configure prompt with
  a clear notice. Sets `configured = true` so the systemd start logic still
  makes sense.
- If the systemd user unit already exists, refresh the file in place, skip
  the enable prompt (it's either already enabled or the user said no last
  time), and ask to `restart` instead of `start` — so the new binary
  actually takes effect.

Adds `SystemctlAction::Restart` and a test for it. Same `./install.sh` works
for both first install and subsequent updates; no `--update` flag.

Refs NAP-225.
This commit is contained in:
2026-05-25 15:07:09 +00:00
parent ae4a547548
commit 476a414c6f
3 changed files with 92 additions and 34 deletions
+8
View File
@@ -20,6 +20,7 @@ pub enum SystemctlAction {
DaemonReload, DaemonReload,
Enable, Enable,
Start, Start,
Restart,
} }
impl SystemctlAction { impl SystemctlAction {
@@ -28,6 +29,7 @@ impl SystemctlAction {
Self::DaemonReload => "daemon-reload", Self::DaemonReload => "daemon-reload",
Self::Enable => "enable", Self::Enable => "enable",
Self::Start => "start", Self::Start => "start",
Self::Restart => "restart",
} }
} }
@@ -164,6 +166,12 @@ mod tests {
assert_eq!(args, vec!["--user", "start", "maestrod.service"]); assert_eq!(args, vec!["--user", "start", "maestrod.service"]);
} }
#[test]
fn systemctl_restart_includes_unit() {
let args = systemctl_user_args(SystemctlAction::Restart, "maestrod.service");
assert_eq!(args, vec!["--user", "restart", "maestrod.service"]);
}
#[test] #[test]
fn cargo_bin_honors_env_var() { fn cargo_bin_honors_env_var() {
// Save & restore $CARGO to keep the test hermetic. // Save & restore $CARGO to keep the test hermetic.
+62 -17
View File
@@ -96,18 +96,23 @@ fn run(args: Args) -> Result<(), Box<dyn std::error::Error>> {
// 2. Offer to run `maestrod configure` now (must happen before systemd // 2. Offer to run `maestrod configure` now (must happen before systemd
// start, since the daemon refuses to run without registry_url / token). // start, since the daemon refuses to run without registry_url / token).
// In non-interactive mode (--yes / no TTY) we skip — `maestrod configure` // Re-runs of the wizard auto-skip when the config already exists; that's
// would prompt for required fields and we don't want to surprise scripts. // what makes "re-run to update" pleasant. In non-interactive mode (--yes /
let want_configure = interactive // no TTY) we also skip — `maestrod configure` would prompt for required
&& !args.no_configure // fields and we don't want to surprise scripts.
&& confirm( let configured = if let Some(path) = maestrod_config_path() {
"Run `maestrod configure` now (writes ~/.config/maestrod/config.toml)?", if path.exists() {
true, eprintln!(
)?; "\nconfig already exists at {} — skipping configure step.",
let configured = if want_configure { path.display()
run_configure_passthrough()? );
eprintln!("(run `maestrod configure --force` to rewrite it.)");
true
} else { } else {
false run_configure_step(&args, interactive)?
}
} else {
run_configure_step(&args, interactive)?
}; };
// 3. systemd integration. // 3. systemd integration.
@@ -170,6 +175,24 @@ fn install_crate(
Ok(()) Ok(())
} }
fn maestrod_config_path() -> Option<PathBuf> {
Some(dirs::config_dir()?.join("maestrod").join("config.toml"))
}
fn run_configure_step(args: &Args, interactive: bool) -> Result<bool, Box<dyn std::error::Error>> {
let want = interactive
&& !args.no_configure
&& confirm(
"Run `maestrod configure` now (writes ~/.config/maestrod/config.toml)?",
true,
)?;
if want {
run_configure_passthrough()
} else {
Ok(false)
}
}
fn run_configure_passthrough() -> Result<bool, Box<dyn std::error::Error>> { fn run_configure_passthrough() -> Result<bool, Box<dyn std::error::Error>> {
// Run the freshly-installed `maestrod configure` interactively. We don't // Run the freshly-installed `maestrod configure` interactively. We don't
// hard-code the path; rely on it being on PATH after `cargo install`. // hard-code the path; rely on it being on PATH after `cargo install`.
@@ -203,8 +226,18 @@ fn handle_systemd(
}; };
eprintln!(); eprintln!();
let already_installed = unit_path.exists();
if already_installed {
eprintln!(
"systemd unit already present at {} — refreshing.",
unit_path.display()
);
} else {
eprintln!("systemd detected."); eprintln!("systemd detected.");
let want_unit = if !interactive { }
// First-install: ask before dropping the unit. Update: just refresh it.
let want_unit = if already_installed || !interactive {
true true
} else { } else {
confirm( confirm(
@@ -225,6 +258,7 @@ fn handle_systemd(
run_systemctl(SystemctlAction::DaemonReload, UNIT_FILE_NAME)?; run_systemctl(SystemctlAction::DaemonReload, UNIT_FILE_NAME)?;
if !already_installed {
let want_enable = if !interactive { let want_enable = if !interactive {
true true
} else { } else {
@@ -233,10 +267,19 @@ fn handle_systemd(
if want_enable { if want_enable {
run_systemctl(SystemctlAction::Enable, UNIT_FILE_NAME)?; run_systemctl(SystemctlAction::Enable, UNIT_FILE_NAME)?;
} }
}
// Auto-start only when the user actually ran configure this session; // Update path: restart so the freshly-installed binary actually runs.
// otherwise the daemon will fail immediately for lack of credentials. // First-install path: only start if the user has a config to start with.
let want_start = if !interactive { let (action, want) = if already_installed {
let want = if !interactive {
true
} else {
confirm("Restart maestrod.service to pick up the new binary?", true)?
};
(SystemctlAction::Restart, want)
} else {
let want = if !interactive {
false false
} else if !configured { } else if !configured {
eprintln!( eprintln!(
@@ -247,8 +290,10 @@ fn handle_systemd(
} else { } else {
confirm("Start maestrod.service now?", true)? confirm("Start maestrod.service now?", true)?
}; };
if want_start { (SystemctlAction::Start, want)
run_systemctl(SystemctlAction::Start, UNIT_FILE_NAME)?; };
if want {
run_systemctl(action, UNIT_FILE_NAME)?;
} }
Ok(()) Ok(())
+6 -1
View File
@@ -14,7 +14,12 @@ The repo is private, so grab `install.sh` while signed in to gitea, then run it:
The bootstrap script builds `maestro-installer` into a temp directory, runs the The bootstrap script builds `maestro-installer` into a temp directory, runs the
wizard, and cleans up — only `maestrod` and `maestro` end up persisted in wizard, and cleans up — only `maestrod` and `maestro` end up persisted in
`~/.cargo/bin`. Re-run any time to update; `cargo install` overwrites in place. `~/.cargo/bin`.
**Updating is the same command.** Re-running `./install.sh` detects an
existing `~/.config/maestrod/config.toml` and existing systemd unit, skips the
configure prompt, refreshes the unit, and runs `systemctl --user restart
maestrod` instead of `start`. No `--update` flag needed.
Inside the script, `cargo install --git https://git.naps.pt/naps62/maestro …` Inside the script, `cargo install --git https://git.naps.pt/naps62/maestro …`
uses your already-configured git credentials for this host (HTTPS helper or uses your already-configured git credentials for this host (HTTPS helper or