aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDavid Heinemeier Hansson <david@hey.com>2026-09-27 19:06:33 +0200
committerGitHub <noreply@github.com>2026-09-27 20:06:33 +0300
commit316d176768e99bd80b81696c4e1f310f0643e744 (patch)
treed723283bc10f95d47dfc148136dbdd93d9d6c677
parentfff6c820aa45f4eabb9b2e0456326dc74cca5a25 (diff)
feat: default machine add label to the ssh host (#4678)
* feat: default machine add label to the ssh host `herdr machine add <ssh-target>` no longer requires `--label`. Without one, the machine is named after the SSH host with any `user@` prefix removed, so `herdr machine add dev@workbox` shows up as `workbox`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Apply suggestion from @greptile-apps[bot] Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix: make default machine labels unique per session and handle ssh urls --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
-rw-r--r--docs/next/website/src/content/docs/cli-reference.mdx1
-rw-r--r--docs/next/website/src/content/docs/connecting-machines.mdx8
-rw-r--r--src/cli/machine.rs97
-rw-r--r--src/cli/spec/machine.rs6
-rw-r--r--src/client/endpoint/catalog.rs2
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);