diff options
| -rw-r--r-- | docs/next/website/src/content/docs/cli-reference.mdx | 1 | ||||
| -rw-r--r-- | docs/next/website/src/content/docs/connecting-machines.mdx | 8 | ||||
| -rw-r--r-- | src/cli/machine.rs | 97 | ||||
| -rw-r--r-- | src/cli/spec/machine.rs | 6 | ||||
| -rw-r--r-- | src/client/endpoint/catalog.rs | 2 |
5 files changed, 100 insertions, 14 deletions
diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index 52c39f40..f9aff8fe 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -51,6 +51,7 @@ with the installed binary. Use `--json` for the full JSON Schema document, or ```bash herdr machine list +herdr machine add workbox herdr machine add workbox --label "Build machine" herdr machine rename <profile-id> --label "New name" herdr machine disable <profile-id> diff --git a/docs/next/website/src/content/docs/connecting-machines.mdx b/docs/next/website/src/content/docs/connecting-machines.mdx index 9be796f3..2ce18b06 100644 --- a/docs/next/website/src/content/docs/connecting-machines.mdx +++ b/docs/next/website/src/content/docs/connecting-machines.mdx @@ -32,17 +32,19 @@ Multi-machine connections support Linux, macOS, and Windows clients connecting t Run setup in an interactive terminal so Herdr can ask before installing or replacing anything: ```bash -herdr machine add workbox --label "Build machine" +herdr machine add workbox ``` -This uses the remote default session. A machine profile targets one remote session; it does not combine every session on the host. +The sidebar shows the machine as `workbox`, the SSH host without any `user@` prefix. Add `--label "Build machine"` to choose a different name. This uses the remote default session. A machine profile targets one remote session; it does not combine every session on the host. To use a named session instead, add the optional `--remote-session` flag: ```bash -herdr machine add workbox --label "Build machine" --remote-session agents +herdr machine add workbox --remote-session agents ``` +Without `--label`, this machine shows as `workbox/agents`. If the default name is already taken, `machine add` asks you to pass `--label`. + Herdr checks both the installed binary and the running server. It starts the requested background server before saving the profile. Compatible client and server versions do not have to match. Missing or incompatible installations go through an approval-based setup. When the running server needs replacement, setup asks before stopping it and its pane processes, then starts the compatible server. The default answer is No. If installation and replacement are both needed, one confirmation covers them. `machine add` does not use experimental live handoff. Cancelling or failing setup leaves the profile unsaved. Run `herdr` to open the UI. If a local client is already open, added and enabled machines normally appear within a second and connect in the background without changing your selection. An in-progress machine switch finishes before profile changes are applied. The remote server keeps running after setup exits. diff --git a/src/cli/machine.rs b/src/cli/machine.rs index 9328347e..52828f75 100644 --- a/src/cli/machine.rs +++ b/src/cli/machine.rs @@ -1,12 +1,12 @@ use serde::Serialize; -use crate::client::endpoint::{EndpointCatalog, ProfileId}; +use crate::client::endpoint::{EndpointCatalog, ProfileId, MAX_LABEL_BYTES}; const HELP: &str = "Usage: herdr machine list [--json] herdr machine status [<label-or-id>] [--json] herdr machine reconnect <label-or-id> - herdr machine add <ssh-target> --label <label> [--remote-session <name>] + 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> @@ -205,7 +205,7 @@ fn reconnect(args: &[String]) -> std::io::Result<i32> { #[derive(Debug, PartialEq, Eq)] struct AddArgs { target: String, - label: String, + label: Option<String>, session: String, } @@ -246,9 +246,9 @@ fn parse_add_args(args: &[String]) -> Result<AddArgs, String> { } } let target = target.ok_or_else(|| { - "usage: herdr machine add <ssh-target> --label <label> [--remote-session <name>]".to_owned() + "usage: herdr machine add <ssh-target> [--label <label>] [--remote-session <name>]" + .to_owned() })?; - let label = label.ok_or_else(|| "--label is required".to_owned())?; let session = session.unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_owned()); Ok(AddArgs { target, @@ -257,6 +257,41 @@ fn parse_add_args(args: &[String]) -> Result<AddArgs, String> { }) } +/// Names the machine after the SSH host, plus the session when it is not the default. +fn default_label(target: &str, session: &str) -> String { + let url = target + .strip_prefix("ssh://") + .map(|authority| authority.trim_end_matches('/')); + let authority = url.unwrap_or(target); + let host = authority + .rsplit_once('@') + .map_or(authority, |(_, host)| host); + let host = match (url, host.strip_prefix('[')) { + (Some(_), Some(bracketed)) => bracketed.split_once(']').map_or(host, |(ip, _)| ip), + (Some(_), None) => host.split_once(':').map_or(host, |(host, _)| host), + (None, _) => host, + }; + if session == crate::session::DEFAULT_SESSION_NAME { + host.to_owned() + } else { + format!("{host}/{session}") + } +} + +fn check_default_label(catalog: &EndpointCatalog, label: &str) -> Result<(), String> { + if label.len() > MAX_LABEL_BYTES { + return Err(format!( + "default machine name '{label}' is longer than {MAX_LABEL_BYTES} bytes; pass --label to choose a name" + )); + } + if catalog.ssh.iter().any(|profile| profile.label == label) { + return Err(format!( + "a machine named '{label}' already exists; pass --label to choose another name" + )); + } + Ok(()) +} + fn add(args: &[String]) -> std::io::Result<i32> { let AddArgs { target, @@ -269,7 +304,15 @@ fn add(args: &[String]) -> std::io::Result<i32> { return Ok(2); } }; + let label_is_default = label.is_none(); + let label = label.unwrap_or_else(|| default_label(&target, &session)); let mut catalog = load_catalog()?; + if label_is_default { + if let Err(error) = check_default_label(&catalog, &label) { + eprintln!("error: {error}"); + return Ok(2); + } + } match catalog.add_ssh(label.clone(), &target, session.clone()) { Ok(_) => {} Err(error) => { @@ -291,6 +334,12 @@ fn add(args: &[String]) -> std::io::Result<i32> { "remote prepared, but machine was not saved: {error}" )) })?; + if label_is_default { + if let Err(error) = check_default_label(&catalog, &label) { + eprintln!("error: {error}; machine was not saved"); + return Ok(2); + } + } let id = match catalog.add_ssh(label, &target, &session) { Ok(id) => id, Err(error) => { @@ -457,7 +506,7 @@ mod tests { parse_add_args(&args).unwrap(), AddArgs { target: "workstation.coder".into(), - label: "coder".into(), + label: Some("coder".into()), session: session.into(), }, "{args:?}" @@ -466,11 +515,45 @@ mod tests { } #[test] + fn add_parser_leaves_label_unset_without_flag() { + let parsed = parse_add_args(&["workstation.coder".to_owned()]).unwrap(); + assert_eq!(parsed.label, None); + assert_eq!(parsed.session, "default"); + } + + #[test] + fn default_label_uses_ssh_host_and_non_default_session() { + for (target, session, label) in [ + ("workbox", "default", "workbox"), + ("dev@workbox", "default", "workbox"), + ("workbox", "agents", "workbox/agents"), + ("ssh://workbox", "default", "workbox"), + ("ssh://dev@workbox:2222", "default", "workbox"), + ("ssh://dev@[::1]:2222", "agents", "::1/agents"), + ("ssh://dev@workbox/", "default", "workbox"), + ("ssh://dev@workbox:2222/", "agents", "workbox/agents"), + ] { + assert_eq!(default_label(target, session), label, "{target} {session}"); + } + } + + #[test] + fn default_label_must_be_unique_and_fit() { + let mut catalog = EndpointCatalog::default(); + catalog.add_ssh("workbox", "workbox", "default").unwrap(); + + assert!(check_default_label(&catalog, "workbox/agents").is_ok()); + let duplicate = check_default_label(&catalog, "workbox").unwrap_err(); + assert!(duplicate.contains("--label"), "{duplicate}"); + let long = check_default_label(&catalog, &"h".repeat(MAX_LABEL_BYTES + 1)).unwrap_err(); + assert!(long.contains("--label"), "{long}"); + } + + #[test] fn add_parser_rejects_incomplete_duplicate_and_extra_arguments() { for args in [ vec![], vec!["--label", "coder"], - vec!["workstation.coder"], vec!["workstation.coder", "--label"], vec!["workstation.coder", "--label", "coder", "--remote-session"], vec!["--label", "coder", "--label", "other", "workstation.coder"], diff --git a/src/cli/spec/machine.rs b/src/cli/spec/machine.rs index 82bc4c5d..19a34088 100644 --- a/src/cli/spec/machine.rs +++ b/src/cli/spec/machine.rs @@ -30,9 +30,9 @@ pub(super) fn command() -> Command { .required(true), ) .arg( - option("label", "LABEL") - .required(true) - .help("Set the machine label shown in the sidebar"), + option("label", "LABEL").help( + "Set the machine label shown in the sidebar (defaults to the SSH host, or host/session)", + ), ) .arg( option("remote-session", "NAME") diff --git a/src/client/endpoint/catalog.rs b/src/client/endpoint/catalog.rs index e65d1e9e..b1812ce7 100644 --- a/src/client/endpoint/catalog.rs +++ b/src/client/endpoint/catalog.rs @@ -11,7 +11,7 @@ const CATALOG_VERSION: u32 = 1; const SELECTION_VERSION: u32 = 1; const MAX_CATALOG_BYTES: u64 = 64 * 1024; const MAX_PROFILES: usize = 64; -const MAX_LABEL_BYTES: usize = 128; +pub(crate) const MAX_LABEL_BYTES: usize = 128; const MAX_TARGET_BYTES: usize = 1024; static NEXT_TEMP_FILE: AtomicU64 = AtomicU64::new(1); |
