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:
Miguel Palhas
2026-08-22 10:07:12 +01:00
parent d006f4a7bd
commit 6790f41652
16 changed files with 287 additions and 1164 deletions
+19 -95
View File
@@ -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 -30
View File
@@ -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",
+27 -149
View File
@@ -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()
}
);
}
}
+35 -201
View File
@@ -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(&registry, &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(&registry, 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(&registry, &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(&registry, 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)))
}
+13 -64
View File
@@ -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 -4
View File
@@ -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)]
+78 -153
View File
@@ -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");
}
}