mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-19 16:39:50 +00:00
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:
parent
6ef20ed437
commit
5cc9930769
6 changed files with 736 additions and 1 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "0.19.7",
|
||||
"version": "0.20.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
275
packages/cli/src/__tests__/cmd-link.test.ts
Normal file
275
packages/cli/src/__tests__/cmd-link.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
441
packages/cli/src/commands/link.ts
Normal file
441
packages/cli/src/commands/link.ts
Normal 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.`);
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue