diff --git a/CLAUDE.md b/CLAUDE.md index 152ebbd..c9b8fa4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock index ff2a09a..e059ae2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/crates/maestro-cli/Cargo.toml b/crates/maestro-cli/Cargo.toml index 87c8f89..54a6a7a 100644 --- a/crates/maestro-cli/Cargo.toml +++ b/crates/maestro-cli/Cargo.toml @@ -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 diff --git a/crates/maestro-cli/src/dispatch.rs b/crates/maestro-cli/src/dispatch.rs new file mode 100644 index 0000000..dba6b93 --- /dev/null +++ b/crates/maestro-cli/src/dispatch.rs @@ -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, + registry_url_override: Option, +) -> 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, + 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, + 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, + 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, + 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, + config_path: &Path, +) -> Result { + 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(), + )) +} diff --git a/crates/maestro-cli/src/lib.rs b/crates/maestro-cli/src/lib.rs index ccf03ec..8e5b116 100644 --- a/crates/maestro-cli/src/lib.rs +++ b/crates/maestro-cli/src/lib.rs @@ -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}; diff --git a/crates/maestro-cli/src/main.rs b/crates/maestro-cli/src/main.rs deleted file mode 100644 index 683441f..0000000 --- a/crates/maestro-cli/src/main.rs +++ /dev/null @@ -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, - config_path: &Path, -) -> Result { - 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(), - )) -} diff --git a/crates/maestro-daemon/Cargo.toml b/crates/maestro-daemon/Cargo.toml index 33f9aff..b5f0338 100644 --- a/crates/maestro-daemon/Cargo.toml +++ b/crates/maestro-daemon/Cargo.toml @@ -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 diff --git a/crates/maestro-daemon/src/main.rs b/crates/maestro-daemon/src/cli.rs similarity index 67% rename from crates/maestro-daemon/src/main.rs rename to crates/maestro-daemon/src/cli.rs index 2a1ff76..4c9e61c 100644 --- a/crates/maestro-daemon/src/main.rs +++ b/crates/maestro-daemon/src/cli.rs @@ -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, - - #[command(subcommand)] - command: Option, -} #[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, + pub registry_url: Option, /// Reachable URL clients dial for this daemon's attach WS. #[arg(long)] - daemon_url: Option, + pub daemon_url: Option, /// Shared token (MAESTRO_TOKEN). Tip: pipe from `pass` / `op` / `secret-tool`. #[arg(long)] - token: Option, + pub token: Option, /// Stable daemon ID. Defaults to the system hostname. #[arg(long)] - daemon_id: Option, + pub daemon_id: Option, /// Local bind address for the per-session WS endpoint. #[arg(long)] - bind_addr: Option, + pub bind_addr: Option, /// Override the daemon's state directory. #[arg(long)] - state_dir: Option, + pub state_dir: Option, /// Override the worktree root. #[arg(long)] - worktree_root: Option, + pub worktree_root: Option, /// Override the recorded hostname (defaults to `gethostname(2)`). #[arg(long)] - hostname: Option, + pub hostname: Option, /// 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> = 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, + cmd: Option, +) -> Result<(), Box> { + 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) -> Result<(), Box> { "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) -> Result<(), Box> { 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>. 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) -> Result<(), Box> { 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) -> Result<(), Box> { Ok(()) } -fn configure(config_override: Option, args: ConfigureCmd) -> Result<(), Box> { +fn configure(config_override: Option, args: ConfigureArgs) -> Result<(), Box> { let path: PathBuf = match config_override { Some(p) => p, None => default_config_path()?, diff --git a/crates/maestro-daemon/src/config.rs b/crates/maestro-daemon/src/config.rs index e752540..41e342f 100644 --- a/crates/maestro-daemon/src/config.rs +++ b/crates/maestro-daemon/src/config.rs @@ -245,7 +245,7 @@ pub fn default_state_dir() -> Result { 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()); diff --git a/crates/maestro-daemon/src/lib.rs b/crates/maestro-daemon/src/lib.rs index 162c01a..f93f871 100644 --- a/crates/maestro-daemon/src/lib.rs +++ b/crates/maestro-daemon/src/lib.rs @@ -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; diff --git a/crates/maestro-e2e/src/daemon.rs b/crates/maestro-e2e/src/daemon.rs index 6ca2943..d706be8 100644 --- a/crates/maestro-e2e/src/daemon.rs +++ b/crates/maestro-e2e/src/daemon.rs @@ -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", diff --git a/crates/maestro-e2e/src/registry.rs b/crates/maestro-e2e/src/registry.rs index af5919d..c3fc154 100644 --- a/crates/maestro-e2e/src/registry.rs +++ b/crates/maestro-e2e/src/registry.rs @@ -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( diff --git a/crates/maestro-installer/Cargo.toml b/crates/maestro-installer/Cargo.toml index 98ffce0..f865bb9 100644 --- a/crates/maestro-installer/Cargo.toml +++ b/crates/maestro-installer/Cargo.toml @@ -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" diff --git a/crates/maestro-installer/src/install.rs b/crates/maestro-installer/src/install.rs index c1ccfc7..16908a7 100644 --- a/crates/maestro-installer/src/install.rs +++ b/crates/maestro-installer/src/install.rs @@ -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] diff --git a/crates/maestro-installer/src/lib.rs b/crates/maestro-installer/src/lib.rs index 8ff32c4..2c657a9 100644 --- a/crates/maestro-installer/src/lib.rs +++ b/crates/maestro-installer/src/lib.rs @@ -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: diff --git a/crates/maestro-installer/src/main.rs b/crates/maestro-installer/src/main.rs index f7af18f..e9a8688 100644 --- a/crates/maestro-installer/src/main.rs +++ b/crates/maestro-installer/src/main.rs @@ -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, @@ -101,16 +101,15 @@ fn run(args: Args) -> Result<(), Box> { 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> { "\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 Result 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(); + // 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> { - 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 `"); if web_installed { diff --git a/crates/maestro-installer/src/systemd.rs b/crates/maestro-installer/src/systemd.rs index e539b15..a2a1aa7 100644 --- a/crates/maestro-installer/src/systemd.rs +++ b/crates/maestro-installer/src/systemd.rs @@ -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 { 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 { user_unit_path_for(UNIT_FILE_NAME) @@ -35,23 +36,27 @@ pub fn user_unit_path() -> Option { /// Convenience: path for `maestro-web.service` (NAP-249). pub fn user_web_unit_path() -> Option { - 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 { + 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 { - Some( - dirs::home_dir()? - .join(".cargo") - .join("bin") - .join("maestrod"), - ) +pub fn default_maestro_path() -> Option { + 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"); diff --git a/crates/maestro-registry/Cargo.toml b/crates/maestro-registry/Cargo.toml index 2f64433..e20669b 100644 --- a/crates/maestro-registry/Cargo.toml +++ b/crates/maestro-registry/Cargo.toml @@ -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 diff --git a/crates/maestro-registry/src/lib.rs b/crates/maestro-registry/src/lib.rs index 1b7dfcf..e4c67e7 100644 --- a/crates/maestro-registry/src/lib.rs +++ b/crates/maestro-registry/src/lib.rs @@ -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}; diff --git a/crates/maestro-registry/src/main.rs b/crates/maestro-registry/src/run.rs similarity index 50% rename from crates/maestro-registry/src/main.rs rename to crates/maestro-registry/src/run.rs index 4411367..111ba7a 100644 --- a/crates/maestro-registry/src/main.rs +++ b/crates/maestro-registry/src/run.rs @@ -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> { - 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) -> Result<(), Box> { + 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())?; diff --git a/crates/maestro/Cargo.toml b/crates/maestro/Cargo.toml new file mode 100644 index 0000000..c8dfac1 --- /dev/null +++ b/crates/maestro/Cargo.toml @@ -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 } diff --git a/crates/maestro/src/main.rs b/crates/maestro/src/main.rs new file mode 100644 index 0000000..7a325b4 --- /dev/null +++ b/crates/maestro/src/main.rs @@ -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, + + /// Path to the unified Maestro config file. Default `~/.config/maestro/config.toml`. + #[arg(long, global = true, env = "MAESTRO_CONFIG")] + config: Option, + + /// 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, + }, + + /// 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> { + 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, + registry_url: Option, +) -> 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"), + } +} diff --git a/docs/INSTALL.md b/docs/INSTALL.md index b71d56a..f631dca 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -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 ` | Install from a different git repo (default: this one). | | `--branch ` | Pin a branch with `--git`. | -| `--path ` | Install from a local workspace checkout (`--path` + `-p `). | +| `--path ` | Install from a local workspace checkout (`--path` + `-p maestro`). | | `--no-locked` | Drop `--locked` from `cargo install` (resolves a fresh dep tree). | -| `--exec-start ` | Override the systemd unit's `ExecStart` (default `~/.cargo/bin/maestrod`). | +| `--exec-start ` | 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 ` | 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 # equivalent; what `systemctl --user reload` does +kill -HUP # 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. diff --git a/docs/superpowers/specs/2026-05-24-maestro-design.md b/docs/superpowers/specs/2026-05-24-maestro-design.md index c13151c..0aded2a 100644 --- a/docs/superpowers/specs/2026-05-24-maestro-design.md +++ b/docs/superpowers/specs/2026-05-24-maestro-design.md @@ -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 ` (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) diff --git a/install.sh b/install.sh index 3b16d4e..8d4718a 100755 --- a/install.sh +++ b/install.sh @@ -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