Merge pull request 'feat(maestro): unify maestro/maestrod/maestro-registry into one binary (NAP-245)' (#3) from linear/nap-245-unify-maestromaestrodmaestro-registry-into-a-single-maestro into main
ci / rust (push) Successful in 2m35s

This commit was merged in pull request #3.
This commit is contained in:
2026-05-27 16:12:36 +01:00
25 changed files with 614 additions and 375 deletions
+12 -6
View File
@@ -12,19 +12,25 @@ Linear project **Maestro** under team **Naps62**. Issues `NAP-211..218` cover th
`.claude/linear.json` wires `/yolo` and `/work` to this project — they pick from "available now" (status backlog/pending, not blocked).
## Repo layout (target)
## Repo layout
Cargo workspace, populated incrementally starting with NAP-211:
Cargo workspace. NAP-245 unified the per-binary crates behind a single
`crates/maestro/` umbrella that ships the `maestro` binary with feature gates
(`cli` / `daemon` / `registry`, default = all three). The library crates kept
their existing shape; only the per-binary `main.rs` files were retired.
```
crates/
├── maestro/ # umbrella binary `maestro` (cli + daemon + registry, feature-gated) (NAP-245)
├── maestro-protocol/ # shared wire types (NAP-211)
├── 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-config/ # shared on-disk config schema (NAP-233)
├── maestro-cli/ # `maestro list/spawn/attach/kill/policy` dispatch (NAP-216)
├── maestro-daemon/ # `maestro daemon` — tmux wrapper + WS endpoints (NAP-212, NAP-214)
├── maestro-registry/ # `maestro registry` — phone book + HTTP API (NAP-213, NAP-215)
├── maestro-web/ # standalone `maestro-web` browser UI (NAP-241, NAP-249)
├── 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)
web/ # browser SPA (NAP-218)
```
See `docs/INSTALL.md` for end-user install instructions.
Generated
+12
View File
@@ -939,6 +939,18 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "maestro"
version = "0.1.0"
dependencies = [
"clap",
"maestro-cli",
"maestro-daemon",
"maestro-registry",
"tokio",
"tracing-subscriber",
]
[[package]]
name = "maestro-cli"
version = "0.1.0"
-4
View File
@@ -10,10 +10,6 @@ description = "`maestro` CLI: list, spawn, attach, kill sessions across daemons"
[lib]
path = "src/lib.rs"
[[bin]]
name = "maestro"
path = "src/main.rs"
[dependencies]
chrono.workspace = true
clap.workspace = true
+140
View File
@@ -0,0 +1,140 @@
//! Public dispatch for the `maestro` CLI subcommands.
//!
//! Extracted from the per-binary `main.rs` (NAP-245) so the umbrella `maestro`
//! binary can dispatch to the same logic.
use std::path::{Path, PathBuf};
use crate::attach::{parse_detach_key, run_attach};
use crate::cli::{AttachArgs, Command, KillArgs, ListArgs, PolicyCmd, SetRegistryArgs, SpawnArgs};
use crate::config::{default_config_path, CliConfig, ConfigError};
use crate::http::RegistryClient;
use crate::policy::{run_reload as run_policy_reload, run_show as run_policy_show};
use crate::sessions::{fetch_connect_info, run_kill, run_list, run_spawn};
#[derive(Debug, thiserror::Error)]
#[allow(clippy::result_large_err)]
pub enum CliError {
#[error("config: {0}")]
Config(#[from] ConfigError),
#[error("{0}")]
Http(#[from] crate::http::HttpError),
#[error("{0}")]
Attach(#[from] crate::attach::AttachError),
#[error("{0}")]
Io(#[from] std::io::Error),
#[error("{0}")]
Policy(#[from] crate::policy::PolicyError),
}
/// Dispatch a parsed top-level subcommand. The umbrella binary passes the
/// global flags (`--config`, `--registry-url`) in alongside the variant.
#[allow(clippy::result_large_err)]
pub async fn dispatch(
command: Command,
config: Option<PathBuf>,
registry_url_override: Option<String>,
) -> Result<(), CliError> {
let config_path = match config {
Some(p) => p,
None => default_config_path()?,
};
match command {
Command::SetRegistry(args) => set_registry(&config_path, args),
Command::Policy(sub) => policy(&config_path, sub).await,
Command::List(args) => list(&config_path, registry_url_override, args).await,
Command::Spawn(args) => spawn(&config_path, registry_url_override, args).await,
Command::Attach(args) => attach(&config_path, registry_url_override, args).await,
Command::Kill(args) => kill(&config_path, registry_url_override, args).await,
}
}
#[allow(clippy::result_large_err)]
fn set_registry(config_path: &Path, args: SetRegistryArgs) -> Result<(), CliError> {
let url = args.url.trim_end_matches('/');
CliConfig::save_registry_url(config_path, url)?;
println!("registry URL saved to {}", config_path.display());
Ok(())
}
#[allow(clippy::result_large_err)]
async fn policy(config_path: &Path, sub: PolicyCmd) -> Result<(), CliError> {
let mut stdout = std::io::stdout().lock();
match sub {
PolicyCmd::Reload => run_policy_reload(config_path, &mut stdout).await?,
PolicyCmd::Show { json } => run_policy_show(config_path, json, &mut stdout).await?,
}
Ok(())
}
#[allow(clippy::result_large_err)]
async fn list(
config_path: &Path,
registry_url_override: Option<String>,
args: ListArgs,
) -> Result<(), CliError> {
let client = build_client(registry_url_override, config_path)?;
let mut stdout = std::io::stdout().lock();
run_list(&client, &args, &mut stdout).await?;
Ok(())
}
#[allow(clippy::result_large_err)]
async fn spawn(
config_path: &Path,
registry_url_override: Option<String>,
args: SpawnArgs,
) -> Result<(), CliError> {
let client = build_client(registry_url_override, config_path)?;
let attach_after = args.attach;
let session = run_spawn(&client, args).await?;
println!("spawned {}", session.id);
if attach_after {
let info = fetch_connect_info(&client, session.id).await?;
let detach = 0x11; // Ctrl-q
run_attach(info, session.id, detach).await?;
}
Ok(())
}
#[allow(clippy::result_large_err)]
async fn attach(
config_path: &Path,
registry_url_override: Option<String>,
args: AttachArgs,
) -> Result<(), CliError> {
let client = build_client(registry_url_override, config_path)?;
let info = fetch_connect_info(&client, args.session_id).await?;
let detach = parse_detach_key(&args.detach_key)?;
run_attach(info, args.session_id, detach).await?;
Ok(())
}
#[allow(clippy::result_large_err)]
async fn kill(
config_path: &Path,
registry_url_override: Option<String>,
args: KillArgs,
) -> Result<(), CliError> {
let client = build_client(registry_url_override, config_path)?;
run_kill(&client, &args).await?;
println!("killed {}", args.session_id);
Ok(())
}
#[allow(clippy::result_large_err)]
fn build_client(
override_url: Option<String>,
config_path: &Path,
) -> Result<RegistryClient, CliError> {
let cfg = CliConfig::load(config_path)?;
let url = override_url
.or(cfg.registry_url.clone())
.ok_or(ConfigError::NoRegistryUrl)?;
Ok(RegistryClient::new(
url,
cfg.cookie,
config_path.to_path_buf(),
))
}
+3
View File
@@ -8,6 +8,9 @@
pub mod attach;
pub mod cli;
pub mod config;
pub mod dispatch;
pub mod http;
pub mod policy;
pub mod sessions;
pub use dispatch::{dispatch, CliError};
-132
View File
@@ -1,132 +0,0 @@
//! `maestro` — CLI binary entry point.
use std::path::Path;
use std::process::ExitCode;
use clap::Parser;
use maestro_cli::attach::{parse_detach_key, run_attach};
use maestro_cli::cli::{Cli, Command, PolicyCmd};
use maestro_cli::config::{default_config_path, CliConfig, ConfigError};
use maestro_cli::http::RegistryClient;
use maestro_cli::policy::{run_reload as run_policy_reload, run_show as run_policy_show};
use maestro_cli::sessions::{fetch_connect_info, run_kill, run_list, run_spawn};
use tracing_subscriber::EnvFilter;
#[tokio::main(flavor = "current_thread")]
async fn main() -> ExitCode {
let cli = Cli::parse();
init_tracing(cli.verbose);
match dispatch(cli).await {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("maestro: {e}");
ExitCode::FAILURE
}
}
}
fn init_tracing(verbose: u8) {
let filter = match (std::env::var_os("RUST_LOG"), verbose) {
(Some(_), _) => {
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn"))
}
(None, 0) => EnvFilter::new("warn"),
(None, 1) => EnvFilter::new("info"),
(None, 2) => EnvFilter::new("debug"),
(None, _) => EnvFilter::new("trace"),
};
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.init();
}
#[derive(Debug, thiserror::Error)]
#[allow(clippy::result_large_err)]
enum CliError {
#[error("config: {0}")]
Config(#[from] ConfigError),
#[error("{0}")]
Http(#[from] maestro_cli::http::HttpError),
#[error("{0}")]
Attach(#[from] maestro_cli::attach::AttachError),
#[error("{0}")]
Io(#[from] std::io::Error),
#[error("{0}")]
Policy(#[from] maestro_cli::policy::PolicyError),
}
#[allow(clippy::result_large_err)]
async fn dispatch(cli: Cli) -> Result<(), CliError> {
let config_path = match cli.config.clone() {
Some(p) => p,
None => default_config_path()?,
};
// `set-registry` is the only command that writes config first and skips
// the registry-required path below.
if let Command::SetRegistry(args) = &cli.command {
let url = args.url.trim_end_matches('/');
CliConfig::save_registry_url(&config_path, url)?;
println!("registry URL saved to {}", config_path.display());
return Ok(());
}
// `policy` talks to the local daemon's HTTP API, not the registry. Handle
// it here so it works even without a registry URL configured.
if let Command::Policy(sub) = &cli.command {
let mut stdout = std::io::stdout().lock();
match sub {
PolicyCmd::Reload => run_policy_reload(&config_path, &mut stdout).await?,
PolicyCmd::Show { json } => run_policy_show(&config_path, *json, &mut stdout).await?,
}
return Ok(());
}
let client = build_client(cli.registry_url.clone(), &config_path)?;
match cli.command {
Command::List(args) => {
let mut stdout = std::io::stdout().lock();
run_list(&client, &args, &mut stdout).await?;
}
Command::Spawn(args) => {
let attach_after = args.attach;
let session = run_spawn(&client, args).await?;
println!("spawned {}", session.id);
if attach_after {
let info = fetch_connect_info(&client, session.id).await?;
let detach = 0x11; // Ctrl-q
run_attach(info, session.id, detach).await?;
}
}
Command::Attach(args) => {
let info = fetch_connect_info(&client, args.session_id).await?;
let detach = parse_detach_key(&args.detach_key)?;
run_attach(info, args.session_id, detach).await?;
}
Command::Kill(args) => {
run_kill(&client, &args).await?;
println!("killed {}", args.session_id);
}
Command::SetRegistry(_) | Command::Policy(_) => unreachable!("handled above"),
}
Ok(())
}
#[allow(clippy::result_large_err)]
fn build_client(
override_url: Option<String>,
config_path: &Path,
) -> Result<RegistryClient, CliError> {
let cfg = CliConfig::load(config_path)?;
let url = override_url
.or(cfg.registry_url.clone())
.ok_or(ConfigError::NoRegistryUrl)?;
Ok(RegistryClient::new(
url,
cfg.cookie,
config_path.to_path_buf(),
))
}
-4
View File
@@ -10,10 +10,6 @@ description = "Per-host tmux wrapper + session orchestrator that talks to the re
[lib]
path = "src/lib.rs"
[[bin]]
name = "maestrod"
path = "src/main.rs"
[dependencies]
axum.workspace = true
chrono.workspace = true
@@ -1,102 +1,77 @@
//! `maestrod` — the Maestro per-host daemon binary.
//! Clap-derive subcommand for the daemon, plus a runner that wires together
//! everything the binary used to do in `main.rs`.
//!
//! Two subcommands:
//! - `maestrod` / `maestrod run` — start the daemon.
//! - `maestrod configure` — bootstrap the config file.
//! Lifted out of the per-binary `main.rs` (NAP-245) so the umbrella `maestro`
//! binary can compose this subcommand alongside the CLI and registry.
use std::error::Error;
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand};
use maestro_daemon::session::DEFAULT_LIVENESS_INTERVAL;
use maestro_daemon::sidecar::SidecarStore;
use maestro_daemon::tmux::Tmux;
use maestro_daemon::worktree::WorktreeManager;
use maestro_daemon::{
use clap::Subcommand;
use tokio::net::TcpListener;
use crate::session::DEFAULT_LIVENESS_INTERVAL;
use crate::sidecar::SidecarStore;
use crate::tmux::Tmux;
use crate::worktree::WorktreeManager;
use crate::{
attach, default_config_path, default_state_dir, load_config, local_api, policy, run_configure,
spawn_session_publisher, spawn_sighup_listener, ConfigureInputs, EnvOverrides, JwtVerifier,
LocalEventBus, PairAttachVerifier, PolicyState, RegistryClient, SessionManager,
};
use tokio::net::TcpListener;
use tracing_subscriber::EnvFilter;
#[derive(Debug, Parser)]
#[command(
name = "maestrod",
version,
about = "Per-host Maestro daemon: tmux wrapper + registry client"
)]
struct Cli {
/// Override the unified Maestro config-file path (default `~/.config/maestro/config.toml`).
#[arg(long, global = true, env = "MAESTRO_CONFIG")]
config: Option<PathBuf>,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Debug, Subcommand)]
enum Command {
pub enum DaemonCmd {
/// Start the daemon. Default when no subcommand is given.
Run,
/// Write `~/.config/maestro/config.toml` interactively or from flags.
Configure(ConfigureCmd),
Configure(ConfigureArgs),
}
#[derive(Debug, clap::Args)]
struct ConfigureCmd {
pub struct ConfigureArgs {
/// Registry base URL (e.g. `https://maestro.example.com`). The daemon
/// derives its WS endpoint by scheme-swapping and appending `/daemon`.
#[arg(long)]
registry_url: Option<String>,
pub registry_url: Option<String>,
/// Reachable URL clients dial for this daemon's attach WS.
#[arg(long)]
daemon_url: Option<String>,
pub daemon_url: Option<String>,
/// Shared token (MAESTRO_TOKEN). Tip: pipe from `pass` / `op` / `secret-tool`.
#[arg(long)]
token: Option<String>,
pub token: Option<String>,
/// Stable daemon ID. Defaults to the system hostname.
#[arg(long)]
daemon_id: Option<String>,
pub daemon_id: Option<String>,
/// Local bind address for the per-session WS endpoint.
#[arg(long)]
bind_addr: Option<String>,
pub bind_addr: Option<String>,
/// Override the daemon's state directory.
#[arg(long)]
state_dir: Option<PathBuf>,
pub state_dir: Option<PathBuf>,
/// Override the worktree root.
#[arg(long)]
worktree_root: Option<PathBuf>,
pub worktree_root: Option<PathBuf>,
/// Override the recorded hostname (defaults to `gethostname(2)`).
#[arg(long)]
hostname: Option<String>,
pub hostname: Option<String>,
/// Overwrite an existing config file.
#[arg(long)]
force: bool,
pub force: bool,
/// Disable interactive prompts (errors if any required field is missing).
#[arg(long)]
non_interactive: bool,
pub non_interactive: bool,
}
#[tokio::main]
async fn main() -> ExitCode {
let cli = Cli::parse();
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::fmt().with_env_filter(filter).init();
let result: Result<(), Box<dyn Error>> = match cli.command.unwrap_or(Command::Run) {
Command::Run => run(cli.config).await,
Command::Configure(args) => configure(cli.config, args),
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
tracing::error!(error = %e, "maestrod failed");
eprintln!("maestrod: {e}");
ExitCode::FAILURE
}
/// Dispatch a parsed daemon subcommand. `config_override` is the unified
/// `--config` flag from the top-level CLI.
pub async fn dispatch(
config_override: Option<PathBuf>,
cmd: Option<DaemonCmd>,
) -> Result<(), Box<dyn Error>> {
match cmd.unwrap_or(DaemonCmd::Run) {
DaemonCmd::Run => run(config_override).await,
DaemonCmd::Configure(args) => configure(config_override, args),
}
}
@@ -113,7 +88,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
"loaded config"
);
let _ = default_state_dir(); // surface XDG diagnostics early but don't override
let _ = default_state_dir();
std::fs::create_dir_all(config.sidecar_dir())?;
std::fs::create_dir_all(config.fifo_dir())?;
std::fs::create_dir_all(&config.worktree_root)?;
@@ -144,14 +119,9 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
config.allow_inbound.iter().cloned(),
);
// Daemon-local event bus (NAP-248): own session events + peer catalog
// deltas funnel through here for `WS /api/events` subscribers.
let events = LocalEventBus::new();
spawn_session_publisher(manager.clone(), events.clone());
// Build the registry client first so we can hand its catalog mirror to
// the local-API state — both halves share the same Arc<Mutex<…>>. Hand
// the event bus to the catalog mirror so peer deltas fan out too.
let client = RegistryClient::new_with_bus(
config.registry_url.clone(),
config.daemon_id.clone(),
@@ -178,8 +148,6 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
self_daemon_id: std::sync::Arc::new(config.daemon_id.clone()),
self_perimeter_id: config.perimeter_id.clone().map(std::sync::Arc::new),
};
// NAP-237: hot-reload `[daemon.policy].allow_inbound` via SIGHUP or
// POST /api/policy/reload. Both swap the same verifier instance.
let policy_state = PolicyState {
verifier: pair_verifier.clone(),
config_path: std::sync::Arc::new(config_path.clone()),
@@ -208,7 +176,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
Ok(())
}
fn configure(config_override: Option<PathBuf>, args: ConfigureCmd) -> Result<(), Box<dyn Error>> {
fn configure(config_override: Option<PathBuf>, args: ConfigureArgs) -> Result<(), Box<dyn Error>> {
let path: PathBuf = match config_override {
Some(p) => p,
None => default_config_path()?,
+2 -2
View File
@@ -245,7 +245,7 @@ pub fn default_state_dir() -> Result<PathBuf, ConfigError> {
let base = dirs::state_dir()
.or_else(dirs::data_local_dir)
.ok_or(maestro_config::ConfigError::NoConfigDir)?;
Ok(base.join("maestrod"))
Ok(base.join("maestro"))
}
/// Read the kernel hostname via `gethostname(2)`. Falls back to `"unknown"`
@@ -285,7 +285,7 @@ daemon_url = "ws://localhost:7070"
assert_eq!(cfg.daemon_url, "ws://localhost:7070");
assert_eq!(cfg.token, "abc");
assert_eq!(cfg.bind_addr, default_bind_addr());
assert!(cfg.state_dir.ends_with("maestrod"));
assert!(cfg.state_dir.ends_with("maestro"));
assert!(cfg.worktree_root.ends_with("worktrees"));
assert_eq!(cfg.daemon_id, cfg.hostname);
assert!(!cfg.hostname.is_empty());
+1
View File
@@ -9,6 +9,7 @@
pub mod attach;
pub mod broker;
pub mod catalog_mirror;
pub mod cli;
pub mod config;
pub mod configure;
pub mod jwt;
+2 -1
View File
@@ -189,8 +189,9 @@ fn launch(
tmux_tmpdir: &std::path::Path,
opts: &DaemonOptions,
) -> Child {
let bin = workspace_target_bin("maestro-daemon", "maestrod");
let bin = workspace_target_bin("maestro", "maestro");
let mut cmd = Command::new(&bin);
cmd.arg("daemon");
cmd.env_clear()
.env(
"PATH",
+2 -1
View File
@@ -27,8 +27,9 @@ impl Registry {
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
let home = TempDir::new().expect("home tmpdir");
let bin = workspace_target_bin("maestro-registry", "maestro-registry");
let bin = workspace_target_bin("maestro", "maestro");
let child = Command::new(&bin)
.arg("registry")
.env_clear()
// PATH so tmux etc. resolve. We need a few essentials.
.env(
+1 -1
View File
@@ -5,7 +5,7 @@ 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"
description = "Interactive install wizard for the unified `maestro` binary; cargo-installs it and (on systemd hosts) drops a user unit"
[lib]
path = "src/lib.rs"
+13 -23
View File
@@ -101,14 +101,10 @@ mod tests {
url: "https://git.naps.pt/naps62/maestro".into(),
branch: None,
};
let args = cargo_install_args(&src, "maestro-daemon", false);
let args = cargo_install_args(&src, "maestro", false);
assert_eq!(
args,
vec![
"--git",
"https://git.naps.pt/naps62/maestro",
"maestro-daemon",
]
vec!["--git", "https://git.naps.pt/naps62/maestro", "maestro",]
);
}
@@ -118,7 +114,7 @@ mod tests {
url: "https://git.example/x".into(),
branch: Some("main".into()),
};
let args = cargo_install_args(&src, "maestro-cli", true);
let args = cargo_install_args(&src, "maestro", true);
assert_eq!(
args,
vec![
@@ -126,7 +122,7 @@ mod tests {
"https://git.example/x",
"--branch",
"main",
"maestro-cli",
"maestro",
"--locked",
]
);
@@ -135,41 +131,35 @@ mod tests {
#[test]
fn path_args_use_p_flag() {
let src = CargoSource::Path(PathBuf::from("/repos/maestro"));
let args = cargo_install_args(&src, "maestro-daemon", true);
let args = cargo_install_args(&src, "maestro", true);
assert_eq!(
args,
vec![
"--path",
"/repos/maestro",
"-p",
"maestro-daemon",
"--locked",
]
vec!["--path", "/repos/maestro", "-p", "maestro", "--locked",]
);
}
#[test]
fn systemctl_daemon_reload_omits_unit() {
let args = systemctl_user_args(SystemctlAction::DaemonReload, "maestrod.service");
let args = systemctl_user_args(SystemctlAction::DaemonReload, "maestro.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"]);
let args = systemctl_user_args(SystemctlAction::Enable, "maestro.service");
assert_eq!(args, vec!["--user", "enable", "maestro.service"]);
}
#[test]
fn systemctl_start_includes_unit() {
let args = systemctl_user_args(SystemctlAction::Start, "maestrod.service");
assert_eq!(args, vec!["--user", "start", "maestrod.service"]);
let args = systemctl_user_args(SystemctlAction::Start, "maestro.service");
assert_eq!(args, vec!["--user", "start", "maestro.service"]);
}
#[test]
fn systemctl_restart_includes_unit() {
let args = systemctl_user_args(SystemctlAction::Restart, "maestrod.service");
assert_eq!(args, vec!["--user", "restart", "maestrod.service"]);
let args = systemctl_user_args(SystemctlAction::Restart, "maestro.service");
assert_eq!(args, vec!["--user", "restart", "maestro.service"]);
}
#[test]
+2 -1
View File
@@ -1,4 +1,5 @@
//! `maestro-installer` — install wizard for maestrod + maestro.
//! `maestro-installer` — install wizard for the unified `maestro` binary
//! (NAP-245).
//!
//! See `docs/INSTALL.md` for the user-facing flow. This crate exposes the
//! testable pieces:
+37 -36
View File
@@ -1,6 +1,6 @@
//! `maestro-install` — interactive wizard that installs `maestrod` + `maestro`
//! via `cargo install`, optionally drops a systemd user unit, and optionally
//! kicks off `maestrod configure`.
//! `maestro-install` — interactive wizard that installs the unified `maestro`
//! binary via `cargo install`, optionally drops a systemd user unit, and
//! optionally kicks off `maestro daemon configure`.
//!
//! Intended bootstrap path:
//!
@@ -22,8 +22,8 @@ use maestro_installer::{
};
const DEFAULT_GIT_URL: &str = "https://git.naps.pt/naps62/maestro";
const DAEMON_CRATE: &str = "maestro-daemon";
const CLI_CRATE: &str = "maestro-cli";
/// Unified `maestro` crate — single `cargo install` covers CLI + daemon + registry.
const MAESTRO_CRATE: &str = "maestro";
const WEB_CRATE: &str = "maestro-web";
const SYSTEMD_PROBE: &str = "/run/systemd/system";
@@ -31,7 +31,7 @@ const SYSTEMD_PROBE: &str = "/run/systemd/system";
#[command(
name = "maestro-install",
version,
about = "Install maestrod + maestro CLI, and (optionally) a systemd user unit"
about = "Install the unified `maestro` binary, and (optionally) a systemd user unit"
)]
struct Args {
/// Git URL to install from (default: this repo).
@@ -59,12 +59,12 @@ struct Args {
#[arg(long)]
no_systemd: bool,
/// Skip the `maestrod configure` step.
/// Skip the `maestro daemon configure` step.
#[arg(long)]
no_configure: bool,
/// Override the ExecStart path written to the systemd unit.
/// Default: `~/.cargo/bin/maestrod`.
/// Default: `~/.cargo/bin/maestro` (invoked as `maestro daemon`).
#[arg(long, value_name = "PATH")]
exec_start: Option<PathBuf>,
@@ -101,16 +101,15 @@ fn run(args: Args) -> Result<(), Box<dyn std::error::Error>> {
return Ok(());
}
// 1. Install both binaries.
// 1. Install the unified binary (NAP-245).
let locked = !args.no_locked;
install_crate(&source, DAEMON_CRATE, locked)?;
install_crate(&source, CLI_CRATE, locked)?;
install_crate(&source, MAESTRO_CRATE, locked)?;
// 2. Offer to run `maestrod configure` now (must happen before systemd
// 2. Offer to run `maestro daemon configure` now (must happen before systemd
// start, since the daemon refuses to run without registry_url / token).
// Re-runs of the wizard auto-skip when the unified config already has a
// [daemon] section; that's what makes "re-run to update" pleasant. In
// non-interactive mode (--yes / no TTY) we also skip — `maestrod
// non-interactive mode (--yes / no TTY) we also skip — `maestro daemon
// configure` would prompt for required fields and we don't want to
// surprise scripts.
let configured = if let Some(path) = maestro_config_path() {
@@ -119,7 +118,7 @@ fn run(args: Args) -> Result<(), Box<dyn std::error::Error>> {
"\n[daemon] section already present in {} — skipping configure step.",
path.display()
);
eprintln!("(run `maestrod configure --force` to rewrite it.)");
eprintln!("(run `maestro daemon configure --force` to rewrite it.)");
true
} else {
run_configure_step(&args, interactive)?
@@ -165,8 +164,8 @@ 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!(" 1. cargo install `{MAESTRO_CRATE}` (unified CLI + daemon + registry)");
eprintln!(" 2. (optional) run `maestro daemon configure` to bootstrap the config file");
eprintln!(" 3. (optional) install a systemd user unit and enable it");
eprintln!(" 4. (optional) install `{WEB_CRATE}` + its systemd unit (browser UI)");
eprintln!();
@@ -218,7 +217,7 @@ fn run_configure_step(args: &Args, interactive: bool) -> Result<bool, Box<dyn st
let want = interactive
&& !args.no_configure
&& confirm(
"Run `maestrod configure` now (writes ~/.config/maestro/config.toml)?",
"Run `maestro daemon configure` now (writes ~/.config/maestro/config.toml)?",
true,
)?;
if want {
@@ -229,20 +228,22 @@ fn run_configure_step(args: &Args, interactive: bool) -> Result<bool, Box<dyn st
}
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();
// Run the freshly-installed `maestro daemon configure` interactively. We
// don't hard-code the path; rely on it being on PATH after `cargo install`.
eprintln!("\n==> maestro daemon configure");
let status = Command::new("maestro")
.args(["daemon", "configure"])
.status();
match status {
Ok(s) if s.success() => Ok(true),
Ok(s) => {
eprintln!("`maestrod configure` exited {s}");
eprintln!("`maestro daemon 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."
"couldn't launch `maestro`: {e}. Make sure ~/.cargo/bin is on PATH, \
then run `maestro daemon configure` manually."
);
Ok(false)
}
@@ -297,7 +298,7 @@ fn handle_systemd(
let want_enable = if !interactive {
true
} else {
confirm("Enable maestrod.service (start on login)?", true)?
confirm("Enable maestro.service (start on login)?", true)?
};
if want_enable {
run_systemctl(SystemctlAction::Enable, UNIT_FILE_NAME)?;
@@ -310,7 +311,7 @@ fn handle_systemd(
let want = if !interactive {
true
} else {
confirm("Restart maestrod.service to pick up the new binary?", true)?
confirm("Restart maestro.service to pick up the new binary?", true)?
};
(SystemctlAction::Restart, want)
} else {
@@ -318,12 +319,12 @@ fn handle_systemd(
false
} else if !configured {
eprintln!(
"Note: starting now will fail until you run `maestrod configure` \
"Note: starting now will fail until you run `maestro daemon configure` \
(no registry_url / token yet)."
);
confirm("Start maestrod.service now anyway?", false)?
confirm("Start maestro.service now anyway?", false)?
} else {
confirm("Start maestrod.service now?", true)?
confirm("Start maestro.service now?", true)?
};
(SystemctlAction::Start, want)
};
@@ -335,7 +336,7 @@ fn handle_systemd(
}
fn default_exec_start() -> Result<PathBuf, Box<dyn std::error::Error>> {
maestro_installer::systemd::default_maestrod_path()
maestro_installer::systemd::default_maestro_path()
.ok_or_else(|| "could not resolve $HOME (needed for ExecStart)".into())
}
@@ -378,7 +379,7 @@ fn handle_web(
} else {
eprintln!(
"\nOptional: install the browser UI (`maestro-web`)?\n \
Reverse-proxies a local maestrod and serves the SPA at \
Reverse-proxies a local maestro daemon and serves the SPA at \
http://127.0.0.1:8080/ by default.\n \
Edit [web].bind_addr in ~/.config/maestro/config.toml \
to expose it to your perimeter (e.g. WireGuard IP)."
@@ -390,7 +391,7 @@ fn handle_web(
}
// Install the binary. Even if cargo-install fails we keep going so the
// operator can fix the cause and re-run; the existing maestrod work
// operator can fix the cause and re-run; the existing daemon work
// doesn't need to be undone.
let source = build_source(args);
let locked = !args.no_locked;
@@ -491,13 +492,13 @@ fn print_summary(configured: bool, systemd_enabled: bool, web_installed: bool) {
eprintln!();
eprintln!("Next steps:");
if !configured {
eprintln!(" • Run `maestrod configure` to bootstrap your config file.");
eprintln!(" • Run `maestro daemon configure` to bootstrap your config file.");
}
if systemd_enabled {
eprintln!(" • Check status: `systemctl --user status maestrod`");
eprintln!(" • Logs: `journalctl --user -u maestrod -f`");
eprintln!(" • Check status: `systemctl --user status maestro`");
eprintln!(" • Logs: `journalctl --user -u maestro -f`");
} else {
eprintln!(" • Start the daemon: `maestrod`");
eprintln!(" • Start the daemon: `maestro daemon`");
}
eprintln!(" • Set the CLI's registry: `maestro set-registry <URL>`");
if web_installed {
+34 -28
View File
@@ -1,15 +1,16 @@
//! 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.
//! `maestro daemon` 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/` for the daemon.
pub const UNIT_FILE_NAME: &str = "maestrod.service";
/// Filename written under `~/.config/systemd/user/` for the daemon (NAP-245
/// renamed this from `maestrod.service`).
pub const UNIT_FILE_NAME: &str = "maestro.service";
/// Filename for the optional `maestro-web` browser UI unit (NAP-249).
pub const WEB_UNIT_FILE_NAME: &str = "maestro-web.service";
@@ -27,7 +28,7 @@ pub fn user_unit_path_for(unit_name: &str) -> Option<PathBuf> {
Some(base.join("systemd").join("user").join(unit_name))
}
/// Convenience: path for `maestrod.service`. Kept as a free function so
/// Convenience: path for `maestro.service`. Kept as a free function so
/// existing callers don't need to thread the unit name around.
pub fn user_unit_path() -> Option<PathBuf> {
user_unit_path_for(UNIT_FILE_NAME)
@@ -35,23 +36,27 @@ pub fn user_unit_path() -> Option<PathBuf> {
/// Convenience: path for `maestro-web.service` (NAP-249).
pub fn user_web_unit_path() -> Option<PathBuf> {
user_unit_path_for(WEB_UNIT_FILE_NAME)
user_web_unit_path_for(WEB_UNIT_FILE_NAME)
}
/// Render the daemon unit. `exec_start` is the absolute path to `maestrod` —
/// we default to `~/.cargo/bin/maestrod` because that's where `cargo install`
fn user_web_unit_path_for(unit_name: &str) -> Option<PathBuf> {
user_unit_path_for(unit_name)
}
/// Render the daemon unit. `exec_start` is the absolute path to `maestro` —
/// we default to `~/.cargo/bin/maestro` 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`.
/// on a bare `maestro`. The unit runs `maestro daemon` (NAP-245).
pub fn render_user_unit(exec_start: &Path) -> String {
format!(
"[Unit]\n\
Description=Maestro per-host daemon (maestrod)\n\
Description=Maestro per-host daemon\n\
Documentation=https://git.naps.pt/naps62/maestro\n\
After=network-online.target\n\
\n\
[Service]\n\
Type=simple\n\
ExecStart={exec}\n\
ExecStart={exec} daemon\n\
ExecReload=/bin/kill -HUP $MAINPID\n\
Restart=on-failure\n\
RestartSec=5\n\
@@ -62,7 +67,7 @@ pub fn render_user_unit(exec_start: &Path) -> String {
)
}
/// Render the `maestro-web` user unit (NAP-249). Orders after `maestrod`
/// Render the `maestro-web` user unit (NAP-249). Orders after `maestro.service`
/// so the proxy doesn't return 502s on startup; `Wants=` keeps it best-effort
/// — operators with daemon-less hosts can still install web alone if they
/// later remove the dependency.
@@ -71,8 +76,8 @@ pub fn render_web_unit(exec_start: &Path) -> String {
"[Unit]\n\
Description=Maestro web UI (maestro-web)\n\
Documentation=https://git.naps.pt/naps62/maestro\n\
After=network-online.target maestrod.service\n\
Wants=maestrod.service\n\
After=network-online.target maestro.service\n\
Wants=maestro.service\n\
\n\
[Service]\n\
Type=simple\n\
@@ -86,15 +91,10 @@ pub fn render_web_unit(exec_start: &Path) -> String {
)
}
/// Default `ExecStart` target: `~/.cargo/bin/maestrod`. `None` if `$HOME` is
/// Default `ExecStart` target: `~/.cargo/bin/maestro`. `None` if `$HOME` is
/// unresolvable.
pub fn default_maestrod_path() -> Option<PathBuf> {
Some(
dirs::home_dir()?
.join(".cargo")
.join("bin")
.join("maestrod"),
)
pub fn default_maestro_path() -> Option<PathBuf> {
Some(dirs::home_dir()?.join(".cargo").join("bin").join("maestro"))
}
/// Default `ExecStart` target for the web unit: `~/.cargo/bin/maestro-web`.
@@ -128,8 +128,8 @@ mod tests {
#[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"));
let body = render_user_unit(Path::new("/home/alice/.cargo/bin/maestro"));
assert!(body.contains("ExecStart=/home/alice/.cargo/bin/maestro daemon"));
assert!(body.contains("[Unit]"));
assert!(body.contains("[Service]"));
assert!(body.contains("[Install]"));
@@ -139,9 +139,9 @@ mod tests {
#[test]
fn unit_wires_execreload_to_sighup() {
// NAP-237: `systemctl --user reload maestrod` must trigger our
// NAP-237: `systemctl --user reload maestro` must trigger our
// SIGHUP handler so operators can hot-reload `[daemon.policy]`.
let body = render_user_unit(Path::new("/x/maestrod"));
let body = render_user_unit(Path::new("/x/maestro"));
assert!(body.contains("ExecReload=/bin/kill -HUP $MAINPID"));
}
@@ -154,7 +154,7 @@ mod tests {
#[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:?}");
assert!(p.ends_with("systemd/user/maestro.service"), "got {p:?}");
}
#[test]
@@ -167,12 +167,18 @@ mod tests {
fn web_unit_renders_with_dependency_on_daemon() {
let body = render_web_unit(Path::new("/home/alice/.cargo/bin/maestro-web"));
assert!(body.contains("ExecStart=/home/alice/.cargo/bin/maestro-web"));
assert!(body.contains("After=network-online.target maestrod.service"));
assert!(body.contains("Wants=maestrod.service"));
assert!(body.contains("After=network-online.target maestro.service"));
assert!(body.contains("Wants=maestro.service"));
assert!(body.contains("WantedBy=default.target"));
assert!(body.ends_with('\n'));
}
#[test]
fn default_maestro_path_under_cargo_bin() {
let p = default_maestro_path().expect("HOME resolvable in test env");
assert!(p.ends_with(".cargo/bin/maestro"), "got {p:?}");
}
#[test]
fn default_maestro_web_path_under_cargo_bin() {
let p = default_maestro_web_path().expect("HOME resolvable in test env");
-4
View File
@@ -10,10 +10,6 @@ description = "Always-on registry — daemon presence index and session catalog"
[lib]
path = "src/lib.rs"
[[bin]]
name = "maestro-registry"
path = "src/main.rs"
[dependencies]
axum.workspace = true
chrono.workspace = true
+3
View File
@@ -17,11 +17,14 @@ pub mod client_api;
pub mod events;
pub mod handler;
pub mod jwt;
pub mod run;
pub mod server;
pub mod state;
pub mod token;
pub mod web_assets;
pub use run::run;
pub use auth::{issue_cookie, verify_cookie, COOKIE_NAME};
pub use jwt::{issue_attach_jwt, ATTACH_JWT_TTL};
pub use server::{router, router_full, router_with_timeout, RegistryServer};
@@ -1,34 +1,26 @@
//! `maestro-registry` — the Maestro always-on registry binary.
//! Public entry point: load config, bind, serve until SIGINT.
//!
//! Extracted from the per-binary `main.rs` (NAP-245) so the umbrella `maestro`
//! binary can dispatch `maestro registry` here.
use std::error::Error;
use std::path::PathBuf;
use std::process::ExitCode;
use maestro_registry::{default_config_path, load_config, router, Registry};
use tokio::net::TcpListener;
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> ExitCode {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::fmt().with_env_filter(filter).init();
use crate::{
default_config_path, load_config, router, Registry, ENV_REGISTRY_BIND_ADDR, ENV_TOKEN,
};
match run().await {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
tracing::error!(error = %e, "maestro-registry failed");
ExitCode::FAILURE
}
}
}
async fn run() -> Result<(), Box<dyn Error>> {
let config_path: PathBuf = match std::env::var_os(maestro_registry::CONFIG_PATH_ENV) {
Some(p) => PathBuf::from(p),
/// Run the registry. `config_override` is the top-level `--config` flag from
/// the umbrella binary (also populated from `$MAESTRO_CONFIG` by clap).
pub async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
let config_path: PathBuf = match config_override {
Some(p) => p,
None => default_config_path()?,
};
let env_token = std::env::var(maestro_registry::ENV_TOKEN).ok();
let env_bind = std::env::var(maestro_registry::ENV_REGISTRY_BIND_ADDR).ok();
let env_token = std::env::var(ENV_TOKEN).ok();
let env_bind = std::env::var(ENV_REGISTRY_BIND_ADDR).ok();
let token_from_env = env_token.is_some();
let bind_from_env = env_bind.is_some();
let config = load_config(&config_path, env_token, env_bind.as_deref())?;
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "maestro"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
description = "Unified `maestro` binary: CLI + per-host daemon + always-on registry, feature-gated."
[[bin]]
name = "maestro"
path = "src/main.rs"
[features]
default = ["cli", "daemon", "registry"]
cli = ["dep:maestro-cli"]
daemon = ["dep:maestro-daemon"]
registry = ["dep:maestro-registry"]
[dependencies]
clap.workspace = true
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal"] }
tracing-subscriber.workspace = true
maestro-cli = { path = "../maestro-cli", optional = true }
maestro-daemon = { path = "../maestro-daemon", optional = true }
maestro-registry = { path = "../maestro-registry", optional = true }
+209
View File
@@ -0,0 +1,209 @@
//! `maestro` — unified binary that hosts the CLI, the per-host daemon, and the
//! always-on registry behind feature-gated subcommands (NAP-245).
//!
//! ```text
//! cargo install --no-default-features --features registry maestro # registry server
//! cargo install --no-default-features --features daemon maestro # daemon-only host
//! cargo install maestro # default: cli + daemon + registry
//! ```
#[cfg(not(any(feature = "cli", feature = "daemon", feature = "registry")))]
compile_error!("at least one of the `cli`, `daemon`, `registry` features must be enabled");
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand};
use tracing_subscriber::EnvFilter;
#[derive(Debug, Parser)]
#[command(
name = "maestro",
version,
about = "Unified CLI / daemon / registry for the Maestro terminal-session orchestrator",
arg_required_else_help = true
)]
struct Cli {
/// Override the registry URL for this invocation (e.g. `https://maestro.example.com`).
/// Used by CLI subcommands; ignored by `daemon` / `registry`.
#[arg(long, global = true, env = "MAESTRO_REGISTRY_URL")]
registry_url: Option<String>,
/// Path to the unified Maestro config file. Default `~/.config/maestro/config.toml`.
#[arg(long, global = true, env = "MAESTRO_CONFIG")]
config: Option<PathBuf>,
/// Increase log verbosity (CLI subcommands only). Repeat for more: `-v` = debug, `-vv` = trace.
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
verbose: u8,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
/// List all sessions, grouped by daemon.
#[cfg(feature = "cli")]
List(maestro_cli::cli::ListArgs),
/// Spawn a new session on a given daemon.
#[cfg(feature = "cli")]
Spawn(maestro_cli::cli::SpawnArgs),
/// Attach to a session over WebSocket. Bytes flow through the local TTY.
#[cfg(feature = "cli")]
Attach(maestro_cli::cli::AttachArgs),
/// Kill a session by ID.
#[cfg(feature = "cli")]
Kill(maestro_cli::cli::KillArgs),
/// Set the default registry URL persisted in the CLI config.
#[cfg(feature = "cli")]
SetRegistry(maestro_cli::cli::SetRegistryArgs),
/// Inspect or hot-reload the local daemon's `[daemon.policy]` (NAP-237).
#[cfg(feature = "cli")]
#[command(subcommand)]
Policy(maestro_cli::cli::PolicyCmd),
/// Per-host daemon: tmux wrapper + registry client.
#[cfg(feature = "daemon")]
Daemon {
#[command(subcommand)]
cmd: Option<maestro_daemon::cli::DaemonCmd>,
},
/// Always-on registry: presence index + session catalog.
#[cfg(feature = "registry")]
Registry,
}
fn main() -> ExitCode {
let cli = Cli::parse();
init_tracing(&cli);
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("build tokio runtime");
match rt.block_on(dispatch(cli)) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("maestro: {e}");
ExitCode::FAILURE
}
}
}
async fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
match cli.command {
#[cfg(feature = "cli")]
Command::List(args) => {
cli_dispatch(
maestro_cli::cli::Command::List(args),
cli.config,
cli.registry_url,
)
.await?
}
#[cfg(feature = "cli")]
Command::Spawn(args) => {
cli_dispatch(
maestro_cli::cli::Command::Spawn(args),
cli.config,
cli.registry_url,
)
.await?
}
#[cfg(feature = "cli")]
Command::Attach(args) => {
cli_dispatch(
maestro_cli::cli::Command::Attach(args),
cli.config,
cli.registry_url,
)
.await?
}
#[cfg(feature = "cli")]
Command::Kill(args) => {
cli_dispatch(
maestro_cli::cli::Command::Kill(args),
cli.config,
cli.registry_url,
)
.await?
}
#[cfg(feature = "cli")]
Command::SetRegistry(args) => {
cli_dispatch(
maestro_cli::cli::Command::SetRegistry(args),
cli.config,
cli.registry_url,
)
.await?
}
#[cfg(feature = "cli")]
Command::Policy(sub) => {
cli_dispatch(
maestro_cli::cli::Command::Policy(sub),
cli.config,
cli.registry_url,
)
.await?
}
#[cfg(feature = "daemon")]
Command::Daemon { cmd } => maestro_daemon::cli::dispatch(cli.config, cmd).await?,
#[cfg(feature = "registry")]
Command::Registry => maestro_registry::run(cli.config).await?,
}
Ok(())
}
#[cfg(feature = "cli")]
async fn cli_dispatch(
command: maestro_cli::cli::Command,
config: Option<PathBuf>,
registry_url: Option<String>,
) -> Result<(), maestro_cli::CliError> {
maestro_cli::dispatch(command, config, registry_url).await
}
fn init_tracing(cli: &Cli) {
let filter = if is_cli_subcommand(&cli.command) {
cli_filter(cli.verbose)
} else {
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))
};
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.init();
}
#[cfg(feature = "cli")]
fn is_cli_subcommand(command: &Command) -> bool {
matches!(
command,
Command::List(_)
| Command::Spawn(_)
| Command::Attach(_)
| Command::Kill(_)
| Command::SetRegistry(_)
| Command::Policy(_)
)
}
#[cfg(not(feature = "cli"))]
fn is_cli_subcommand(_command: &Command) -> bool {
false
}
fn cli_filter(verbose: u8) -> EnvFilter {
match (std::env::var_os("RUST_LOG"), verbose) {
(Some(_), _) => {
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn"))
}
(None, 0) => EnvFilter::new("warn"),
(None, 1) => EnvFilter::new("info"),
(None, 2) => EnvFilter::new("debug"),
(None, _) => EnvFilter::new("trace"),
}
}
+49 -31
View File
@@ -13,13 +13,13 @@ 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
wizard, and cleans up — only `maestrod` and `maestro` end up persisted in
wizard, and cleans up — only the unified `maestro` binary ends up persisted in
`~/.cargo/bin`.
**Updating is the same command.** Re-running `./install.sh` detects an
existing `[daemon]` section in `~/.config/maestro/config.toml` and an
existing systemd unit, skips the configure prompt, refreshes the unit, and
runs `systemctl --user restart maestrod` instead of `start`. No `--update`
runs `systemctl --user restart maestro` instead of `start`. No `--update`
flag needed.
Inside the script, `cargo install --git https://git.naps.pt/naps62/maestro …`
@@ -38,20 +38,39 @@ Pass flags through to the wizard after `./install.sh`:
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 populate `~/.config/maestro/config.toml`
(one shared TOML for `maestrod`, `maestro-registry`, and the `maestro` CLI).
1. Runs `cargo install` for the unified `maestro` crate (pinned to the
committed `Cargo.lock` via `--locked`). The same binary hosts the CLI,
the per-host daemon (`maestro daemon`), and the always-on registry
(`maestro registry`), gated by Cargo features.
2. Optionally runs `maestro daemon configure` to populate `~/.config/maestro/config.toml`
(one shared TOML for the CLI, daemon, registry, and web).
3. On systemd hosts, optionally writes a user unit at
`~/.config/systemd/user/maestrod.service`, reloads systemd, and offers to
`~/.config/systemd/user/maestro.service`, reloads systemd, and offers to
enable + start it.
Both binaries land in `~/.cargo/bin` — make sure that's on your `$PATH`.
The binary lands in `~/.cargo/bin/maestro` — make sure that's on your `$PATH`.
## Thin builds for single-role hosts
The default install pulls in everything. Registry-only and daemon-only hosts
can opt out of unused dependencies:
```sh
# Registry server — no tmux/PTY/jwt-verifier code:
cargo install --git https://git.naps.pt/naps62/maestro \
--no-default-features --features registry --locked maestro
# Daemon-only host — no embedded web assets, no registry HTTP surface:
cargo install --git https://git.naps.pt/naps62/maestro \
--no-default-features --features daemon --locked maestro
```
The wizard always installs the default-feature build.
## Adding the browser UI (optional)
`maestro-web` is a separate binary that serves the SPA bundle and reverse-
proxies API calls to a co-located `maestrod`.
proxies API calls to a co-located `maestro daemon`.
**Interactive install (default path).** The wizard now offers it as an
optional step right after the daemon flow:
@@ -63,7 +82,7 @@ Install maestro-web? [Y/n]
Press `Y` and it `cargo install`s `maestro-web`, drops a
`~/.config/systemd/user/maestro-web.service` unit (ordered after
`maestrod.service`), reloads systemd, and offers to enable + start it. Re-runs
`maestro.service`), reloads systemd, and offers to enable + start it. Re-runs
of `./install.sh` detect the existing unit and refresh + restart silently.
Pass `--no-web` to skip the prompt entirely (headless hosts), or `--yes`
@@ -100,18 +119,18 @@ where you want browser access answer yes — both binaries end up in
|---|---|
| `--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>`). |
| `--path <dir>` | Install from a local workspace checkout (`--path` + `-p maestro`). |
| `--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`). |
| `--exec-start <path>` | Override the systemd unit's `ExecStart` (default `~/.cargo/bin/maestro daemon`). |
| `--no-systemd` | Skip the systemd step even if detected. |
| `--no-configure` | Skip the `maestrod configure` step. |
| `--no-configure` | Skip the `maestro daemon configure` step. |
| `--no-web` | Skip the optional `maestro-web` install step. |
| `--web-exec-start <path>` | Override the web unit's `ExecStart` (default `~/.cargo/bin/maestro-web`). |
| `-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). Also skips the web step unless an existing `maestro-web.service` is being refreshed. |
## Where tmux sockets land
`maestrod` defaults `TMUX_TMPDIR` to `$XDG_RUNTIME_DIR` (= `/run/user/$UID`)
`maestro daemon` defaults `TMUX_TMPDIR` to `$XDG_RUNTIME_DIR` (= `/run/user/$UID`)
so a plain `tmux ls` in your shell — which typically uses the same env var
— sees maestro-managed sessions. Override in
`~/.config/maestro/config.toml`:
@@ -129,13 +148,13 @@ precedence over the file.
## Hot-reloading policy
`[daemon.policy].allow_inbound` (the list of peer daemons allowed to broker-
attach into this one) can be changed without restarting `maestrod` — restarting
would kill every live session. Two ways to apply an edit:
attach into this one) can be changed without restarting the daemon — restarting
would kill every live session. Three ways to apply an edit:
```sh
maestro policy reload # POSTs to the local daemon's /api/policy/reload
maestro policy show # prints the current effective list
kill -HUP <maestrod-pid> # equivalent; what `systemctl --user reload` does
kill -HUP <maestro-pid> # equivalent; what `systemctl --user reload` does
```
Only newly-arriving attach attempts see the change — sessions already
@@ -143,18 +162,18 @@ attached stay attached. To boot a live attacher out, kill the session.
## 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:
The wizard writes a *user* unit (`systemctl --user`), since `maestro daemon`
runs per-user and uses tmux sockets under `$XDG_RUNTIME_DIR`. Body:
```ini
[Unit]
Description=Maestro per-host daemon (maestrod)
Description=Maestro per-host daemon
Documentation=https://git.naps.pt/naps62/maestro
After=network-online.target
[Service]
Type=simple
ExecStart=%h/.cargo/bin/maestrod
ExecStart=%h/.cargo/bin/maestro daemon
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5
@@ -166,8 +185,8 @@ WantedBy=default.target
Check status / logs:
```sh
systemctl --user status maestrod
journalctl --user -u maestrod -f
systemctl --user status maestro
journalctl --user -u maestro -f
```
## Running the wizard without the bootstrap script
@@ -185,15 +204,14 @@ script avoids that by installing to a scratch dir.
## Fully 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
cargo install --git https://git.naps.pt/naps62/maestro --locked maestro
maestro daemon configure
maestro daemon # 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.
The wizard works on macOS for the `cargo install` + `maestro daemon configure`
parts. The systemd step is automatically skipped (probe of
`/run/systemd/system`). launchd integration is not yet supported — start
`maestro daemon` from your shell or your own service manager.
@@ -61,7 +61,7 @@ UI polish, layout/tiling, edge-case terminals — all out of scope.
Three components, all reachable over an operator-managed network (LAN, VPN, mesh — maestro is network-agnostic; the operator configures bind addresses and ensures clients can route to them):
- **Registry** — one always-on instance. Knows who's online and what sessions exist. Serves the web client bundle. Never touches PTY bytes.
- **Daemon (`maestrod`)** — one per host. Wraps local tmux. Streams sessions directly to clients over WebSocket.
- **Daemon (`maestro daemon`)** — one per host. Wraps local tmux. Streams sessions directly to clients over WebSocket.
- **Web client** — one TS bundle. Connects to the registry for the catalog, then directly to whichever daemon owns the session being attached.
The registry is a phone book, not a relay. This keeps it tiny and pushes byte traffic to a direct daemon ↔ client link. Confidentiality of that link is the operator's responsibility (Wireguard, a private LAN, mTLS in a future iteration — maestro itself does not encrypt at the transport layer beyond what the operator's network provides).
@@ -85,7 +85,7 @@ Non-responsibilities:
Runs as a single Rust binary on a designated VM. Listens on a configured `bind_addr` (default `127.0.0.1:8080`; operator sets the actual reachable address — e.g. a Wireguard interface IP — in `[registry].bind_addr` of the unified config at `~/.config/maestro/config.toml`).
### Daemon (`maestrod`)
### Daemon (`maestro daemon`)
Responsibilities:
@@ -253,16 +253,21 @@ Single-user trust model. The operator's network (Wireguard / private LAN / etc.)
## Repo layout
Cargo workspace:
Cargo workspace. NAP-245 collapsed the three binaries (`maestro`, `maestrod`, `maestro-registry`) into a single `maestro` binary with feature-gated subcommands. Library crates kept their existing shape; per-binary `main.rs` files are gone.
```
maestro/
├── Cargo.toml # workspace
├── crates/
│ ├── maestro-protocol/ # message types, shared between binaries
│ ├── maestro-registry/ # registry binary
│ ├── maestro-daemon/ # daemon binary
│ └── maestro-cli/ # `maestro` CLI for scripting (spawn/list/kill from shell)
│ ├── maestro/ # umbrella binary: `maestro <list|spawn|attach|kill|set-registry|policy|daemon|registry>` (features: cli/daemon/registry)
│ ├── maestro-protocol/ # message types, shared between subsystems
│ ├── maestro-config/ # unified on-disk config schema
│ ├── maestro-cli/ # CLI subcommand types + dispatch (library)
│ ├── maestro-daemon/ # daemon subsystem (library + `maestro daemon` dispatch)
│ ├── maestro-registry/ # registry subsystem (library + `maestro registry` dispatch)
│ ├── maestro-web/ # standalone `maestro-web` browser-UI binary
│ ├── maestro-installer/ # `maestro-install` wizard
│ └── maestro-e2e/ # cross-process integration tests
├── web/
│ ├── src/
│ ├── package.json
@@ -273,7 +278,7 @@ maestro/
└── CLAUDE.md
```
`maestro-cli` is a low-priority bonus — handy for scripted spawning and for testing the registry HTTP API. Drops out of scope if it slows the plan.
Thin builds for single-role hosts: `cargo install --no-default-features --features {cli|daemon|registry} maestro` produces a binary with only that subsystem's dependencies compiled in.
## Out of scope (prototype)
+3 -3
View File
@@ -1,7 +1,7 @@
#!/bin/sh
# Bootstrap installer for maestro. Builds the `maestro-installer` crate into a
# scratch dir, runs the wizard, and cleans up — leaving only the binaries the
# wizard installs (`maestrod`, `maestro`) in `~/.cargo/bin`.
# scratch dir, runs the wizard, and cleans up — leaving only the unified
# `maestro` binary in `~/.cargo/bin`.
#
# Usage:
# curl -sSf https://git.naps.pt/naps62/maestro/raw/branch/main/install.sh | sh
@@ -9,7 +9,7 @@
# Pass flags through to the wizard with `sh -s --`:
# curl -sSf <...>/install.sh | sh -s -- --yes --no-systemd
#
# Re-run any time to update — `cargo install` overwrites the existing binaries.
# Re-run any time to update — `cargo install` overwrites the existing binary.
set -eu