This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-installer/src/main.rs
T
Miguel PalhasandClaude Opus 5 4fc90aabfb feat(maestro): serve the web UI from the daemon
The browser reached the daemon through maestro-web, a second binary that
served the SPA and reverse-proxied /api/* — two TCP connections and a
WebSocket frame re-encode on every keystroke and every byte of PTY output.
On one machine that hop buys nothing.

The daemon now embeds the bundle and serves it off its own listener, behind
a default-on `web` feature. `--no-default-features` builds without pnpm or
node; MAESTRO_WEB_DIST still takes a prebuilt bundle.

The router's fallback answers an unmatched /api/* with 404 rather than the
SPA shell, so a client expecting JSON gets a status it can read instead of
HTML.

Removes maestro-web and the [web] config section. An existing [web] block
is ignored, not rejected. The installer deletes a leftover
maestro-web.service on upgrade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 18:23:18 +01:00

372 lines
12 KiB
Rust

//! `maestro-install` — interactive wizard that installs the unified `maestro`
//! binary via `cargo install` and optionally drops a systemd user unit.
//!
//! 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, legacy_web_unit_path, render_user_unit,
systemctl_user_args, user_unit_path, CargoSource, SystemctlAction, LEGACY_WEB_UNIT_FILE_NAME,
UNIT_FILE_NAME,
};
const DEFAULT_GIT_URL: &str = "https://git.naps.pt/naps62/maestro";
/// Unified `maestro` crate, built with the full host feature set.
const MAESTRO_CRATE: &str = "maestro";
/// Features installed for a host: CLI + per-host daemon + TUI + the browser
/// SPA the daemon serves.
const MAESTRO_HOST_FEATURES: &str = "cli,daemon,tui,web";
const SYSTEMD_PROBE: &str = "/run/systemd/system";
#[derive(Debug, Parser)]
#[command(
name = "maestro-install",
version,
about = "Install the unified `maestro` binary, 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).
#[arg(long, short)]
yes: bool,
/// Skip the systemd user-unit step even if systemd is detected.
#[arg(long)]
no_systemd: bool,
/// Override the ExecStart path written to the systemd unit.
/// Default: `~/.cargo/bin/maestro` (invoked as `maestro daemon`).
#[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 the unified binary (NAP-245).
warn_if_web_build_prereqs_missing();
let locked = !args.no_locked;
install_crate(&source, MAESTRO_CRATE, locked, Some(MAESTRO_HOST_FEATURES))?;
// 2. 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)?;
}
// 3. Retire the standalone browser-UI service from older installs.
cleanup_legacy_web_unit()?;
print_summary(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 `{MAESTRO_CRATE}` (CLI + daemon + TUI + browser UI)");
eprintln!(" 2. (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,
features: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let cargo = cargo_bin();
let args = cargo_install_args(source, crate_name, locked, features);
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 handle_systemd(args: &Args, interactive: 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!();
let already_installed = unit_path.exists();
if already_installed {
eprintln!(
"systemd unit already present at {} — refreshing.",
unit_path.display()
);
} else {
eprintln!("systemd detected.");
}
// First-install: ask before dropping the unit. Update: just refresh it.
let want_unit = if already_installed || !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)?;
if !already_installed {
let want_enable = if !interactive {
true
} else {
confirm("Enable maestro.service (start on login)?", true)?
};
if want_enable {
run_systemctl(SystemctlAction::Enable, UNIT_FILE_NAME)?;
}
}
// Update path: restart so the freshly-installed binary actually runs.
// First-install path: only start if the user has a config to start with.
let (action, want) = if already_installed {
let want = if !interactive {
true
} else {
confirm("Restart maestro.service to pick up the new binary?", true)?
};
(SystemctlAction::Restart, want)
} else {
let want = if !interactive {
false
} else {
confirm("Start maestro.service now?", true)?
};
(SystemctlAction::Start, want)
};
if want {
run_systemctl(action, UNIT_FILE_NAME)?;
}
Ok(())
}
fn default_exec_start() -> Result<PathBuf, Box<dyn std::error::Error>> {
maestro_installer::systemd::default_maestro_path()
.ok_or_else(|| "could not resolve $HOME (needed for ExecStart)".into())
}
/// True iff both `pnpm` and `node` respond to `--version` on PATH. The daemon's
/// build.rs needs them to build the embedded SPA bundle.
fn frontend_toolchain_present() -> bool {
["pnpm", "node"].iter().all(|tool| {
Command::new(tool)
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
})
}
/// Whether to warn before `cargo install`. A prebuilt bundle pointed at by
/// `$MAESTRO_WEB_DIST` makes pnpm + node unnecessary.
fn should_warn_web_prereqs(toolchain_present: bool, web_dist_set: bool) -> bool {
!toolchain_present && !web_dist_set
}
/// The `web` feature is on by default and its build.rs hard-fails without a
/// bundle, so say so before cargo spends minutes getting there.
fn warn_if_web_build_prereqs_missing() {
let web_dist_set = std::env::var_os("MAESTRO_WEB_DIST").is_some();
if !should_warn_web_prereqs(frontend_toolchain_present(), web_dist_set) {
return;
}
eprintln!(
"\nWarning: pnpm + node are not on PATH. The daemon builds the browser \
UI from source, so the build will fail.\n \
Install pnpm + node (https://pnpm.io), point MAESTRO_WEB_DIST at a \
prebuilt bundle, or drop `web` from the feature list to build without \
the browser UI."
);
}
/// Remove the standalone `maestro-web.service` older installs left behind. The
/// daemon serves the browser UI itself now, so the unit can only fail on start.
/// Best-effort: a failing systemctl call must not abort the install.
fn cleanup_legacy_web_unit() -> Result<(), Box<dyn std::error::Error>> {
let Some(unit_path) = legacy_web_unit_path() else {
return Ok(());
};
if !unit_path.exists() {
return Ok(());
}
eprintln!(
"\nRemoving the separate maestro-web service — the daemon now serves \
the browser UI itself."
);
for action in [SystemctlAction::Stop, SystemctlAction::Disable] {
if let Err(e) = run_systemctl(action, LEGACY_WEB_UNIT_FILE_NAME) {
eprintln!("(ignored: {e})");
}
}
std::fs::remove_file(&unit_path)?;
eprintln!("removed {}", unit_path.display());
if let Err(e) = run_systemctl(SystemctlAction::DaemonReload, LEGACY_WEB_UNIT_FILE_NAME) {
eprintln!("(ignored: {e})");
}
Ok(())
}
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(systemd_enabled: bool) {
eprintln!();
eprintln!("Done.");
eprintln!();
eprintln!("Next steps:");
if systemd_enabled {
eprintln!(" • Check status: `systemctl --user status maestro`");
eprintln!(" • Logs: `journalctl --user -u maestro -f`");
} else {
eprintln!(" • Start the daemon: `maestro daemon`");
}
eprintln!(" • Open the TUI: `maestro tui`");
eprintln!(
" • Browser UI: served by the daemon at its own bind address \
(default http://127.0.0.1:7070/).\n \
Set [daemon].bind_addr in ~/.config/maestro/config.toml to expose it \
on your LAN/VPN address."
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_warning_when_toolchain_present() {
assert!(!should_warn_web_prereqs(true, false));
assert!(!should_warn_web_prereqs(true, true));
}
#[test]
fn no_warning_when_prebuilt_bundle_supplied() {
assert!(!should_warn_web_prereqs(false, true));
}
#[test]
fn warns_when_toolchain_missing_and_no_bundle() {
assert!(should_warn_web_prereqs(false, false));
}
#[test]
fn legacy_web_unit_is_the_only_web_service_touched() {
assert_eq!(LEGACY_WEB_UNIT_FILE_NAME, "maestro-web.service");
let p = legacy_web_unit_path().expect("config dir resolvable in test env");
assert!(p.ends_with("systemd/user/maestro-web.service"), "got {p:?}");
}
}