feat(cli): add spawn link command to reconnect existing deployments (#2675)

Adds `spawn link <ip>` command that re-registers an existing cloud VM
in spawn's local state, so commands like `spawn list`, `spawn delete`,
and `spawn fix` work on it without reprovisioning.

Features:
- Auto-detects running agent via SSH (ps aux + which checks)
- Auto-detects cloud provider via IMDS metadata endpoints (Hetzner,
  AWS, DigitalOcean, GCP)
- Accepts --agent, --cloud, --user, --name flags to skip auto-detection
- TCP connectivity pre-check before SSH attempts
- Creates a SpawnRecord in history with full connection info
- Offers to connect immediately after linking
- Interactive picker fallback when auto-detection fails
- Non-interactive mode support (exits with clear error if detection
  fails without --agent/--cloud flags)

Also adds --user / -u to KNOWN_FLAGS for the unknown-flag checker.

Fixes #2673

Agent: issue-fixer

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
A 2026-03-15 23:11:13 -07:00 committed by GitHub
parent 6ef20ed437
commit 5cc9930769
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 736 additions and 1 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.19.7",
"version": "0.20.0",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -0,0 +1,275 @@
/**
* cmd-link.test.ts Tests for the `spawn link` command.
*
* Uses DI (options.tcpCheck, options.sshCommand) to avoid real network calls.
* Follows the same pattern as cmd-fix.test.ts.
*/
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { asyncTryCatch } from "@openrouter/spawn-shared";
import { mockClackPrompts } from "./test-helpers";
// ── Clack prompts mock (must be at module top level) ───────────────────────
const clack = mockClackPrompts();
// ── Import module under test ───────────────────────────────────────────────
const { cmdLink } = await import("../commands/link.js");
// ── Helpers ────────────────────────────────────────────────────────────────
const TCP_REACHABLE = async () => true;
const TCP_UNREACHABLE = async () => false;
const SSH_NO_DETECT = () => null;
const SSH_DETECT_CLAUDE = (_host: string, _user: string, _keys: string[], cmd: string) => {
if (cmd.includes("ps aux")) {
return "claude";
}
return null;
};
// ── Test Setup ─────────────────────────────────────────────────────────────
describe("cmdLink", () => {
let testDir: string;
let savedSpawnHome: string | undefined;
let processExitSpy: ReturnType<typeof spyOn>;
beforeEach(() => {
testDir = join(process.env.HOME ?? "", `spawn-link-test-${Date.now()}`);
mkdirSync(testDir, {
recursive: true,
});
savedSpawnHome = process.env.SPAWN_HOME;
process.env.SPAWN_HOME = testDir;
clack.logError.mockReset();
clack.logSuccess.mockReset();
clack.logInfo.mockReset();
clack.logStep.mockReset();
clack.spinnerStart.mockReset();
clack.spinnerStop.mockReset();
clack.outro.mockReset();
processExitSpy = spyOn(process, "exit").mockImplementation((_code?: number): never => {
throw new Error(`process.exit(${_code})`);
});
});
afterEach(() => {
process.env.SPAWN_HOME = savedSpawnHome;
processExitSpy.mockRestore();
if (existsSync(testDir)) {
rmSync(testDir, {
recursive: true,
force: true,
});
}
});
it("exits with error when no IP address is provided", async () => {
const consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
await asyncTryCatch(() =>
cmdLink([
"link",
]),
);
expect(processExitSpy).toHaveBeenCalledWith(1);
consoleErrorSpy.mockRestore();
});
it("exits with error when the IP is unreachable", async () => {
await asyncTryCatch(() =>
cmdLink(
[
"link",
"1.2.3.4",
"--agent",
"claude",
"--cloud",
"hetzner",
"--user",
"root",
],
{
tcpCheck: TCP_UNREACHABLE,
sshCommand: SSH_NO_DETECT,
},
),
);
expect(processExitSpy).toHaveBeenCalledWith(1);
expect(clack.logError).toHaveBeenCalledWith(expect.stringContaining("not reachable"));
});
it("saves a spawn record when agent and cloud are provided via flags", async () => {
const { loadHistory } = await import("../history.js");
await cmdLink(
[
"link",
"1.2.3.4",
"--agent",
"claude",
"--cloud",
"hetzner",
"--user",
"root",
],
{
tcpCheck: TCP_REACHABLE,
sshCommand: SSH_NO_DETECT,
},
);
expect(clack.logSuccess).toHaveBeenCalledWith(expect.stringContaining("Deployment linked"));
const records = loadHistory();
expect(records.length).toBe(1);
expect(records[0].agent).toBe("claude");
expect(records[0].cloud).toBe("hetzner");
expect(records[0].connection?.ip).toBe("1.2.3.4");
expect(records[0].connection?.user).toBe("root");
});
it("auto-detects agent from running processes", async () => {
const { loadHistory } = await import("../history.js");
await cmdLink(
[
"link",
"10.0.0.1",
"--cloud",
"hetzner",
"--user",
"root",
],
{
tcpCheck: TCP_REACHABLE,
sshCommand: SSH_DETECT_CLAUDE,
},
);
expect(clack.logSuccess).toHaveBeenCalledWith(expect.stringContaining("Deployment linked"));
const records = loadHistory();
expect(records.length).toBe(1);
expect(records[0].agent).toBe("claude");
});
it("generates a default name from agent and IP", async () => {
const { loadHistory } = await import("../history.js");
await cmdLink(
[
"link",
"192.168.1.50",
"--agent",
"openclaw",
"--cloud",
"hetzner",
"--user",
"root",
],
{
tcpCheck: TCP_REACHABLE,
sshCommand: SSH_NO_DETECT,
},
);
const records = loadHistory();
expect(records.length).toBe(1);
expect(records[0].name).toBe("openclaw-192-168-1-50");
});
it("uses --name flag when specified", async () => {
const { loadHistory } = await import("../history.js");
await cmdLink(
[
"link",
"1.2.3.4",
"--agent",
"claude",
"--cloud",
"hetzner",
"--user",
"root",
"--name",
"my-dev-box",
],
{
tcpCheck: TCP_REACHABLE,
sshCommand: SSH_NO_DETECT,
},
);
const records = loadHistory();
expect(records.length).toBe(1);
expect(records[0].name).toBe("my-dev-box");
});
it("exits with error in non-interactive mode when agent not detected", async () => {
await asyncTryCatch(() =>
cmdLink(
[
"link",
"1.2.3.4",
"--cloud",
"hetzner",
"--user",
"root",
],
{
tcpCheck: TCP_REACHABLE,
sshCommand: SSH_NO_DETECT,
},
),
);
expect(processExitSpy).toHaveBeenCalledWith(1);
expect(clack.logError).toHaveBeenCalledWith(expect.stringContaining("auto-detect agent"));
});
it("exits with error in non-interactive mode when cloud not detected", async () => {
await asyncTryCatch(() =>
cmdLink(
[
"link",
"1.2.3.4",
"--agent",
"claude",
"--user",
"root",
],
{
tcpCheck: TCP_REACHABLE,
sshCommand: SSH_NO_DETECT,
},
),
);
expect(processExitSpy).toHaveBeenCalledWith(1);
expect(clack.logError).toHaveBeenCalledWith(expect.stringContaining("auto-detect cloud"));
});
it("exits with error for an invalid IP address", async () => {
const consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
await asyncTryCatch(() =>
cmdLink(
[
"link",
"not-an-ip",
"--agent",
"claude",
"--cloud",
"hetzner",
],
{
tcpCheck: TCP_REACHABLE,
sshCommand: SSH_NO_DETECT,
},
),
);
expect(processExitSpy).toHaveBeenCalledWith(1);
consoleErrorSpy.mockRestore();
});
});

View file

@ -21,6 +21,8 @@ export {
} from "./info.js";
// interactive.ts — cmdInteractive, cmdAgentInteractive
export { cmdAgentInteractive, cmdInteractive } from "./interactive.js";
// link.ts — cmdLink
export { cmdLink } from "./link.js";
// list.ts — cmdList, cmdLast, cmdListClear, history display
export {
buildRecordLabel,

View file

@ -0,0 +1,441 @@
// commands/link.ts — spawn link: reconnect an existing cloud deployment to spawn
//
// Lets users re-register a running remote VM by IP address, so that
// spawn list/delete/fix all work seamlessly on the re-connected server.
import { spawnSync } from "node:child_process";
import { connect } from "node:net";
import * as p from "@clack/prompts";
import pc from "picocolors";
import { generateSpawnId, saveSpawnRecord } from "../history.js";
import { agentKeys, cloudKeys, loadManifest } from "../manifest.js";
import { validateConnectionIP, validateUsername } from "../security.js";
import { asyncTryCatch, tryCatch } from "../shared/result.js";
import { SSH_BASE_OPTS, SSH_INTERACTIVE_OPTS, spawnInteractive } from "../shared/ssh.js";
import { ensureSshKeys, getSshKeyOpts } from "../shared/ssh-keys.js";
import { getErrorMessage, handleCancel, isInteractiveTTY } from "./shared.js";
// ─── TCP check ───────────────────────────────────────────────────────────────
function defaultTcpCheck(host: string, port: number, timeoutMs = 10000): Promise<boolean> {
return new Promise((resolve) => {
const socket = connect({
host,
port,
});
const timer = setTimeout(() => {
socket.destroy();
resolve(false);
}, timeoutMs);
socket.on("connect", () => {
clearTimeout(timer);
socket.destroy();
resolve(true);
});
socket.on("error", () => {
clearTimeout(timer);
socket.destroy();
resolve(false);
});
});
}
// ─── Remote detection ────────────────────────────────────────────────────────
/** Run a command via SSH and return trimmed stdout, or null on failure. */
function defaultSshCommand(host: string, user: string, keyOpts: string[], cmd: string): string | null {
const result = spawnSync(
"ssh",
[
...SSH_BASE_OPTS,
...keyOpts,
`${user}@${host}`,
cmd,
],
{
encoding: "utf8",
timeout: 15000,
},
);
if (result.status !== 0 || result.error) {
return null;
}
return result.stdout?.trim() || null;
}
const KNOWN_AGENTS = [
"claude",
"openclaw",
"zeroclaw",
"codex",
"opencode",
"kilocode",
"hermes",
"junie",
] as const;
type KnownAgent = (typeof KNOWN_AGENTS)[number];
/** Auto-detect which agent is installed/running on the remote host. */
function detectAgent(host: string, user: string, keyOpts: string[], runCmd: SshCommandFn): string | null {
// First: check running processes
const psCmd =
"ps aux 2>/dev/null | grep -oE 'claude(-code)?|openclaw|zeroclaw|codex|opencode|kilocode|hermes|junie' | grep -v grep | head -1 || true";
const psOut = runCmd(host, user, keyOpts, psCmd);
if (psOut) {
const match = KNOWN_AGENTS.find((b: KnownAgent) => psOut.includes(b));
if (match) {
return match;
}
}
// Second: check installed binaries
const whichCmd = KNOWN_AGENTS.map((b) => `(which ${b} 2>/dev/null && echo ${b})`).join(" || ");
const whichOut = runCmd(host, user, keyOpts, whichCmd);
if (whichOut) {
const match = KNOWN_AGENTS.find((b: KnownAgent) => whichOut.includes(b));
if (match) {
return match;
}
}
return null;
}
/** Auto-detect which cloud provider is hosting the remote server. */
function detectCloud(host: string, user: string, keyOpts: string[], runCmd: SshCommandFn): string | null {
// Check IMDS metadata endpoints — each cloud provider exposes its own
const detectCmd = [
"if curl -sf --max-time 1 http://169.254.169.254/hetzner/v1/metadata/instance-id >/dev/null 2>&1; then echo hetzner",
"elif curl -sf --max-time 1 http://169.254.169.254/latest/meta-data/instance-id >/dev/null 2>&1; then echo aws",
"elif curl -sf --max-time 1 http://169.254.169.254/metadata/v1/id >/dev/null 2>&1; then echo digitalocean",
"elif curl -sf --max-time 1 -H 'Metadata-Flavor: Google' http://metadata.google.internal/computeMetadata/v1/instance/id >/dev/null 2>&1; then echo gcp",
"fi",
].join("; ");
return runCmd(host, user, keyOpts, detectCmd);
}
// ─── Validation helpers ───────────────────────────────────────────────────────
/** Parse and validate a positional IP address from args, returning null if absent. */
function parseIpArg(args: string[]): string | null {
const positional = args.filter((a) => !a.startsWith("-"));
return positional[0] ?? null;
}
/** Extract --flag value pairs from args, returning [value, remainingArgs]. */
function extractFlag(
args: string[],
flags: string[],
): [
string | undefined,
string[],
] {
const idx = args.findIndex((a) => flags.includes(a));
if (idx === -1) {
return [
undefined,
args,
];
}
const val = args[idx + 1];
if (!val || val.startsWith("-")) {
return [
undefined,
args,
];
}
const rest = [
...args,
];
rest.splice(idx, 2);
return [
val,
rest,
];
}
// ─── Dependency injection types ───────────────────────────────────────────────
export type TcpCheckFn = (host: string, port: number, timeoutMs?: number) => Promise<boolean>;
export type SshCommandFn = (host: string, user: string, keyOpts: string[], cmd: string) => string | null;
export interface LinkOptions {
/** Override TCP reachability check (injectable for tests). */
tcpCheck?: TcpCheckFn;
/** Override SSH command runner (injectable for tests). */
sshCommand?: SshCommandFn;
}
// ─── Main command ─────────────────────────────────────────────────────────────
/**
* spawn link <ip> [--agent <agent>] [--cloud <cloud>] [--user <user>] [--name <name>]
*
* Re-registers an existing cloud deployment in spawn's local state so that
* spawn list, spawn delete, spawn fix, etc. all work on it.
*/
export async function cmdLink(args: string[], options?: LinkOptions): Promise<void> {
const tcpCheckFn = options?.tcpCheck ?? defaultTcpCheck;
const sshCommandFn = options?.sshCommand ?? defaultSshCommand;
// ── Parse flags ────────────────────────────────────────────────────────────
let remaining = [
...args.slice(1),
]; // remove "link" command itself
const [cloudFlag, r1] = extractFlag(remaining, [
"--cloud",
"-c",
]);
remaining = r1;
const [agentFlag, r2] = extractFlag(remaining, [
"--agent",
"-a",
]);
remaining = r2;
const [userFlag, r3] = extractFlag(remaining, [
"--user",
"-u",
]);
remaining = r3;
const [nameFlag, r4] = extractFlag(remaining, [
"--name",
"-n",
]);
remaining = r4;
// ── Get IP from positional arg ─────────────────────────────────────────────
const ip = parseIpArg(remaining);
if (!ip) {
console.error(pc.red("Error: spawn link requires an IP address"));
console.error(`\nUsage: ${pc.cyan("spawn link <ip>")}`);
console.error(` ${pc.cyan("spawn link 152.32.1.1 --agent claude --cloud hetzner")}`);
process.exit(1);
}
// ── Validate IP ────────────────────────────────────────────────────────────
const ipValidation = tryCatch(() => validateConnectionIP(ip));
if (!ipValidation.ok) {
console.error(pc.red(`Invalid IP address: ${pc.bold(ip)}`));
console.error(`\n${getErrorMessage(ipValidation.error)}`);
process.exit(1);
}
p.intro(`${pc.bold("spawn link")} — reconnect an existing deployment`);
// ── Determine SSH user ─────────────────────────────────────────────────────
let sshUser = userFlag ?? "root";
if (!userFlag && isInteractiveTTY()) {
const userInput = await p.text({
message: `SSH user for ${pc.cyan(ip)}`,
placeholder: "root",
defaultValue: "root",
});
if (p.isCancel(userInput)) {
handleCancel();
}
sshUser = userInput || "root";
}
// Validate SSH user
const userValidation = tryCatch(() => validateUsername(sshUser));
if (!userValidation.ok) {
p.log.error(`Invalid SSH user: ${sshUser}`);
p.log.info("Username must be lowercase letters, digits, underscores, or hyphens (e.g. root, ubuntu, ec2-user)");
process.exit(1);
}
// ── Check connectivity ─────────────────────────────────────────────────────
const connectSpinner = p.spinner();
connectSpinner.start(`Checking connectivity to ${pc.cyan(ip)}...`);
const reachable = await tcpCheckFn(ip, 22, 10000);
if (!reachable) {
connectSpinner.stop(`Cannot reach ${ip} on port 22`);
p.log.error(`SSH port 22 is not reachable at ${pc.bold(ip)}.`);
p.log.info("Make sure the server is running and port 22 is open.");
p.log.info(`Try manually: ${pc.cyan(`ssh root@${ip}`)}`);
process.exit(1);
}
connectSpinner.stop(`${ip} is reachable`);
// ── Get SSH keys ───────────────────────────────────────────────────────────
const keysResult = await asyncTryCatch(() => ensureSshKeys());
const keyOpts = keysResult.ok ? getSshKeyOpts(keysResult.data) : [];
// ── Auto-detect agent and cloud ────────────────────────────────────────────
let detectedAgent: string | null = agentFlag ?? null;
let detectedCloud: string | null = cloudFlag ?? null;
const needsDetection = !detectedAgent || !detectedCloud;
if (needsDetection) {
const detectSpinner = p.spinner();
detectSpinner.start("Auto-detecting agent and cloud provider...");
if (!detectedAgent) {
detectedAgent = detectAgent(ip, sshUser, keyOpts, sshCommandFn);
}
if (!detectedCloud) {
detectedCloud = detectCloud(ip, sshUser, keyOpts, sshCommandFn);
}
const agentStatus = detectedAgent ?? "unknown";
const cloudStatus = detectedCloud ?? "unknown";
detectSpinner.stop(`Detected: agent=${agentStatus}, cloud=${cloudStatus}`);
}
// ── Load manifest for validation and picker ────────────────────────────────
const manifestResult = await asyncTryCatch(() => loadManifest());
const manifest = manifestResult.ok ? manifestResult.data : null;
// ── Prompt for agent if not detected ──────────────────────────────────────
if (!detectedAgent) {
if (!isInteractiveTTY()) {
p.log.error("Could not auto-detect agent. Use --agent <agent> to specify it.");
p.log.info(`Example: ${pc.cyan(`spawn link ${ip} --agent claude`)}`);
if (manifest) {
const agents = agentKeys(manifest);
p.log.info(`Available agents: ${agents.join(", ")}`);
}
process.exit(1);
}
const agentPickOptions =
manifest && Object.keys(manifest.agents).length > 0
? agentKeys(manifest).map((key) => ({
value: key,
label: manifest.agents[key]?.name ?? key,
hint: key,
}))
: [
{
value: "claude",
label: "Claude Code",
hint: "claude",
},
];
const agentPick = await p.select({
message: "Which agent is running on this server?",
options: agentPickOptions,
});
if (p.isCancel(agentPick)) {
handleCancel();
}
detectedAgent = agentPick;
}
// ── Prompt for cloud if not detected ──────────────────────────────────────
if (!detectedCloud) {
if (!isInteractiveTTY()) {
p.log.error("Could not auto-detect cloud provider. Use --cloud <cloud> to specify it.");
p.log.info(`Example: ${pc.cyan(`spawn link ${ip} --cloud hetzner`)}`);
if (manifest) {
const clouds = cloudKeys(manifest).filter((c) => c !== "local");
p.log.info(`Available clouds: ${clouds.join(", ")}`);
}
process.exit(1);
}
const cloudPickOptions =
manifest && Object.keys(manifest.clouds).length > 0
? cloudKeys(manifest)
.filter((key) => key !== "local")
.map((key) => ({
value: key,
label: manifest.clouds[key]?.name ?? key,
hint: key,
}))
: [];
cloudPickOptions.push({
value: "other",
label: "Other / Unknown",
hint: "other",
});
const cloudPick = await p.select({
message: "Which cloud provider is this server on?",
options: cloudPickOptions,
});
if (p.isCancel(cloudPick)) {
handleCancel();
}
detectedCloud = cloudPick;
}
// ── Confirm details ────────────────────────────────────────────────────────
const safeIpSegment = ip.replace(/\./g, "-");
const spawnName = nameFlag ?? `${detectedAgent}-${safeIpSegment}`;
if (isInteractiveTTY()) {
const agentLabel = manifest?.agents[detectedAgent]?.name ?? detectedAgent;
const cloudLabel = manifest?.clouds[detectedCloud]?.name ?? detectedCloud;
p.log.info(` IP: ${ip}`);
p.log.info(` User: ${sshUser}`);
p.log.info(` Agent: ${agentLabel}`);
p.log.info(` Cloud: ${cloudLabel}`);
p.log.info(` Name: ${spawnName}`);
const confirmed = await p.confirm({
message: "Register this deployment?",
initialValue: true,
});
if (p.isCancel(confirmed) || !confirmed) {
p.outro("Aborted.");
return;
}
}
// ── Save to history ────────────────────────────────────────────────────────
const record = {
id: generateSpawnId(),
agent: detectedAgent,
cloud: detectedCloud,
timestamp: new Date().toISOString(),
name: spawnName,
connection: {
ip,
user: sshUser,
cloud: detectedCloud,
},
};
const saveResult = tryCatch(() => saveSpawnRecord(record));
if (!saveResult.ok) {
p.log.error(`Failed to save deployment: ${getErrorMessage(saveResult.error)}`);
process.exit(1);
}
p.log.success(`Deployment linked! Run ${pc.cyan("spawn list")} to see it.`);
// ── Offer to connect immediately ───────────────────────────────────────────
if (isInteractiveTTY()) {
const connectNow = await p.confirm({
message: "Connect now?",
initialValue: true,
});
if (!p.isCancel(connectNow) && connectNow) {
p.log.step(`Connecting to ${ip}...`);
const sshArgs = [
"ssh",
...SSH_INTERACTIVE_OPTS,
...keyOpts,
`${sshUser}@${ip}`,
];
spawnInteractive(sshArgs);
}
}
p.outro(`Linked as ${spawnName}. Run ${pc.cyan("spawn list")} to manage it.`);
}

View file

@ -35,6 +35,8 @@ export const KNOWN_FLAGS = new Set([
"-m",
"--config",
"--steps",
"--user",
"-u",
]);
/** Return the first unknown flag in args, or null if all are known/positional */

View file

@ -15,6 +15,7 @@ import {
cmdHelp,
cmdInteractive,
cmdLast,
cmdLink,
cmdList,
cmdListClear,
cmdMatrix,
@ -129,6 +130,12 @@ function checkUnknownFlags(args: string[]): void {
console.error(` For ${pc.cyan("spawn pick")}:`);
console.error(` ${pc.cyan("--default")} Pre-selected value in the picker`);
console.error();
console.error(` For ${pc.cyan("spawn link")}:`);
console.error(` ${pc.cyan("-a, --agent")} Agent running on the server`);
console.error(` ${pc.cyan("-c, --cloud")} Cloud provider the server is on`);
console.error(` ${pc.cyan("-u, --user")} SSH user (default: root)`);
console.error(` ${pc.cyan("--name")} Custom name for this linked spawn`);
console.error();
console.error(` For ${pc.cyan("spawn list")}:`);
console.error(` ${pc.cyan("-a, --agent")} Filter history by agent`);
console.error(` ${pc.cyan("-c, --cloud")} Filter history by cloud`);
@ -728,6 +735,14 @@ async function dispatchCommand(
await dispatchSubcommand(cmd, filteredArgs);
return;
}
if (cmd === "link" || cmd === "reconnect") {
if (hasTrailingHelpFlag(filteredArgs)) {
cmdHelp();
return;
}
await cmdLink(filteredArgs);
return;
}
if (VERB_ALIASES.has(cmd)) {
await dispatchVerbAlias(cmd, filteredArgs, prompt, dryRun, debug, headless, outputFormat);
return;