refactor(maestro): point CLI and TUI at the local daemon only
CLI drops the registry fallback, cookie auth, set-registry, and the --daemon selector; every command hits [cli].daemon_url. TUI drops the daemon tree from the sidebar and the daemon field from the spawn modal. GET /api/web/info becomes GET /api/info.
This commit is contained in:
@@ -9,7 +9,7 @@ use std::os::fd::AsFd;
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use maestro_protocol::{ClientControl, ConnectInfo};
|
||||
use maestro_protocol::ClientControl;
|
||||
use nix::sys::termios::{cfmakeraw, tcgetattr, tcsetattr, SetArg, Termios};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
@@ -34,11 +34,8 @@ pub enum AttachError {
|
||||
|
||||
impl AttachError {
|
||||
/// True when the failure was an inability to reach the WS endpoint at the
|
||||
/// transport level (connection refused). Used to fall back from the home
|
||||
/// daemon broker to the legacy registry direct-attach path (NAP-244).
|
||||
/// An HTTP-level rejection from a *reachable* daemon (e.g. the broker
|
||||
/// returns 404 / 502) is deliberately NOT treated as unreachable — the
|
||||
/// daemon answered, so we surface its error instead of masking it.
|
||||
/// transport level (connection refused), as opposed to an HTTP-level
|
||||
/// rejection from a daemon that did answer.
|
||||
pub fn is_unreachable(&self) -> bool {
|
||||
use std::io::ErrorKind;
|
||||
use tokio_tungstenite::tungstenite::Error as WsError;
|
||||
@@ -66,33 +63,14 @@ pub fn parse_detach_key(spec: &str) -> Result<u8, AttachError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the attach WS URL from a `ConnectInfo` and session ID.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn attach_url(info: &ConnectInfo, session_id: Uuid) -> Result<String, AttachError> {
|
||||
let base = info.daemon_url.trim_end_matches('/');
|
||||
if !(base.starts_with("ws://") || base.starts_with("wss://")) {
|
||||
return Err(AttachError::InvalidDaemonUrl(info.daemon_url.clone()));
|
||||
}
|
||||
Ok(format!(
|
||||
"{base}/sessions/{session_id}/stream?token={token}",
|
||||
token = urlencoding::encode(&info.jwt)
|
||||
))
|
||||
}
|
||||
|
||||
/// Build the home-daemon broker attach WS URL from the daemon's HTTP base.
|
||||
/// Build the attach WS URL from the daemon's HTTP base.
|
||||
///
|
||||
/// The broker endpoint (`WS /api/attach/:id`, NAP-243) lives on the daemon's
|
||||
/// `bind_addr`, so we take the HTTP base (`http://127.0.0.1:7070`), swap the
|
||||
/// scheme to its WS equivalent, and append the route. The daemon picks the
|
||||
/// transport and dials the target (which verifies the registry-minted
|
||||
/// pair-attach JWT — session binding, not a per-peer allow-list); the CLI just
|
||||
/// pumps frames.
|
||||
/// The endpoint (`WS /api/attach/:id`) lives on the daemon's `bind_addr`, so we
|
||||
/// take the HTTP base (`http://127.0.0.1:7070`), swap the scheme to its WS
|
||||
/// equivalent, and append the route.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn broker_attach_url(
|
||||
home_daemon_http_base: &str,
|
||||
session_id: Uuid,
|
||||
) -> Result<String, AttachError> {
|
||||
let base = home_daemon_http_base.trim_end_matches('/');
|
||||
pub fn attach_url(daemon_http_base: &str, session_id: Uuid) -> Result<String, AttachError> {
|
||||
let base = daemon_http_base.trim_end_matches('/');
|
||||
let ws_base = if let Some(rest) = base.strip_prefix("https://") {
|
||||
format!("wss://{rest}")
|
||||
} else if let Some(rest) = base.strip_prefix("http://") {
|
||||
@@ -100,9 +78,7 @@ pub fn broker_attach_url(
|
||||
} else if base.starts_with("ws://") || base.starts_with("wss://") {
|
||||
base.to_string()
|
||||
} else {
|
||||
return Err(AttachError::InvalidDaemonUrl(
|
||||
home_daemon_http_base.to_string(),
|
||||
));
|
||||
return Err(AttachError::InvalidDaemonUrl(daemon_http_base.to_string()));
|
||||
};
|
||||
Ok(format!("{ws_base}/api/attach/{session_id}"))
|
||||
}
|
||||
@@ -151,24 +127,9 @@ fn current_winsize() -> Option<(u16, u16)> {
|
||||
Some((ws.ws_col, ws.ws_row))
|
||||
}
|
||||
|
||||
/// Drive the attach loop until the client detaches or the remote closes.
|
||||
///
|
||||
/// `connect_info` is whatever `GET /api/sessions/:id/connect-info` returned;
|
||||
/// `detach_byte` is the single byte that exits cleanly without killing the
|
||||
/// session.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub async fn run_attach(
|
||||
connect_info: ConnectInfo,
|
||||
session_id: Uuid,
|
||||
detach_byte: u8,
|
||||
) -> Result<(), AttachError> {
|
||||
let url = attach_url(&connect_info, session_id)?;
|
||||
run_attach_url(&url, detach_byte).await
|
||||
}
|
||||
|
||||
/// Drive the attach loop against an already-built WS `url`. Shared by the
|
||||
/// home-daemon broker path ([`broker_attach_url`]) and the legacy registry
|
||||
/// direct-attach path ([`run_attach`]) — both speak the same PTY wire format.
|
||||
/// Drive the attach loop against an already-built WS `url` ([`attach_url`])
|
||||
/// until the client detaches or the remote closes. `detach_byte` is the single
|
||||
/// byte that exits cleanly without killing the session.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachError> {
|
||||
let (ws, _resp) = tokio_tungstenite::connect_async(url).await?;
|
||||
@@ -273,7 +234,6 @@ pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachErro
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use maestro_protocol::ConnectInfo;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
@@ -297,64 +257,28 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_url_uses_ws_path_and_token_query() {
|
||||
let info = ConnectInfo {
|
||||
daemon_url: "ws://10.0.0.1:7070".into(),
|
||||
jwt: "abc.def-ghi_jkl".into(),
|
||||
};
|
||||
let id = Uuid::from_u128(0x1234_5678);
|
||||
let url = attach_url(&info, id).unwrap();
|
||||
assert!(url.starts_with("ws://10.0.0.1:7070/sessions/"));
|
||||
assert!(url.contains(&id.to_string()));
|
||||
assert!(url.contains("/stream?token=abc.def-ghi_jkl"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_url_rejects_non_ws_scheme() {
|
||||
let info = ConnectInfo {
|
||||
daemon_url: "http://x".into(),
|
||||
jwt: "t".into(),
|
||||
};
|
||||
assert!(matches!(
|
||||
attach_url(&info, Uuid::nil()),
|
||||
Err(AttachError::InvalidDaemonUrl(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broker_attach_url_swaps_scheme_and_appends_route() {
|
||||
fn attach_url_swaps_scheme_and_appends_route() {
|
||||
let id = Uuid::from_u128(0xabcd);
|
||||
assert_eq!(
|
||||
broker_attach_url("http://127.0.0.1:7070", id).unwrap(),
|
||||
attach_url("http://127.0.0.1:7070", id).unwrap(),
|
||||
format!("ws://127.0.0.1:7070/api/attach/{id}")
|
||||
);
|
||||
assert_eq!(
|
||||
broker_attach_url("https://laptop.lan:7070/", id).unwrap(),
|
||||
attach_url("https://laptop.lan:7070/", id).unwrap(),
|
||||
format!("wss://laptop.lan:7070/api/attach/{id}")
|
||||
);
|
||||
// already-ws bases pass through
|
||||
assert_eq!(
|
||||
broker_attach_url("ws://10.0.0.1:7070", id).unwrap(),
|
||||
attach_url("ws://10.0.0.1:7070", id).unwrap(),
|
||||
format!("ws://10.0.0.1:7070/api/attach/{id}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broker_attach_url_rejects_unknown_scheme() {
|
||||
fn attach_url_rejects_unknown_scheme() {
|
||||
assert!(matches!(
|
||||
broker_attach_url("ftp://x", Uuid::nil()),
|
||||
attach_url("ftp://x", Uuid::nil()),
|
||||
Err(AttachError::InvalidDaemonUrl(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_url_trims_trailing_slash() {
|
||||
let info = ConnectInfo {
|
||||
daemon_url: "wss://reg.example.com/".into(),
|
||||
jwt: "t".into(),
|
||||
};
|
||||
let id = Uuid::nil();
|
||||
let url = attach_url(&info, id).unwrap();
|
||||
assert!(!url.contains("//sessions"), "{url}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,15 @@ use uuid::Uuid;
|
||||
#[command(
|
||||
name = "maestro",
|
||||
version,
|
||||
about = "Control terminal sessions across one or more maestrod hosts",
|
||||
about = "Control terminal sessions on the local maestro daemon",
|
||||
arg_required_else_help = true
|
||||
)]
|
||||
pub struct Cli {
|
||||
/// Override the registry URL for this invocation (e.g. `https://maestro.example.com`).
|
||||
/// If omitted, `[shared].registry_url` from the unified config file is used.
|
||||
#[arg(long, global = true, env = "MAESTRO_REGISTRY_URL")]
|
||||
pub registry_url: Option<String>,
|
||||
/// Override the daemon HTTP base for this invocation (e.g.
|
||||
/// `http://127.0.0.1:7070`). If omitted, `[cli].daemon_url` from the
|
||||
/// unified config file is used.
|
||||
#[arg(long, global = true, env = "MAESTRO_DAEMON_URL")]
|
||||
pub daemon_url: Option<String>,
|
||||
|
||||
/// Path to the unified Maestro config file. Default `~/.config/maestro/config.toml`.
|
||||
#[arg(long, global = true, env = "MAESTRO_CONFIG")]
|
||||
@@ -32,23 +33,18 @@ pub struct Cli {
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum Command {
|
||||
/// List all sessions, grouped by daemon.
|
||||
/// List all sessions.
|
||||
List(ListArgs),
|
||||
/// Spawn a new session on a given daemon.
|
||||
/// Spawn a new session.
|
||||
Spawn(SpawnArgs),
|
||||
/// Attach to a session over WebSocket. Bytes flow through the local TTY.
|
||||
Attach(AttachArgs),
|
||||
/// Kill a session by ID.
|
||||
Kill(KillArgs),
|
||||
/// Set the default registry URL persisted in the CLI config.
|
||||
SetRegistry(SetRegistryArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct ListArgs {
|
||||
/// Show only sessions on this daemon.
|
||||
#[arg(long)]
|
||||
pub daemon: Option<String>,
|
||||
/// Output as JSON instead of a table.
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
@@ -56,9 +52,6 @@ pub struct ListArgs {
|
||||
|
||||
#[derive(Debug, Clone, clap::Args)]
|
||||
pub struct SpawnArgs {
|
||||
/// Daemon ID to spawn on.
|
||||
#[arg(long)]
|
||||
pub daemon: String,
|
||||
/// Working directory for the session (must exist on the daemon's host).
|
||||
#[arg(long)]
|
||||
pub cwd: PathBuf,
|
||||
@@ -97,12 +90,6 @@ pub struct KillArgs {
|
||||
pub session_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct SetRegistryArgs {
|
||||
/// Registry base URL to persist.
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
fn parse_kv(s: &str) -> Result<(String, String), String> {
|
||||
match s.split_once('=') {
|
||||
Some((k, v)) if !k.is_empty() => Ok((k.to_string(), v.to_string())),
|
||||
@@ -117,10 +104,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parses_list() {
|
||||
let cli = Cli::try_parse_from(["maestro", "list", "--daemon", "laptop", "--json"]).unwrap();
|
||||
let cli = Cli::try_parse_from(["maestro", "list", "--json"]).unwrap();
|
||||
match cli.command {
|
||||
Command::List(args) => {
|
||||
assert_eq!(args.daemon.as_deref(), Some("laptop"));
|
||||
assert!(args.json);
|
||||
}
|
||||
_ => panic!("wrong subcommand"),
|
||||
@@ -132,8 +118,6 @@ mod tests {
|
||||
let cli = Cli::try_parse_from([
|
||||
"maestro",
|
||||
"spawn",
|
||||
"--daemon",
|
||||
"vm1",
|
||||
"--cwd",
|
||||
"/tmp",
|
||||
"--command",
|
||||
@@ -151,7 +135,6 @@ mod tests {
|
||||
.unwrap();
|
||||
match cli.command {
|
||||
Command::Spawn(a) => {
|
||||
assert_eq!(a.daemon, "vm1");
|
||||
assert_eq!(a.command, "bash");
|
||||
assert_eq!(a.branch.as_deref(), Some("feat/x"));
|
||||
assert!(a.cleanup_on_kill);
|
||||
@@ -169,8 +152,6 @@ mod tests {
|
||||
let err = Cli::try_parse_from([
|
||||
"maestro",
|
||||
"spawn",
|
||||
"--daemon",
|
||||
"vm1",
|
||||
"--cwd",
|
||||
"/tmp",
|
||||
"--command",
|
||||
@@ -197,8 +178,6 @@ mod tests {
|
||||
let err = Cli::try_parse_from([
|
||||
"maestro",
|
||||
"spawn",
|
||||
"--daemon",
|
||||
"x",
|
||||
"--cwd",
|
||||
"/",
|
||||
"--command",
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
//! CLI view over the unified Maestro config at `~/.config/maestro/config.toml`.
|
||||
//!
|
||||
//! Reads `[shared].registry_url` (registry HTTP base) and `[cli].cookie`
|
||||
//! (persisted `maestro_session` JWT). All other sections in the file are
|
||||
//! preserved on write.
|
||||
//! Reads `[cli].daemon_url` and, as a fallback, `[daemon].bind_addr`.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -11,10 +9,10 @@ use maestro_config::MaestroConfigFile;
|
||||
|
||||
pub use maestro_config::CONFIG_PATH_ENV;
|
||||
|
||||
/// Last-resort home-daemon HTTP base when neither config nor a local
|
||||
/// Last-resort daemon HTTP base when neither config nor a local
|
||||
/// `[daemon].bind_addr` tells us otherwise. Matches the daemon's default
|
||||
/// `bind_addr` (`crates/maestro-daemon` defaults).
|
||||
pub const DEFAULT_HOME_DAEMON_URL: &str = "http://127.0.0.1:7070";
|
||||
/// `bind_addr`.
|
||||
pub const DEFAULT_DAEMON_URL: &str = "http://127.0.0.1:7070";
|
||||
|
||||
/// `~/.config/maestro/config.toml` (XDG-aware).
|
||||
pub fn default_config_path() -> Result<PathBuf, ConfigError> {
|
||||
@@ -30,80 +28,46 @@ pub fn resolve_config_path() -> Result<PathBuf, ConfigError> {
|
||||
pub enum ConfigError {
|
||||
#[error(transparent)]
|
||||
Inner(#[from] maestro_config::ConfigError),
|
||||
#[error(
|
||||
"no registry URL configured — pass --registry-url, set \
|
||||
MAESTRO_REGISTRY_URL, or run `maestro set-registry <url>`"
|
||||
)]
|
||||
NoRegistryUrl,
|
||||
}
|
||||
|
||||
/// Resolved CLI config — the subset of the unified file that this binary
|
||||
/// actually consumes.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct CliConfig {
|
||||
pub registry_url: Option<String>,
|
||||
pub cookie: Option<String>,
|
||||
/// `[cli].home_daemon_url` — explicit home-daemon HTTP base (NAP-244).
|
||||
pub home_daemon_url: Option<String>,
|
||||
/// `[cli].daemon_url` — explicit daemon HTTP base.
|
||||
pub daemon_url: Option<String>,
|
||||
/// `[daemon].bind_addr` if a daemon is configured on this host. Used to
|
||||
/// derive the default home-daemon URL when `home_daemon_url` is unset.
|
||||
/// derive the default daemon URL when `daemon_url` is unset.
|
||||
pub daemon_bind_addr: Option<SocketAddr>,
|
||||
}
|
||||
|
||||
impl CliConfig {
|
||||
/// Load the subset of the unified file the CLI consumes:
|
||||
/// `[shared].registry_url`, `[cli].cookie`, `[cli].home_daemon_url`, and
|
||||
/// `[daemon].bind_addr` (for default home-daemon derivation). Returns an
|
||||
/// empty config if the file is missing — the CLI tolerates a missing file
|
||||
/// so `maestro set-registry <url>` can bootstrap it.
|
||||
/// Load the subset of the unified file the CLI consumes. A missing file is
|
||||
/// fine — every field has a default.
|
||||
pub fn load(path: &Path) -> Result<Self, ConfigError> {
|
||||
let file = MaestroConfigFile::load_or_default(path)?;
|
||||
Ok(Self {
|
||||
registry_url: file.shared.registry_url,
|
||||
cookie: file.cli.cookie,
|
||||
home_daemon_url: file.cli.home_daemon_url,
|
||||
daemon_url: file.cli.daemon_url,
|
||||
daemon_bind_addr: file.daemon.bind_addr,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve the home-daemon HTTP base the broker path should target.
|
||||
/// Resolve the daemon HTTP base to talk to.
|
||||
///
|
||||
/// Precedence: explicit `override_url` (CLI flag / env) → `[cli]
|
||||
/// .home_daemon_url` → `http://<[daemon].bind_addr>` → the built-in
|
||||
/// [`DEFAULT_HOME_DAEMON_URL`]. Always returns a value; reachability is
|
||||
/// decided later by whether the connection actually succeeds.
|
||||
pub fn resolve_home_daemon_url(&self, override_url: Option<String>) -> String {
|
||||
/// Precedence: explicit `override_url` (CLI flag / env) → `[cli].daemon_url`
|
||||
/// → `http://<[daemon].bind_addr>` → [`DEFAULT_DAEMON_URL`]. Always returns
|
||||
/// a value; reachability is decided by whether the connection succeeds.
|
||||
pub fn resolve_daemon_url(&self, override_url: Option<String>) -> String {
|
||||
override_url
|
||||
.or_else(|| self.home_daemon_url.clone())
|
||||
.or_else(|| self.daemon_url.clone())
|
||||
.or_else(|| self.daemon_bind_addr.map(|a| format!("http://{a}")))
|
||||
.unwrap_or_else(|| DEFAULT_HOME_DAEMON_URL.to_string())
|
||||
}
|
||||
|
||||
/// Persist `[shared].registry_url`. Read-modify-write — preserves
|
||||
/// any other sections already in the file (`[daemon]`, `[registry]`,
|
||||
/// existing `[cli].cookie`).
|
||||
pub fn save_registry_url(path: &Path, url: &str) -> Result<(), ConfigError> {
|
||||
let mut file = MaestroConfigFile::load_or_default(path)?;
|
||||
file.shared.registry_url = Some(url.to_string());
|
||||
file.save(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist `[cli].cookie`. Same read-modify-write semantics as
|
||||
/// [`save_registry_url`]; in particular this does not touch
|
||||
/// `[shared].registry_url`.
|
||||
pub fn save_cookie(path: &Path, cookie: &str) -> Result<(), ConfigError> {
|
||||
let mut file = MaestroConfigFile::load_or_default(path)?;
|
||||
file.cli.cookie = Some(cookie.to_string());
|
||||
file.save(path)?;
|
||||
Ok(())
|
||||
.unwrap_or_else(|| DEFAULT_DAEMON_URL.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use maestro_config::SharedSection;
|
||||
|
||||
fn write_file(path: &Path, body: &str) {
|
||||
std::fs::write(path, body).unwrap();
|
||||
@@ -117,134 +81,48 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_reads_shared_registry_url_and_cli_cookie() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
write_file(
|
||||
&path,
|
||||
r#"
|
||||
[shared]
|
||||
registry_url = "https://r.example"
|
||||
|
||||
[cli]
|
||||
cookie = "eyJ..."
|
||||
"#,
|
||||
);
|
||||
let cfg = CliConfig::load(&path).unwrap();
|
||||
assert_eq!(cfg.registry_url.as_deref(), Some("https://r.example"));
|
||||
assert_eq!(cfg.cookie.as_deref(), Some("eyJ..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_registry_url_preserves_other_sections() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
write_file(
|
||||
&path,
|
||||
r#"
|
||||
[shared]
|
||||
token = "abc"
|
||||
|
||||
[daemon]
|
||||
daemon_url = "ws://laptop:7070"
|
||||
"#,
|
||||
);
|
||||
CliConfig::save_registry_url(&path, "https://new.example/").unwrap();
|
||||
let after = MaestroConfigFile::load(&path).unwrap();
|
||||
assert_eq!(
|
||||
after.shared.registry_url.as_deref(),
|
||||
Some("https://new.example/")
|
||||
);
|
||||
assert_eq!(after.shared.token.as_deref(), Some("abc"));
|
||||
assert_eq!(after.daemon.daemon_url.as_deref(), Some("ws://laptop:7070"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_cookie_does_not_touch_registry_url() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
write_file(
|
||||
&path,
|
||||
r#"
|
||||
[shared]
|
||||
registry_url = "https://r.example"
|
||||
"#,
|
||||
);
|
||||
CliConfig::save_cookie(&path, "eyJ.new").unwrap();
|
||||
let after = MaestroConfigFile::load(&path).unwrap();
|
||||
assert_eq!(
|
||||
after.shared.registry_url.as_deref(),
|
||||
Some("https://r.example")
|
||||
);
|
||||
assert_eq!(after.cli.cookie.as_deref(), Some("eyJ.new"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_home_daemon_url_precedence() {
|
||||
fn resolve_daemon_url_precedence() {
|
||||
// override wins over everything
|
||||
let cfg = CliConfig {
|
||||
home_daemon_url: Some("http://from-cli-section:7070".into()),
|
||||
daemon_url: Some("http://from-cli-section:7070".into()),
|
||||
daemon_bind_addr: Some("127.0.0.1:9999".parse().unwrap()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
cfg.resolve_home_daemon_url(Some("http://override:1".into())),
|
||||
cfg.resolve_daemon_url(Some("http://override:1".into())),
|
||||
"http://override:1"
|
||||
);
|
||||
// [cli].home_daemon_url next
|
||||
assert_eq!(
|
||||
cfg.resolve_home_daemon_url(None),
|
||||
"http://from-cli-section:7070"
|
||||
);
|
||||
// [cli].daemon_url next
|
||||
assert_eq!(cfg.resolve_daemon_url(None), "http://from-cli-section:7070");
|
||||
// derive from [daemon].bind_addr when no explicit url
|
||||
let cfg = CliConfig {
|
||||
daemon_bind_addr: Some("10.0.0.7:7070".parse().unwrap()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(cfg.resolve_home_daemon_url(None), "http://10.0.0.7:7070");
|
||||
assert_eq!(cfg.resolve_daemon_url(None), "http://10.0.0.7:7070");
|
||||
// built-in default when nothing is configured
|
||||
let cfg = CliConfig::default();
|
||||
assert_eq!(cfg.resolve_home_daemon_url(None), DEFAULT_HOME_DAEMON_URL);
|
||||
assert_eq!(cfg.resolve_daemon_url(None), DEFAULT_DAEMON_URL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_reads_home_daemon_url_and_daemon_bind_addr() {
|
||||
fn load_reads_daemon_url_and_daemon_bind_addr() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
write_file(
|
||||
&path,
|
||||
r#"
|
||||
[cli]
|
||||
home_daemon_url = "http://laptop:7070"
|
||||
daemon_url = "http://laptop:7070"
|
||||
|
||||
[daemon]
|
||||
bind_addr = "127.0.0.1:7071"
|
||||
"#,
|
||||
);
|
||||
let cfg = CliConfig::load(&path).unwrap();
|
||||
assert_eq!(cfg.home_daemon_url.as_deref(), Some("http://laptop:7070"));
|
||||
assert_eq!(cfg.daemon_url.as_deref(), Some("http://laptop:7070"));
|
||||
assert_eq!(
|
||||
cfg.daemon_bind_addr,
|
||||
Some("127.0.0.1:7071".parse().unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_creates_file_if_missing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("nested/config.toml");
|
||||
CliConfig::save_registry_url(&path, "https://x").unwrap();
|
||||
assert!(path.exists());
|
||||
let after = MaestroConfigFile::load(&path).unwrap();
|
||||
assert_eq!(
|
||||
after,
|
||||
MaestroConfigFile {
|
||||
shared: SharedSection {
|
||||
registry_url: Some("https://x".into()),
|
||||
token: None,
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,16 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! ## Transport (NAP-244)
|
||||
//!
|
||||
//! The CLI talks to its **home daemon** (`[cli].home_daemon_url`, default
|
||||
//! `http://127.0.0.1:7070`) and lets the daemon broker the rest:
|
||||
//!
|
||||
//! * `list` reads the daemon's local catalog mirror (own ∪ peer sessions).
|
||||
//! * `spawn` / `kill` hit the daemon's local session endpoints.
|
||||
//! * `attach` opens the daemon's `WS /api/attach/:id` broker, which picks the
|
||||
//! transport and dials the target (the target verifies the registry-minted
|
||||
//! pair-attach JWT — session binding, not a per-peer allow-list).
|
||||
//!
|
||||
//! If the home daemon is **unreachable** (connection refused / not running),
|
||||
//! every command falls back to the legacy registry path (registry HTTP +
|
||||
//! direct WS) with a one-line stderr warning, so migration is painless. As of
|
||||
//! NAP-327 the daemon brokers cross-daemon create *and* kill over the registry
|
||||
//! control link, so those no longer need a fallback — only an unreachable home
|
||||
//! daemon does.
|
||||
//! Every command talks to the local daemon (`[cli].daemon_url`, default
|
||||
//! `http://127.0.0.1:7070`): `list` reads its session catalog, `spawn` / `kill`
|
||||
//! hit its session endpoints, and `attach` opens `WS /api/attach/:id`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::attach::{broker_attach_url, parse_detach_key, run_attach, run_attach_url};
|
||||
use crate::cli::{AttachArgs, Command, KillArgs, ListArgs, SetRegistryArgs, SpawnArgs};
|
||||
use crate::attach::{attach_url, parse_detach_key, run_attach_url};
|
||||
use crate::cli::{AttachArgs, Command, KillArgs, ListArgs, SpawnArgs};
|
||||
use crate::config::{default_config_path, CliConfig, ConfigError};
|
||||
use crate::http::{HttpError, RegistryClient};
|
||||
use crate::sessions::{fetch_connect_info, run_kill, run_list, run_spawn};
|
||||
use crate::http::DaemonClient;
|
||||
use crate::sessions::{run_kill, run_list, run_spawn};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[allow(clippy::result_large_err)]
|
||||
@@ -43,14 +26,12 @@ pub enum CliError {
|
||||
}
|
||||
|
||||
/// Dispatch a parsed top-level subcommand. The umbrella binary passes the
|
||||
/// global flags (`--config`, `--registry-url`, `--home-daemon-url`) in
|
||||
/// alongside the variant.
|
||||
/// global flags (`--config`, `--daemon-url`) in alongside the variant.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub async fn dispatch(
|
||||
command: Command,
|
||||
config: Option<PathBuf>,
|
||||
registry_url_override: Option<String>,
|
||||
home_daemon_url_override: Option<String>,
|
||||
daemon_url_override: Option<String>,
|
||||
) -> Result<(), CliError> {
|
||||
let config_path = match config {
|
||||
Some(p) => p,
|
||||
@@ -58,116 +39,42 @@ pub async fn dispatch(
|
||||
};
|
||||
|
||||
match command {
|
||||
Command::SetRegistry(args) => set_registry(&config_path, args),
|
||||
Command::List(args) => {
|
||||
list(
|
||||
&config_path,
|
||||
registry_url_override,
|
||||
home_daemon_url_override,
|
||||
args,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Command::Spawn(args) => {
|
||||
spawn(
|
||||
&config_path,
|
||||
registry_url_override,
|
||||
home_daemon_url_override,
|
||||
args,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Command::Attach(args) => {
|
||||
attach(
|
||||
&config_path,
|
||||
registry_url_override,
|
||||
home_daemon_url_override,
|
||||
args,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Command::Kill(args) => {
|
||||
kill(
|
||||
&config_path,
|
||||
registry_url_override,
|
||||
home_daemon_url_override,
|
||||
args,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Command::List(args) => list(&config_path, daemon_url_override, args).await,
|
||||
Command::Spawn(args) => spawn(&config_path, daemon_url_override, args).await,
|
||||
Command::Attach(args) => attach(&config_path, daemon_url_override, args).await,
|
||||
Command::Kill(args) => kill(&config_path, daemon_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 list(
|
||||
config_path: &Path,
|
||||
registry_url_override: Option<String>,
|
||||
home_daemon_url_override: Option<String>,
|
||||
daemon_url_override: Option<String>,
|
||||
args: ListArgs,
|
||||
) -> Result<(), CliError> {
|
||||
let cfg = CliConfig::load(config_path)?;
|
||||
let home_url = cfg.resolve_home_daemon_url(home_daemon_url_override);
|
||||
let daemon = daemon_client(&home_url, config_path);
|
||||
let daemon = client(config_path, daemon_url_override)?;
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
|
||||
match run_list(&daemon, &args, &mut stdout).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.is_unreachable() => {
|
||||
warn_fallback(&home_url, &e);
|
||||
let registry = registry_client(registry_url_override, &cfg, config_path)?;
|
||||
run_list(®istry, &args, &mut stdout).await?;
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
run_list(&daemon, &args, &mut stdout).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
async fn spawn(
|
||||
config_path: &Path,
|
||||
registry_url_override: Option<String>,
|
||||
home_daemon_url_override: Option<String>,
|
||||
daemon_url_override: Option<String>,
|
||||
args: SpawnArgs,
|
||||
) -> Result<(), CliError> {
|
||||
let cfg = CliConfig::load(config_path)?;
|
||||
let home_url = cfg.resolve_home_daemon_url(home_daemon_url_override.clone());
|
||||
let daemon = daemon_client(&home_url, config_path);
|
||||
let daemon = client(config_path, daemon_url_override)?;
|
||||
let attach_after = args.attach;
|
||||
// `spawn --attach` has no detach-key flag; use the attach default (Ctrl-q).
|
||||
let detach_key = 0x11;
|
||||
|
||||
// The home daemon now brokers cross-daemon create over the registry
|
||||
// control link (NAP-327), so the only reason to fall back to the legacy
|
||||
// registry HTTP path is the home daemon being unreachable.
|
||||
let session = match run_spawn(&daemon, args.clone()).await {
|
||||
Ok(s) => s,
|
||||
Err(e) if e.is_unreachable() => {
|
||||
warn_fallback(&home_url, &e);
|
||||
let registry = registry_client(registry_url_override.clone(), &cfg, config_path)?;
|
||||
run_spawn(®istry, args).await?
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let session = run_spawn(&daemon, args).await?;
|
||||
println!("spawned {}", session.id);
|
||||
|
||||
if attach_after {
|
||||
attach_session(
|
||||
&home_url,
|
||||
registry_url_override,
|
||||
&cfg,
|
||||
config_path,
|
||||
session.id,
|
||||
detach_key,
|
||||
)
|
||||
.await?;
|
||||
let url = attach_url(daemon.base(), session.id)?;
|
||||
run_attach_url(&url, detach_key).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -175,105 +82,32 @@ async fn spawn(
|
||||
#[allow(clippy::result_large_err)]
|
||||
async fn attach(
|
||||
config_path: &Path,
|
||||
registry_url_override: Option<String>,
|
||||
home_daemon_url_override: Option<String>,
|
||||
daemon_url_override: Option<String>,
|
||||
args: AttachArgs,
|
||||
) -> Result<(), CliError> {
|
||||
let cfg = CliConfig::load(config_path)?;
|
||||
let home_url = cfg.resolve_home_daemon_url(home_daemon_url_override);
|
||||
let daemon = client(config_path, daemon_url_override)?;
|
||||
let detach = parse_detach_key(&args.detach_key)?;
|
||||
attach_session(
|
||||
&home_url,
|
||||
registry_url_override,
|
||||
&cfg,
|
||||
config_path,
|
||||
args.session_id,
|
||||
detach,
|
||||
)
|
||||
.await
|
||||
let url = attach_url(daemon.base(), args.session_id)?;
|
||||
run_attach_url(&url, detach).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
async fn kill(
|
||||
config_path: &Path,
|
||||
registry_url_override: Option<String>,
|
||||
home_daemon_url_override: Option<String>,
|
||||
daemon_url_override: Option<String>,
|
||||
args: KillArgs,
|
||||
) -> Result<(), CliError> {
|
||||
let cfg = CliConfig::load(config_path)?;
|
||||
let home_url = cfg.resolve_home_daemon_url(home_daemon_url_override);
|
||||
let daemon = daemon_client(&home_url, config_path);
|
||||
|
||||
// The home daemon brokers cross-daemon kills too (NAP-327) and 404s only on
|
||||
// a session no daemon knows about, so we no longer fall back on 404 — only
|
||||
// when the home daemon itself is unreachable.
|
||||
match run_kill(&daemon, &args).await {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.is_unreachable() => {
|
||||
warn_fallback(&home_url, &e);
|
||||
let registry = registry_client(registry_url_override, &cfg, config_path)?;
|
||||
run_kill(®istry, &args).await?;
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
let daemon = client(config_path, daemon_url_override)?;
|
||||
run_kill(&daemon, &args).await?;
|
||||
println!("killed {}", args.session_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Attach to `session_id` through the home-daemon broker, falling back to the
|
||||
/// registry's connect-info + direct WS path if the daemon is unreachable.
|
||||
/// HTTP client pointed at the daemon's API. That surface needs no auth —
|
||||
/// reaching `bind_addr` is the authorization.
|
||||
#[allow(clippy::result_large_err)]
|
||||
async fn attach_session(
|
||||
home_url: &str,
|
||||
registry_url_override: Option<String>,
|
||||
cfg: &CliConfig,
|
||||
config_path: &Path,
|
||||
session_id: uuid::Uuid,
|
||||
detach: u8,
|
||||
) -> Result<(), CliError> {
|
||||
let broker_url = broker_attach_url(home_url, session_id)?;
|
||||
match run_attach_url(&broker_url, detach).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.is_unreachable() => {
|
||||
eprintln!(
|
||||
"[maestro] home daemon at {home_url} unreachable; \
|
||||
falling back to the registry (legacy direct attach)"
|
||||
);
|
||||
let registry = registry_client(registry_url_override, cfg, config_path)?;
|
||||
let info = fetch_connect_info(®istry, session_id).await?;
|
||||
run_attach(info, session_id, detach).await?;
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP client pointed at the home daemon's local API. The daemon's local
|
||||
/// surface needs no auth (same-trust as `bind_addr`), so no cookie is sent.
|
||||
fn daemon_client(home_url: &str, config_path: &Path) -> RegistryClient {
|
||||
RegistryClient::new(home_url.to_string(), None, config_path.to_path_buf())
|
||||
}
|
||||
|
||||
/// HTTP client pointed at the registry (legacy / fallback path).
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn registry_client(
|
||||
override_url: Option<String>,
|
||||
cfg: &CliConfig,
|
||||
config_path: &Path,
|
||||
) -> Result<RegistryClient, CliError> {
|
||||
let url = override_url
|
||||
.or_else(|| cfg.registry_url.clone())
|
||||
.ok_or(ConfigError::NoRegistryUrl)?;
|
||||
Ok(RegistryClient::new(
|
||||
url,
|
||||
cfg.cookie.clone(),
|
||||
config_path.to_path_buf(),
|
||||
))
|
||||
}
|
||||
|
||||
fn warn_fallback(home_url: &str, err: &HttpError) {
|
||||
eprintln!(
|
||||
"[maestro] home daemon at {home_url} unavailable ({err}); \
|
||||
falling back to the registry"
|
||||
);
|
||||
fn client(config_path: &Path, override_url: Option<String>) -> Result<DaemonClient, CliError> {
|
||||
let cfg = CliConfig::load(config_path)?;
|
||||
Ok(DaemonClient::new(cfg.resolve_daemon_url(override_url)))
|
||||
}
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
//! Thin HTTP client wrapper around `reqwest` that persists the
|
||||
//! `maestro_session` cookie issued by the registry's cookie middleware.
|
||||
//! Thin HTTP client wrapper around `reqwest` for the daemon's `/api/*` surface.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use maestro_protocol::{extract_cookie, COOKIE_NAME};
|
||||
use reqwest::header::{HeaderMap, HeaderValue, COOKIE, SET_COOKIE};
|
||||
use reqwest::{Method, Response, StatusCode};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::config::{CliConfig, ConfigError};
|
||||
use crate::config::ConfigError;
|
||||
|
||||
/// Time budget for establishing a TCP+TLS connection to the registry/daemon.
|
||||
/// Time budget for establishing a TCP connection to the daemon.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
/// Total time budget for a single request (connect + send + receive). Bounds
|
||||
/// the case where the peer is TCP-alive but never makes forward progress.
|
||||
@@ -23,7 +18,7 @@ const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
///
|
||||
/// Centralizing construction here guarantees every client the CLI uses gets a
|
||||
/// connect *and* request timeout, so `maestro` never hangs forever on an
|
||||
/// unhealthy registry or daemon (NAP-262).
|
||||
/// unhealthy daemon (NAP-262).
|
||||
pub fn build_client() -> reqwest::Client {
|
||||
build_client_with_timeouts(CONNECT_TIMEOUT, REQUEST_TIMEOUT)
|
||||
}
|
||||
@@ -47,41 +42,30 @@ pub enum HttpError {
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("config: {0}")]
|
||||
Config(#[from] ConfigError),
|
||||
#[error("invalid header: {0}")]
|
||||
InvalidHeader(#[from] reqwest::header::InvalidHeaderValue),
|
||||
}
|
||||
|
||||
impl HttpError {
|
||||
/// True when the error is a transport-level failure to reach the host
|
||||
/// (connection refused / connect error) rather than an HTTP-level
|
||||
/// rejection. The CLI uses this to decide whether to fall back from the
|
||||
/// home daemon to the legacy registry path (NAP-244).
|
||||
/// rejection.
|
||||
pub fn is_unreachable(&self) -> bool {
|
||||
matches!(self, HttpError::Reqwest(e) if e.is_connect())
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP client bound to a single registry. Persists the auth cookie back to
|
||||
/// disk when the registry sets one (first-contact bootstrap from
|
||||
/// `crates/maestro-registry/src/auth.rs`).
|
||||
/// HTTP client bound to one daemon's `/api/*` base URL. The daemon's surface
|
||||
/// needs no auth — reaching its bind address is the authorization.
|
||||
#[derive(Debug)]
|
||||
pub struct RegistryClient {
|
||||
pub struct DaemonClient {
|
||||
base: String,
|
||||
inner: reqwest::Client,
|
||||
/// Cookie value (the JWT body), shared with the config-persister.
|
||||
cookie: Arc<Mutex<Option<String>>>,
|
||||
/// Where to write the config back when the cookie changes.
|
||||
config_path: PathBuf,
|
||||
}
|
||||
|
||||
impl RegistryClient {
|
||||
pub fn new(base: String, cookie: Option<String>, config_path: PathBuf) -> Self {
|
||||
let inner = build_client();
|
||||
impl DaemonClient {
|
||||
pub fn new(base: String) -> Self {
|
||||
Self {
|
||||
base: base.trim_end_matches('/').to_string(),
|
||||
inner,
|
||||
cookie: Arc::new(Mutex::new(cookie)),
|
||||
config_path,
|
||||
inner: build_client(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,13 +73,6 @@ impl RegistryClient {
|
||||
&self.base
|
||||
}
|
||||
|
||||
/// Snapshot of the current cookie (mostly for tests).
|
||||
pub fn current_cookie(&self) -> Option<String> {
|
||||
self.cookie.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Send a request, attaching the persisted cookie. If the response sets a
|
||||
/// new cookie (first contact, or a rotation), persist it back to disk.
|
||||
async fn send(
|
||||
&self,
|
||||
method: Method,
|
||||
@@ -103,39 +80,11 @@ impl RegistryClient {
|
||||
body: Option<serde_json::Value>,
|
||||
) -> Result<Response, HttpError> {
|
||||
let url = format!("{}{}", self.base, path);
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Some(c) = self.cookie.lock().unwrap().as_ref() {
|
||||
headers.insert(
|
||||
COOKIE,
|
||||
HeaderValue::from_str(&format!("{COOKIE_NAME}={c}"))?,
|
||||
);
|
||||
}
|
||||
let mut req = self.inner.request(method, &url).headers(headers);
|
||||
let mut req = self.inner.request(method, &url);
|
||||
if let Some(b) = body {
|
||||
req = req.json(&b);
|
||||
}
|
||||
let resp = req.send().await?;
|
||||
self.capture_cookie(resp.headers())?;
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
fn capture_cookie(&self, headers: &HeaderMap) -> Result<(), HttpError> {
|
||||
let Some(raw) = headers.get(SET_COOKIE).and_then(|h| h.to_str().ok()) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(val) = extract_cookie(raw, COOKIE_NAME) else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut guard = self.cookie.lock().unwrap();
|
||||
if guard.as_deref() == Some(val) {
|
||||
return Ok(());
|
||||
}
|
||||
*guard = Some(val.to_string());
|
||||
// Persist; on failure, log but don't abort the request.
|
||||
if let Err(e) = CliConfig::save_cookie(&self.config_path, val) {
|
||||
tracing::warn!(error = %e, path = %self.config_path.display(), "failed to persist cookie");
|
||||
}
|
||||
Ok(())
|
||||
Ok(req.send().await?)
|
||||
}
|
||||
|
||||
pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, HttpError> {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
//! `maestro` CLI — thin client for the registry/daemon API.
|
||||
//!
|
||||
//! See `docs/superpowers/specs/2026-05-24-maestro-design.md` §"Wire protocol"
|
||||
//! and the registry's `client_api.rs` for the HTTP surface this client targets.
|
||||
//! `maestro` CLI — thin client for the local daemon's `/api/*` surface.
|
||||
|
||||
#![deny(missing_debug_implementations)]
|
||||
|
||||
|
||||
@@ -1,45 +1,46 @@
|
||||
//! `maestro list / spawn / kill` — registry HTTP calls + console formatting.
|
||||
//! `maestro list / spawn / kill` — daemon HTTP calls + console formatting.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
use maestro_protocol::{
|
||||
ActivityState, ConnectInfo, CreateSessionRequest, DaemonInfo, Session, SessionStatus,
|
||||
WorktreeSpec,
|
||||
};
|
||||
use maestro_protocol::{ActivityState, CreateSessionRequest, Session, SessionStatus, WorktreeSpec};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::cli::{KillArgs, ListArgs, SpawnArgs};
|
||||
use crate::http::{HttpError, RegistryClient};
|
||||
use crate::http::{DaemonClient, HttpError};
|
||||
|
||||
/// The bits of `GET /api/info` the CLI renders. Deserialized structurally so
|
||||
/// the CLI doesn't depend on the daemon crate.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct DaemonInfo {
|
||||
#[serde(default)]
|
||||
pub hostname: String,
|
||||
#[serde(default)]
|
||||
pub home_dir: String,
|
||||
}
|
||||
|
||||
/// `maestro list`
|
||||
pub async fn run_list(
|
||||
client: &RegistryClient,
|
||||
client: &DaemonClient,
|
||||
args: &ListArgs,
|
||||
out: &mut impl Write,
|
||||
) -> Result<(), HttpError> {
|
||||
let mut sessions: Vec<Session> = client.get_json("/api/sessions").await?;
|
||||
let daemons: Vec<DaemonInfo> = client
|
||||
.get_json::<Vec<DaemonInfo>>("/api/daemons")
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if let Some(d) = &args.daemon {
|
||||
sessions.retain(|s| &s.daemon_id == d);
|
||||
}
|
||||
let sessions: Vec<Session> = client.get_json("/api/sessions").await?;
|
||||
let info: DaemonInfo = client.get_json("/api/info").await.unwrap_or_default();
|
||||
|
||||
if args.json {
|
||||
let body = serde_json::json!({ "sessions": sessions, "daemons": daemons });
|
||||
let body = serde_json::json!({ "sessions": sessions });
|
||||
writeln!(out, "{}", serde_json::to_string_pretty(&body)?).ok();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
print_session_table(out, &sessions, &daemons);
|
||||
print_session_table(out, &sessions, &info);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `maestro spawn`
|
||||
pub async fn run_spawn(client: &RegistryClient, args: SpawnArgs) -> Result<Session, HttpError> {
|
||||
pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session, HttpError> {
|
||||
let mut env: HashMap<String, String> = HashMap::new();
|
||||
for (k, v) in args.env.iter() {
|
||||
env.insert(k.clone(), v.clone());
|
||||
@@ -57,7 +58,6 @@ pub async fn run_spawn(client: &RegistryClient, args: SpawnArgs) -> Result<Sessi
|
||||
};
|
||||
|
||||
let body = CreateSessionRequest {
|
||||
daemon_id: args.daemon.clone(),
|
||||
cwd: args.cwd.clone(),
|
||||
command: args.command.clone(),
|
||||
env,
|
||||
@@ -69,88 +69,55 @@ pub async fn run_spawn(client: &RegistryClient, args: SpawnArgs) -> Result<Sessi
|
||||
}
|
||||
|
||||
/// `maestro kill`
|
||||
pub async fn run_kill(client: &RegistryClient, args: &KillArgs) -> Result<(), HttpError> {
|
||||
pub async fn run_kill(client: &DaemonClient, args: &KillArgs) -> Result<(), HttpError> {
|
||||
client
|
||||
.delete(&format!("/api/sessions/{}", args.session_id))
|
||||
.await
|
||||
}
|
||||
|
||||
/// `GET /api/sessions/:id/connect-info`
|
||||
pub async fn fetch_connect_info(
|
||||
client: &RegistryClient,
|
||||
session_id: Uuid,
|
||||
) -> Result<ConnectInfo, HttpError> {
|
||||
client
|
||||
.get_json(&format!("/api/sessions/{session_id}/connect-info"))
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::write_literal)]
|
||||
fn print_session_table(out: &mut impl Write, sessions: &[Session], daemons: &[DaemonInfo]) {
|
||||
fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &DaemonInfo) {
|
||||
if sessions.is_empty() {
|
||||
if daemons.is_empty() {
|
||||
writeln!(out, "no sessions, no daemons connected").ok();
|
||||
} else {
|
||||
writeln!(
|
||||
out,
|
||||
"no sessions ({} daemon{} connected)",
|
||||
daemons.len(),
|
||||
if daemons.len() == 1 { "" } else { "s" }
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
writeln!(out, "no sessions").ok();
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by daemon, stable order.
|
||||
let mut by_daemon: Vec<(String, Vec<&Session>)> = Vec::new();
|
||||
for s in sessions {
|
||||
if let Some(slot) = by_daemon.iter_mut().find(|(d, _)| d == &s.daemon_id) {
|
||||
slot.1.push(s);
|
||||
} else {
|
||||
by_daemon.push((s.daemon_id.clone(), vec![s]));
|
||||
}
|
||||
}
|
||||
for (_, group) in &mut by_daemon {
|
||||
group.sort_by_key(|s| s.created_at);
|
||||
}
|
||||
let mut sorted: Vec<&Session> = sessions.iter().collect();
|
||||
sorted.sort_by_key(|s| s.created_at);
|
||||
|
||||
for (daemon_id, group) in &by_daemon {
|
||||
let daemon = daemons.iter().find(|d| &d.daemon_id == daemon_id);
|
||||
let hostname = daemon.map(|d| d.hostname.as_str()).unwrap_or("?");
|
||||
let home = daemon.map(|d| d.home_dir.as_str()).unwrap_or("");
|
||||
writeln!(out, "\n{daemon_id} ({hostname})").ok();
|
||||
if !info.hostname.is_empty() {
|
||||
writeln!(out, "\n{}", info.hostname).ok();
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
" {:<36} {:<8} {:<9} {:<28} {}",
|
||||
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND"
|
||||
)
|
||||
.ok();
|
||||
for s in sorted {
|
||||
// The pane's live cwd if observed, else the spawn dir, home-abbreviated.
|
||||
let dir = abbreviate_dir(s.current_cwd.as_deref().unwrap_or(&s.cwd), &info.home_dir);
|
||||
let status = match s.status {
|
||||
SessionStatus::Running => "running",
|
||||
SessionStatus::Dead => "dead",
|
||||
};
|
||||
let activity = match s.activity {
|
||||
ActivityState::Active => "active",
|
||||
ActivityState::Idle => "idle",
|
||||
ActivityState::AwaitingInput => "awaiting",
|
||||
};
|
||||
// Show the live foreground command alongside the spawn command when
|
||||
// the daemon has observed one that differs (NAP-340).
|
||||
let command = match &s.foreground {
|
||||
Some(fg) if fg != &s.command => format!("{} [{}]", s.command, fg),
|
||||
_ => s.command.clone(),
|
||||
};
|
||||
writeln!(
|
||||
out,
|
||||
" {:<36} {:<8} {:<9} {:<28} {}",
|
||||
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND"
|
||||
" {:<36} {:<8} {:<9} {:<28} {command}",
|
||||
s.id, status, activity, dir
|
||||
)
|
||||
.ok();
|
||||
for s in group {
|
||||
// The pane's live cwd if observed, else the spawn dir, home-abbreviated.
|
||||
let dir = abbreviate_dir(s.current_cwd.as_deref().unwrap_or(&s.cwd), home);
|
||||
let status = match s.status {
|
||||
SessionStatus::Running => "running",
|
||||
SessionStatus::Dead => "dead",
|
||||
};
|
||||
let activity = match s.activity {
|
||||
ActivityState::Active => "active",
|
||||
ActivityState::Idle => "idle",
|
||||
ActivityState::AwaitingInput => "awaiting",
|
||||
};
|
||||
// Show the live foreground command alongside the spawn command when
|
||||
// the daemon has observed one that differs (NAP-340).
|
||||
let command = match &s.foreground {
|
||||
Some(fg) if fg != &s.command => format!("{} [{}]", s.command, fg),
|
||||
_ => s.command.clone(),
|
||||
};
|
||||
writeln!(
|
||||
out,
|
||||
" {:<36} {:<8} {:<9} {:<28} {command}",
|
||||
s.id, status, activity, dir
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,13 +142,14 @@ mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use maestro_protocol::{ActivityState, DaemonInfo, Session, SessionStatus};
|
||||
use maestro_protocol::{ActivityState, Session, SessionStatus};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn session(daemon: &str, cwd: &str, command: &str, id: u128) -> Session {
|
||||
use super::DaemonInfo;
|
||||
|
||||
fn session(cwd: &str, command: &str, id: u128) -> Session {
|
||||
Session {
|
||||
id: Uuid::from_u128(id),
|
||||
daemon_id: daemon.into(),
|
||||
cwd: PathBuf::from(cwd),
|
||||
command: command.into(),
|
||||
env: HashMap::new(),
|
||||
@@ -195,37 +163,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn info(home: &str) -> DaemonInfo {
|
||||
DaemonInfo {
|
||||
hostname: "naps-laptop".into(),
|
||||
home_dir: home.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_grouped_table() {
|
||||
fn formats_table() {
|
||||
let sessions = vec![
|
||||
session("laptop", "/home/naps/maestro", "claude", 1),
|
||||
session("vm1", "/srv/work/api", "bash", 2),
|
||||
session("laptop", "/home/naps/scratch", "nvim", 3),
|
||||
];
|
||||
let daemons = vec![
|
||||
DaemonInfo {
|
||||
daemon_id: "laptop".into(),
|
||||
hostname: "naps-laptop".into(),
|
||||
version: "0.1.0".into(),
|
||||
daemon_url: "ws://10.0.0.1:7070".into(),
|
||||
home_dir: "/home/naps".into(),
|
||||
},
|
||||
DaemonInfo {
|
||||
daemon_id: "vm1".into(),
|
||||
hostname: "vm1.lan".into(),
|
||||
version: "0.1.0".into(),
|
||||
daemon_url: "ws://10.0.0.2:7070".into(),
|
||||
home_dir: String::new(),
|
||||
},
|
||||
session("/home/naps/maestro", "claude", 1),
|
||||
session("/srv/work/api", "bash", 2),
|
||||
];
|
||||
let mut buf = Vec::new();
|
||||
super::print_session_table(&mut buf, &sessions, &daemons);
|
||||
super::print_session_table(&mut buf, &sessions, &info("/home/naps"));
|
||||
let s = String::from_utf8(buf).unwrap();
|
||||
assert!(s.contains("laptop (naps-laptop)"), "{s}");
|
||||
assert!(s.contains("vm1 (vm1.lan)"), "{s}");
|
||||
assert!(s.contains("naps-laptop"), "{s}");
|
||||
// Home dir collapses to ~ in the DIR column.
|
||||
assert!(s.contains("~/maestro"), "{s}");
|
||||
// No home dir → path shown verbatim.
|
||||
// Outside the home dir → path shown verbatim.
|
||||
assert!(s.contains("/srv/work/api"), "{s}");
|
||||
assert!(s.contains("running"), "{s}");
|
||||
assert!(s.contains("ACTIVITY"), "{s}");
|
||||
@@ -235,35 +192,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn live_cwd_overrides_spawn_dir_in_table() {
|
||||
let mut s = session("laptop", "/home/naps/maestro", "bash", 1);
|
||||
let mut s = session("/home/naps/maestro", "bash", 1);
|
||||
s.current_cwd = Some(PathBuf::from("/home/naps/maestro/crates/maestro-cli"));
|
||||
let daemons = vec![DaemonInfo {
|
||||
daemon_id: "laptop".into(),
|
||||
hostname: "h".into(),
|
||||
version: "0.1.0".into(),
|
||||
daemon_url: "ws://x".into(),
|
||||
home_dir: "/home/naps".into(),
|
||||
}];
|
||||
let mut buf = Vec::new();
|
||||
super::print_session_table(&mut buf, &[s], &daemons);
|
||||
super::print_session_table(&mut buf, &[s], &info("/home/naps"));
|
||||
let out = String::from_utf8(buf).unwrap();
|
||||
assert!(out.contains("~/maestro/crates/maestro-cli"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shows_activity_and_foreground() {
|
||||
let mut s1 = session("laptop", "/tmp", "bash", 1);
|
||||
let mut s1 = session("/tmp", "bash", 1);
|
||||
s1.activity = ActivityState::AwaitingInput;
|
||||
s1.foreground = Some("claude".into()); // differs from spawn command
|
||||
let daemons = vec![DaemonInfo {
|
||||
daemon_id: "laptop".into(),
|
||||
hostname: "h".into(),
|
||||
version: "0.1.0".into(),
|
||||
daemon_url: "ws://x".into(),
|
||||
home_dir: String::new(),
|
||||
}];
|
||||
let mut buf = Vec::new();
|
||||
super::print_session_table(&mut buf, &[s1], &daemons);
|
||||
super::print_session_table(&mut buf, &[s1], &info(""));
|
||||
let out = String::from_utf8(buf).unwrap();
|
||||
assert!(out.contains("awaiting"), "{out}");
|
||||
// Live foreground surfaces next to the spawn command.
|
||||
@@ -271,27 +214,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_sessions_with_no_daemons() {
|
||||
fn empty_sessions() {
|
||||
let mut buf = Vec::new();
|
||||
super::print_session_table(&mut buf, &[], &[]);
|
||||
assert_eq!(
|
||||
String::from_utf8(buf).unwrap(),
|
||||
"no sessions, no daemons connected\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_sessions_with_daemons() {
|
||||
let daemons = vec![DaemonInfo {
|
||||
daemon_id: "laptop".into(),
|
||||
hostname: "naps".into(),
|
||||
version: "0.1.0".into(),
|
||||
daemon_url: "ws://x".into(),
|
||||
home_dir: String::new(),
|
||||
}];
|
||||
let mut buf = Vec::new();
|
||||
super::print_session_table(&mut buf, &[], &daemons);
|
||||
let s = String::from_utf8(buf).unwrap();
|
||||
assert!(s.contains("1 daemon connected"), "{s}");
|
||||
super::print_session_table(&mut buf, &[], &info(""));
|
||||
assert_eq!(String::from_utf8(buf).unwrap(), "no sessions\n");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user