diff options
Diffstat (limited to 'src/cli')
| -rw-r--r-- | src/cli/machine.rs | 295 | ||||
| -rw-r--r-- | src/cli/spec.rs | 19 | ||||
| -rw-r--r-- | src/cli/spec/completion.rs | 14 | ||||
| -rw-r--r-- | src/cli/spec/machine.rs | 49 | ||||
| -rw-r--r-- | src/cli/status.rs | 12 |
5 files changed, 375 insertions, 14 deletions
diff --git a/src/cli/machine.rs b/src/cli/machine.rs new file mode 100644 index 00000000..a55e4611 --- /dev/null +++ b/src/cli/machine.rs @@ -0,0 +1,295 @@ +use serde::Serialize; + +use crate::client::endpoint::{EndpointCatalog, ProfileId}; + +const HELP: &str = "Usage: + herdr machine list [--json] + herdr machine add <ssh-target> --label <label> [--remote-session <name>] + herdr machine rename <profile-id> --label <label> + herdr machine remove <profile-id> + herdr machine enable <profile-id> + herdr machine disable <profile-id> + +Add prepares the remote Herdr installation and starts its server before saving. +Missing or incompatible installations require approval in an interactive terminal. +Changes apply automatically to open local Herdr clients. +Removing or disabling a machine leaves its remote sessions running. +Saved machines contain only a label, SSH target, explicit Herdr session, and enabled state. +SSH credentials and key material remain owned by OpenSSH."; + +#[derive(Serialize)] +struct MachineListRow<'a> { + id: &'a str, + label: &'a str, + target: &'a str, + session: &'a str, + enabled: bool, + selected: bool, +} + +pub(super) fn run_machine_command(args: &[String]) -> std::io::Result<i32> { + match args.first().map(String::as_str) { + Some("list") => list(&args[1..]), + Some("add") => add(&args[1..]), + Some("rename") => rename(&args[1..]), + Some("remove") => remove(&args[1..]), + Some("enable") => set_enabled(&args[1..], true), + Some("disable") => set_enabled(&args[1..], false), + Some("help" | "--help" | "-h") => { + println!("{HELP}"); + Ok(0) + } + _ => { + eprintln!("{HELP}"); + Ok(2) + } + } +} + +fn list(args: &[String]) -> std::io::Result<i32> { + let json = match args { + [] => false, + [flag] if flag == "--json" => true, + _ => { + eprintln!("usage: herdr machine list [--json]"); + return Ok(2); + } + }; + let catalog = load_catalog()?; + let rows = catalog + .ssh + .iter() + .map(|profile| MachineListRow { + id: profile.id.as_str(), + label: &profile.label, + target: &profile.target, + session: &profile.session, + enabled: profile.enabled, + selected: catalog.selected_profile.as_ref() == Some(&profile.id), + }) + .collect::<Vec<_>>(); + if json { + println!( + "{}", + serde_json::to_string_pretty(&rows).map_err(std::io::Error::other)? + ); + return Ok(0); + } + if rows.is_empty() { + println!("No saved SSH machines."); + return Ok(0); + } + for row in rows { + let state = if row.enabled { "enabled" } else { "disabled" }; + println!( + "{}\t{}\t{}\t{}\t{}", + row.id, row.label, row.target, row.session, state + ); + } + Ok(0) +} + +fn add(args: &[String]) -> std::io::Result<i32> { + let args = super::expand_equals_args(args, &["--label", "--remote-session"]); + let Some(target) = args.first().filter(|value| !value.starts_with('-')) else { + eprintln!( + "usage: herdr machine add <ssh-target> --label <label> [--remote-session <name>]" + ); + return Ok(2); + }; + let mut label = None; + let mut session = None; + let mut index = 1; + while index < args.len() { + let (name, value) = match args[index].as_str() { + "--label" | "--remote-session" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for {}", args[index]); + return Ok(2); + }; + index += 2; + (args[index - 2].as_str(), value.clone()) + } + unknown => { + eprintln!("unknown machine add option: {unknown}"); + return Ok(2); + } + }; + match name { + "--label" if label.is_none() => label = Some(value), + "--remote-session" if session.is_none() => session = Some(value), + "--remote-session" => { + eprintln!("--remote-session can only be specified once"); + return Ok(2); + } + "--label" => { + eprintln!("--label can only be specified once"); + return Ok(2); + } + _ => unreachable!("validated machine add option"), + } + } + let Some(label) = label else { + eprintln!("--label is required"); + return Ok(2); + }; + let session = session.unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_owned()); + let mut catalog = load_catalog()?; + match catalog.add_ssh(label.clone(), target, session.clone()) { + Ok(_) => {} + Err(error) => { + eprintln!("error: {error}"); + return Ok(2); + } + } + if let Err(error) = crate::remote::prepare_saved_ssh(target, &session) { + eprintln!("error: {error}; machine was not saved"); + crate::remote::print_remote_error_hint(&error, target); + return Ok(1); + } + // Setup can wait for human approval. Do not overwrite catalog edits made meanwhile. + let mut catalog = load_catalog().map_err(|error| { + std::io::Error::other(format!( + "remote prepared, but machine was not saved: {error}" + )) + })?; + let id = match catalog.add_ssh(label, target, session) { + Ok(id) => id, + Err(error) => { + eprintln!("error: {error}"); + return Ok(2); + } + }; + store_catalog(&catalog).map_err(|error| { + std::io::Error::other(format!( + "remote prepared, but machine was not saved: {error}" + )) + })?; + println!("Saved SSH machine {id}. Remote server is ready."); + println!("Open Herdr clients connect automatically."); + Ok(0) +} + +fn rename(args: &[String]) -> std::io::Result<i32> { + let args = super::expand_equals_args(args, &["--label"]); + let [raw_id, flag, label] = args.as_slice() else { + eprintln!("usage: herdr machine rename <profile-id> --label <label>"); + return Ok(2); + }; + if flag != "--label" { + eprintln!("usage: herdr machine rename <profile-id> --label <label>"); + return Ok(2); + } + let id = match ProfileId::parse(raw_id.clone()) { + Ok(id) => id, + Err(error) => { + eprintln!("error: {error}"); + return Ok(2); + } + }; + let mut catalog = load_catalog()?; + match catalog.rename_ssh(&id, label) { + Ok(true) => {} + Ok(false) => { + eprintln!("machine profile {id} was not found"); + return Ok(1); + } + Err(error) => { + eprintln!("error: {error}"); + return Ok(2); + } + } + store_catalog(&catalog)?; + println!("Renamed SSH machine {id}."); + Ok(0) +} + +fn remove(args: &[String]) -> std::io::Result<i32> { + let Some(id) = one_profile_id(args, "usage: herdr machine remove <profile-id>")? else { + return Ok(2); + }; + let mut catalog = load_catalog()?; + let previous_selection = catalog.selected_profile.clone(); + if !catalog.remove_ssh(&id) { + eprintln!("machine profile {id} was not found"); + return Ok(1); + } + store_catalog(&catalog)?; + if catalog.selected_profile != previous_selection { + catalog.store_selection().map_err(std::io::Error::other)?; + } + println!("Removed SSH machine {id}."); + Ok(0) +} + +fn set_enabled(args: &[String], enabled: bool) -> std::io::Result<i32> { + let action = if enabled { "enable" } else { "disable" }; + let usage = format!("usage: herdr machine {action} <profile-id>"); + let Some(id) = one_profile_id(args, &usage)? else { + return Ok(2); + }; + let mut catalog = load_catalog()?; + let previous_selection = catalog.selected_profile.clone(); + if !catalog.set_enabled(&id, enabled) { + eprintln!("machine profile {id} was not found"); + return Ok(1); + } + store_catalog(&catalog)?; + if catalog.selected_profile != previous_selection { + catalog.store_selection().map_err(std::io::Error::other)?; + } + println!( + "{} SSH machine {id}.", + if enabled { "Enabled" } else { "Disabled" } + ); + Ok(0) +} + +fn one_profile_id(args: &[String], usage: &str) -> std::io::Result<Option<ProfileId>> { + let [raw] = args else { + eprintln!("{usage}"); + return Ok(None); + }; + match ProfileId::parse(raw.clone()) { + Ok(id) => Ok(Some(id)), + Err(error) => { + eprintln!("error: {error}"); + Ok(None) + } + } +} + +fn load_catalog() -> std::io::Result<EndpointCatalog> { + EndpointCatalog::load().map_err(std::io::Error::other) +} + +fn store_catalog(catalog: &EndpointCatalog) -> std::io::Result<()> { + catalog.store_profiles().map_err(std::io::Error::other) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_id_parser_rejects_target_text() { + assert!(one_profile_id(&["build.example".into()], "usage") + .unwrap() + .is_none()); + } + + #[test] + fn list_rows_do_not_have_credential_fields() { + let encoded = serde_json::to_string(&MachineListRow { + id: "0123456789abcdef0123456789abcdef", + label: "Build", + target: "dev@build", + session: "agents", + enabled: true, + selected: false, + }) + .unwrap(); + assert!(!encoded.contains("password")); + assert!(!encoded.contains("key")); + } +} diff --git a/src/cli/spec.rs b/src/cli/spec.rs index cf23e320..2da0a25c 100644 --- a/src/cli/spec.rs +++ b/src/cli/spec.rs @@ -2,6 +2,9 @@ use std::io::Write; use clap::{Arg, ArgAction, ArgGroup, Command, ValueHint}; +mod completion; +mod machine; + pub(super) fn command() -> Command { let command = Command::new("herdr") .about("terminal workspace manager for AI coding agents") @@ -25,11 +28,12 @@ pub(super) fn command() -> Command { .action(ArgAction::SetTrue) .help("Print version and exit"), ) - .subcommand(completion_command()) + .subcommand(completion::command()) .subcommand(update_command()) .subcommand(status_command()) .subcommand(config_command()) .subcommand(channel_command()) + .subcommand(machine::command()) .subcommand(server_command()) .subcommand(api_command()) .subcommand(workspace_command()) @@ -108,19 +112,6 @@ fn write_requested_help( Ok(true) } -fn completion_command() -> Command { - Command::new("completion") - .visible_alias("completions") - .about("Generate shell completion scripts") - .arg( - Arg::new("shell") - .value_name("SHELL") - .required(true) - .value_parser(super::completion::SUPPORTED_SHELLS) - .help("Shell to generate completions for"), - ) -} - fn update_command() -> Command { Command::new("update") .about("Download and install the latest version") diff --git a/src/cli/spec/completion.rs b/src/cli/spec/completion.rs new file mode 100644 index 00000000..8cc27d7b --- /dev/null +++ b/src/cli/spec/completion.rs @@ -0,0 +1,14 @@ +use clap::{Arg, Command}; + +pub(super) fn command() -> Command { + Command::new("completion") + .visible_alias("completions") + .about("Generate shell completion scripts") + .arg( + Arg::new("shell") + .value_name("SHELL") + .required(true) + .value_parser(super::super::completion::SUPPORTED_SHELLS) + .help("Shell to generate completions for"), + ) +} diff --git a/src/cli/spec/machine.rs b/src/cli/spec/machine.rs new file mode 100644 index 00000000..188e27b9 --- /dev/null +++ b/src/cli/spec/machine.rs @@ -0,0 +1,49 @@ +use clap::{Arg, Command}; + +use super::{json_flag, option}; + +pub(super) fn command() -> Command { + Command::new("machine") + .about("Manage saved SSH machines") + .subcommand( + Command::new("list") + .about("List saved SSH machines") + .arg(json_flag()), + ) + .subcommand( + Command::new("add") + .about("Prepare the remote Herdr server and save an SSH machine") + .arg( + Arg::new("ssh-target") + .value_name("SSH_TARGET") + .required(true), + ) + .arg( + option("label", "LABEL") + .required(true) + .help("Set the machine label shown in the sidebar"), + ) + .arg( + option("remote-session", "NAME") + .help("Set the explicit Herdr session on the remote machine"), + ), + ) + .subcommand( + profile_command("rename", "Rename a saved SSH machine").arg( + option("label", "LABEL") + .required(true) + .help("Set the machine label shown in the sidebar"), + ), + ) + .subcommand(profile_command("remove", "Remove a saved SSH machine")) + .subcommand(profile_command("enable", "Enable a saved SSH machine")) + .subcommand(profile_command("disable", "Disable a saved SSH machine")) +} + +fn profile_command(name: &'static str, about: &'static str) -> Command { + Command::new(name).about(about).arg( + Arg::new("profile-id") + .value_name("PROFILE_ID") + .required(true), + ) +} diff --git a/src/cli/status.rs b/src/cli/status.rs index 4dba81b7..c6dda462 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -255,6 +255,7 @@ struct ClientStatusJson { channel: &'static str, protocol: u32, endpoint_protocol_generation: u32, + endpoint_capabilities: Vec<&'static str>, binary: String, session: Option<String>, } @@ -279,6 +280,8 @@ struct ServerCapabilitiesJson { live_handoff: bool, detached_server_daemon: bool, endpoint_protocol_generation: Option<u32>, + surface_interest: bool, + health_check: bool, } #[derive(Serialize)] @@ -293,6 +296,11 @@ fn client_status_json() -> ClientStatusJson { channel: crate::config::Config::load().config.update.channel.as_str(), protocol: crate::protocol::PROTOCOL_VERSION, endpoint_protocol_generation: crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION, + endpoint_capabilities: vec![ + crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY, + crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY, + crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY, + ], binary: current_exe_label(), session: crate::session::active_name(), } @@ -315,6 +323,8 @@ fn server_status_json(server: &ServerRuntimeStatus) -> ServerStatusJson { live_handoff: capabilities.live_handoff, detached_server_daemon: capabilities.detached_server_daemon, endpoint_protocol_generation: capabilities.endpoint_protocol_generation, + surface_interest: capabilities.surface_interest, + health_check: capabilities.health_check, }), compatible: protocol.map(|value| value == crate::protocol::PROTOCOL_VERSION), endpoint_compatible: capabilities.as_ref().and_then(|capabilities| { @@ -404,6 +414,8 @@ mod tests { live_handoff: true, detached_server_daemon: true, endpoint_protocol_generation: endpoint_generation, + surface_interest: true, + health_check: true, }), } } |
