spawn/packages/cli/src/gcp/gcp.ts
L 65a81edc57
fix: add unique spawn IDs to prevent history record corruption (#2235)
* fix: add unique spawn IDs to prevent history record corruption

History records were matched by heuristic ("most recent record for this
cloud without a connection"), which caused saveVmConnection and
saveLaunchCmd to overwrite the wrong record during concurrent or failed
spawns.

Fix: every SpawnRecord now has a unique `id` (UUID). All history
operations (saveVmConnection, saveLaunchCmd, removeRecord,
markRecordDeleted, mergeLastConnection) match by id when available,
falling back to the old heuristic for pre-migration records.

The orchestrator (TS path) now creates the history record AFTER server
creation succeeds, not before — so failed provisions don't leave orphan
entries.

Also adds "Remove from history" option to the spawn ls action picker,
restoring the ability to soft-delete entries without destroying the VM.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add 18 unit tests for spawn ID history behavior

Tests cover:
- generateSpawnId returns unique UUIDs
- saveSpawnRecord auto-generates id when not provided
- saveVmConnection matches by spawnId (not heuristic)
- saveVmConnection does not cross-contaminate concurrent spawns
- saveVmConnection falls back to heuristic without spawnId
- saveLaunchCmd matches by spawnId (not heuristic)
- saveLaunchCmd falls back without spawnId
- removeRecord matches by id, not by timestamp+agent+cloud
- removeRecord handles duplicate timestamps correctly
- removeRecord falls back for legacy records without id
- markRecordDeleted targets correct record by id
- mergeLastConnection uses spawn_id from last-connection.json
- mergeLastConnection falls back to heuristic without spawn_id

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: enable biome import sorting with grouped imports

Adds organizeImports to biome assist config with groups:
1. Type imports
2. Node built-ins
3. Third-party packages
4. @openrouter/* packages
5. Aliases

Auto-fixed import order and lint issues across all TypeScript files,
including .claude/skills/ and packages/cli/src/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-05 23:27:03 -08:00

1014 lines
28 KiB
TypeScript

// gcp/gcp.ts — Core GCP Compute Engine provider: gcloud CLI wrapper, auth, provisioning, SSH
import type { CloudInitTier } from "../shared/agents";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { saveVmConnection } from "../history.js";
import { getPackagesForTier, NODE_INSTALL_CMD, needsBun, needsNode } from "../shared/cloud-init";
import {
killWithTimeout,
SSH_BASE_OPTS,
SSH_INTERACTIVE_OPTS,
waitForSsh as sharedWaitForSsh,
sleep,
spawnInteractive,
} from "../shared/ssh";
import { ensureSshKeys, getSshKeyOpts } from "../shared/ssh-keys";
import {
defaultSpawnName,
logError,
logInfo,
logStep,
logStepDone,
logStepInline,
logWarn,
prompt,
sanitizeTermValue,
selectFromList,
toKebabCase,
validateServerName,
} from "../shared/ui";
const DASHBOARD_URL = "https://console.cloud.google.com/compute/instances";
// ─── Machine Type Tiers ─────────────────────────────────────────────────────
export interface MachineTypeTier {
id: string;
label: string;
}
export const MACHINE_TYPES: MachineTypeTier[] = [
{
id: "e2-micro",
label: "Shared CPU \u00b7 2 vCPU \u00b7 1 GB RAM (~$7/mo)",
},
{
id: "e2-small",
label: "Shared CPU \u00b7 2 vCPU \u00b7 2 GB RAM (~$14/mo)",
},
{
id: "e2-medium",
label: "Shared CPU \u00b7 2 vCPU \u00b7 4 GB RAM (~$28/mo)",
},
{
id: "e2-standard-2",
label: "2 vCPU \u00b7 8 GB RAM (~$49/mo)",
},
{
id: "e2-standard-4",
label: "4 vCPU \u00b7 16 GB RAM (~$98/mo)",
},
{
id: "n2-standard-2",
label: "2 vCPU \u00b7 8 GB RAM, higher perf (~$72/mo)",
},
{
id: "n2-standard-4",
label: "4 vCPU \u00b7 16 GB RAM, higher perf (~$144/mo)",
},
{
id: "c4-standard-2",
label: "2 vCPU \u00b7 8 GB RAM, latest gen (~$82/mo)",
},
];
export const DEFAULT_MACHINE_TYPE = "e2-medium";
// ─── Zone Options ────────────────────────────────────────────────────────────
export interface ZoneOption {
id: string;
label: string;
}
export const ZONES: ZoneOption[] = [
{
id: "us-central1-a",
label: "Iowa, US",
},
{
id: "us-east1-b",
label: "South Carolina, US",
},
{
id: "us-east4-a",
label: "N. Virginia, US",
},
{
id: "us-west1-a",
label: "Oregon, US",
},
{
id: "us-west2-a",
label: "Los Angeles, US",
},
{
id: "northamerica-northeast1-a",
label: "Montreal, Canada",
},
{
id: "europe-west1-b",
label: "Belgium",
},
{
id: "europe-west4-a",
label: "Netherlands",
},
{
id: "europe-west6-a",
label: "Zurich, Switzerland",
},
{
id: "asia-east1-a",
label: "Taiwan",
},
{
id: "asia-southeast1-a",
label: "Singapore",
},
{
id: "australia-southeast1-a",
label: "Sydney, Australia",
},
];
export const DEFAULT_ZONE = "us-central1-a";
// ─── State ──────────────────────────────────────────────────────────────────
let gcpProject = "";
let gcpZone = "";
let gcpInstanceName = "";
let gcpServerIp = "";
let gcpUsername = "";
// ─── gcloud CLI Wrapper ─────────────────────────────────────────────────────
function getGcloudCmd(): string | null {
if (
Bun.spawnSync(
[
"which",
"gcloud",
],
{
stdio: [
"ignore",
"pipe",
"ignore",
],
},
).exitCode === 0
) {
return "gcloud";
}
// Check common install locations
const paths = [
join(process.env.HOME || homedir(), "google-cloud-sdk/bin/gcloud"),
"/usr/lib/google-cloud-sdk/bin/gcloud",
"/snap/bin/gcloud",
];
for (const p of paths) {
if (existsSync(p)) {
return p;
}
}
return null;
}
/** Get gcloud path or throw a descriptive error. */
function requireGcloudCmd(): string {
const cmd = getGcloudCmd();
if (!cmd) {
throw new Error(
"gcloud CLI not found. Install it first:\n" +
" macOS: brew install --cask google-cloud-sdk\n" +
" Linux: curl https://sdk.cloud.google.com | bash\n" +
" Or run: spawn <agent> gcp (auto-installs gcloud)",
);
}
return cmd;
}
/** Run a gcloud command and return stdout. */
function gcloudSync(args: string[]): {
stdout: string;
stderr: string;
exitCode: number;
} {
const cmd = requireGcloudCmd();
const proc = Bun.spawnSync(
[
cmd,
...args,
],
{
stdio: [
"ignore",
"pipe",
"pipe",
],
env: process.env,
},
);
return {
stdout: new TextDecoder().decode(proc.stdout).trim(),
stderr: new TextDecoder().decode(proc.stderr).trim(),
exitCode: proc.exitCode,
};
}
/** Run a gcloud command asynchronously and return stdout. */
async function gcloud(args: string[]): Promise<{
stdout: string;
stderr: string;
exitCode: number;
}> {
const cmd = requireGcloudCmd();
const proc = Bun.spawn(
[
cmd,
...args,
],
{
stdio: [
"ignore",
"pipe",
"pipe",
],
env: process.env,
},
);
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
const exitCode = await proc.exited;
return {
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode,
};
}
/** Run a gcloud command interactively (inheriting stdio). */
async function gcloudInteractive(args: string[]): Promise<number> {
const cmd = requireGcloudCmd();
const proc = Bun.spawn(
[
cmd,
...args,
],
{
stdio: [
"inherit",
"inherit",
"inherit",
],
env: process.env,
},
);
return proc.exited;
}
// ─── CLI Installation ───────────────────────────────────────────────────────
export async function ensureGcloudCli(): Promise<void> {
if (getGcloudCmd()) {
logInfo("gcloud CLI available");
return;
}
logStep("Installing Google Cloud SDK...");
if (process.platform === "darwin") {
// Try Homebrew on macOS
const brewCheck = Bun.spawnSync(
[
"which",
"brew",
],
{
stdio: [
"ignore",
"pipe",
"ignore",
],
},
);
if (brewCheck.exitCode === 0) {
const proc = Bun.spawn(
[
"brew",
"install",
"--cask",
"google-cloud-sdk",
],
{
stdio: [
"ignore",
"inherit",
"inherit",
],
},
);
if ((await proc.exited) === 0) {
// Source the path
const prefix = new TextDecoder()
.decode(
Bun.spawnSync(
[
"brew",
"--prefix",
],
{
stdio: [
"ignore",
"pipe",
"ignore",
],
},
).stdout,
)
.trim();
const pathInc = `${prefix}/share/google-cloud-sdk/path.bash.inc`;
if (existsSync(pathInc)) {
// Add gcloud to PATH
const sdkBin = `${prefix}/share/google-cloud-sdk/bin`;
if (!process.env.PATH?.includes(sdkBin)) {
process.env.PATH = `${sdkBin}:${process.env.PATH}`;
}
}
if (getGcloudCmd()) {
logInfo("Google Cloud SDK installed via Homebrew");
return;
}
}
}
}
// Linux / macOS without brew: use Google's installer
const proc = Bun.spawn(
[
"bash",
"-c",
[
"_gcp_tmp=$(mktemp -d)",
`curl --proto "=https" -fsSL "https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-cli-linux-x86_64.tar.gz" -o "$_gcp_tmp/gcloud.tar.gz"`,
`tar -xzf "$_gcp_tmp/gcloud.tar.gz" -C "$HOME"`,
`"$HOME/google-cloud-sdk/install.sh" --quiet --path-update true`,
`rm -rf "$_gcp_tmp"`,
].join(" && "),
],
{
stdio: [
"ignore",
"inherit",
"inherit",
],
},
);
const exitCode = await proc.exited;
if (exitCode !== 0) {
logError("Failed to install Google Cloud SDK");
logError("Install manually: https://cloud.google.com/sdk/docs/install");
throw new Error("gcloud install failed");
}
// Add to PATH
const sdkBin = join(process.env.HOME || homedir(), "google-cloud-sdk/bin");
if (!process.env.PATH?.includes(sdkBin)) {
process.env.PATH = `${sdkBin}:${process.env.PATH}`;
}
if (!getGcloudCmd()) {
logError("gcloud not found after install. You may need to restart your shell.");
throw new Error("gcloud not in PATH");
}
logInfo("Google Cloud SDK installed");
}
// ─── Authentication ─────────────────────────────────────────────────────────
export async function authenticate(): Promise<void> {
// Check for active account
const result = gcloudSync([
"auth",
"list",
"--filter=status:ACTIVE",
"--format=value(account)",
]);
const activeAccount = result.stdout.split("\n")[0]?.trim();
if (activeAccount?.includes("@")) {
logInfo(`Authenticated as: ${activeAccount}`);
return;
}
logWarn("No active Google Cloud account -- launching gcloud auth login...");
const exitCode = await gcloudInteractive([
"auth",
"login",
]);
if (exitCode !== 0) {
logError("Authentication failed. You can also set credentials via:");
logError(" export GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json");
throw new Error("gcloud auth failed");
}
logInfo("Authenticated with Google Cloud");
}
// ─── Project Resolution ─────────────────────────────────────────────────────
export async function resolveProject(): Promise<void> {
// 1. Env var
if (process.env.GCP_PROJECT) {
gcpProject = process.env.GCP_PROJECT;
logInfo(`Using GCP project from environment: ${gcpProject}`);
return;
}
// 2. gcloud config
const configResult = gcloudSync([
"config",
"get-value",
"project",
]);
let project = configResult.stdout;
if (project === "(unset)") {
project = "";
}
// 3. Confirm or pick
if (project && process.env.SPAWN_NON_INTERACTIVE !== "1") {
const confirm = await prompt(`Use project '${project}'? [Y/n]: `);
if (/^[nN]/.test(confirm)) {
project = "";
}
}
if (!project) {
// In non-interactive mode (e.g. during deletion), fail fast instead of prompting
if (process.env.SPAWN_NON_INTERACTIVE === "1") {
logError("No GCP project found in metadata or gcloud config");
logError("Set one before retrying:");
logError(" export GCP_PROJECT=your-project-id");
throw new Error("No GCP project");
}
logInfo("Fetching your GCP projects...");
const listResult = await gcloud([
"projects",
"list",
"--filter=lifecycleState=ACTIVE",
"--format=value(projectId,name)",
]);
if (listResult.exitCode !== 0 || !listResult.stdout) {
logError("Failed to list GCP projects");
logError("Set one before retrying:");
logError(" export GCP_PROJECT=your-project-id");
throw new Error("No GCP project");
}
const items = listResult.stdout
.split("\n")
.filter((l) => l.trim())
.map((line) => {
const parts = line.split("\t");
return `${parts[0]}|${parts[1] || parts[0]}`;
});
if (items.length === 0) {
logError("No active GCP projects found");
logError("Create one at: https://console.cloud.google.com/projectcreate");
throw new Error("No GCP projects");
}
project = await selectFromList(items, "GCP projects", items[0].split("|")[0]);
}
if (!project) {
logError("No GCP project selected");
logError("Set one before retrying:");
logError(" export GCP_PROJECT=your-project-id");
throw new Error("No GCP project");
}
gcpProject = project;
logInfo(`Using GCP project: ${gcpProject}`);
}
// ─── Interactive Pickers ────────────────────────────────────────────────────
export async function promptMachineType(): Promise<string> {
if (process.env.GCP_MACHINE_TYPE) {
logInfo(`Using machine type from environment: ${process.env.GCP_MACHINE_TYPE}`);
return process.env.GCP_MACHINE_TYPE;
}
if (process.env.SPAWN_CUSTOM !== "1") {
return DEFAULT_MACHINE_TYPE;
}
if (process.env.SPAWN_NON_INTERACTIVE === "1") {
return DEFAULT_MACHINE_TYPE;
}
process.stderr.write("\n");
const items = MACHINE_TYPES.map((t) => `${t.id}|${t.label}`);
return selectFromList(items, "GCP machine types", DEFAULT_MACHINE_TYPE);
}
export async function promptZone(): Promise<string> {
if (process.env.GCP_ZONE) {
logInfo(`Using zone from environment: ${process.env.GCP_ZONE}`);
return process.env.GCP_ZONE;
}
if (process.env.SPAWN_CUSTOM !== "1") {
return DEFAULT_ZONE;
}
if (process.env.SPAWN_NON_INTERACTIVE === "1") {
return DEFAULT_ZONE;
}
process.stderr.write("\n");
const items = ZONES.map((z) => `${z.id}|${z.label}`);
return selectFromList(items, "GCP zones", DEFAULT_ZONE);
}
// ─── SSH Key ────────────────────────────────────────────────────────────────
async function ensureSshKey(): Promise<string> {
const selectedKeys = await ensureSshKeys();
// GCP accepts multiple ssh-keys in metadata, one per line
const pubKeys: string[] = [];
for (const key of selectedKeys) {
const pubKey = readFileSync(key.pubPath, "utf-8").trim();
pubKeys.push(pubKey);
}
logInfo(`${selectedKeys.length} SSH key(s) ready`);
return pubKeys.join("\n");
}
// ─── Username ───────────────────────────────────────────────────────────────
function resolveUsername(): string {
if (gcpUsername) {
return gcpUsername;
}
const result = Bun.spawnSync(
[
"whoami",
],
{
stdio: [
"ignore",
"pipe",
"ignore",
],
},
);
const username = new TextDecoder().decode(result.stdout).trim();
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
logError("Invalid username detected");
throw new Error("Invalid username");
}
gcpUsername = username;
return username;
}
// ─── Server Name ────────────────────────────────────────────────────────────
export async function getServerName(): Promise<string> {
if (process.env.GCP_INSTANCE_NAME) {
const name = process.env.GCP_INSTANCE_NAME;
if (!validateServerName(name)) {
logError(`Invalid GCP_INSTANCE_NAME: '${name}'`);
throw new Error("Invalid server name");
}
logInfo(`Using instance name from environment: ${name}`);
return name;
}
const kebab = process.env.SPAWN_NAME_KEBAB || (process.env.SPAWN_NAME ? toKebabCase(process.env.SPAWN_NAME) : "");
return kebab || defaultSpawnName();
}
export async function promptSpawnName(): Promise<void> {
if (process.env.SPAWN_NAME_KEBAB) {
return;
}
let kebab: string;
if (process.env.SPAWN_NON_INTERACTIVE === "1") {
kebab = (process.env.SPAWN_NAME ? toKebabCase(process.env.SPAWN_NAME) : "") || defaultSpawnName();
} else {
const derived = process.env.SPAWN_NAME ? toKebabCase(process.env.SPAWN_NAME) : "";
const fallback = derived || defaultSpawnName();
process.stderr.write("\n");
const answer = await prompt(`GCP instance name [${fallback}]: `);
kebab = toKebabCase(answer || fallback) || defaultSpawnName();
}
process.env.SPAWN_NAME_DISPLAY = kebab;
process.env.SPAWN_NAME_KEBAB = kebab;
logInfo(`Using resource name: ${kebab}`);
}
// ─── Cloud Init Startup Script ──────────────────────────────────────────────
function getStartupScript(username: string, tier: CloudInitTier = "full"): string {
const packages = getPackagesForTier(tier);
const lines = [
"#!/bin/bash",
"export DEBIAN_FRONTEND=noninteractive",
"apt-get update -y",
`apt-get install -y --no-install-recommends ${packages.join(" ")}`,
];
if (needsNode(tier)) {
lines.push(
"# Install Node.js 22 via n (run as root so it installs to /usr/local/bin/)",
`${NODE_INSTALL_CMD} || true`,
"# Install Claude Code as the login user",
`su - "${username}" -c 'curl --proto "=https" -fsSL https://claude.ai/install.sh | bash' || true`,
);
}
if (needsBun(tier)) {
lines.push(
"# Install Bun as the login user",
`su - "${username}" -c 'curl --proto "=https" -fsSL https://bun.sh/install | bash' || true`,
`ln -sf /home/${username}/.bun/bin/bun /usr/local/bin/bun 2>/dev/null || true`,
);
}
lines.push(
"# Configure PATH for all users",
"echo 'export PATH=\"${HOME}/.npm-global/bin:${HOME}/.claude/local/bin:${HOME}/.local/bin:${HOME}/.bun/bin:${PATH}\"' >> /etc/profile.d/spawn.sh",
"chmod +x /etc/profile.d/spawn.sh",
"touch /tmp/.cloud-init-complete",
);
return lines.join("\n") + "\n";
}
// ─── Provisioning ───────────────────────────────────────────────────────────
export async function createInstance(
name: string,
zone: string,
machineType: string,
tier?: CloudInitTier,
): Promise<void> {
const username = resolveUsername();
const pubKeys = await ensureSshKey();
// Build ssh-keys metadata: one "user:key" entry per line
const sshKeysMetadata = pubKeys
.split("\n")
.map((k) => `${username}:${k}`)
.join("\n");
logStep(`Creating GCP instance '${name}' (type: ${machineType}, zone: ${zone})...`);
// Write startup script to a temp file
const tmpFile = `/tmp/spawn_startup_${Date.now()}.sh`;
writeFileSync(tmpFile, getStartupScript(username, tier));
const args = [
"compute",
"instances",
"create",
name,
`--zone=${zone}`,
`--machine-type=${machineType}`,
"--image-family=ubuntu-2404-lts-amd64",
"--image-project=ubuntu-os-cloud",
`--network=${process.env.GCP_NETWORK ?? "default"}`,
`--subnet=${process.env.GCP_SUBNET ?? "default"}`,
`--metadata-from-file=startup-script=${tmpFile}`,
`--metadata=ssh-keys=${sshKeysMetadata}`,
`--project=${gcpProject}`,
"--quiet",
];
let result = await gcloud(args);
// Auto-reauth on expired tokens
if (
result.exitCode !== 0 &&
/reauthentication|refresh.*auth|token.*expired|credentials.*invalid/i.test(result.stderr)
) {
logWarn("Auth tokens expired -- running gcloud auth login...");
const reauth = await gcloudInteractive([
"auth",
"login",
]);
if (reauth === 0) {
await gcloudInteractive([
"config",
"set",
"project",
gcpProject,
]);
logInfo("Re-authenticated, retrying instance creation...");
result = await gcloud(args);
}
}
// Clean up temp file
try {
Bun.spawnSync([
"rm",
"-f",
tmpFile,
]);
} catch {
/* ignore */
}
if (result.exitCode !== 0) {
logError("Failed to create GCP instance");
if (result.stderr) {
logError(`gcloud error: ${result.stderr}`);
}
logWarn("Common issues:");
logWarn(" - Billing not enabled (enable at https://console.cloud.google.com/billing)");
logWarn(" - Compute Engine API not enabled (enable at https://console.cloud.google.com/apis)");
logWarn(" - Instance quota exceeded (try different GCP_ZONE)");
logWarn(" - Machine type unavailable (try different GCP_MACHINE_TYPE or GCP_ZONE)");
throw new Error("Instance creation failed");
}
// Get external IP
const ipResult = gcloudSync([
"compute",
"instances",
"describe",
name,
`--zone=${zone}`,
`--project=${gcpProject}`,
"--format=get(networkInterfaces[0].accessConfigs[0].natIP)",
]);
gcpInstanceName = name;
gcpZone = zone;
gcpServerIp = ipResult.stdout;
logInfo(`Instance created: IP=${gcpServerIp}`);
// Save connection info with zone/project for later deletion
saveVmConnection(
gcpServerIp,
username,
"",
name,
"gcp",
undefined,
{
zone,
project: gcpProject,
},
process.env.SPAWN_ID || undefined,
);
}
// ─── SSH Operations ─────────────────────────────────────────────────────────
async function waitForSsh(maxAttempts = 36): Promise<void> {
const username = resolveUsername();
const keyOpts = getSshKeyOpts(await ensureSshKeys());
await sharedWaitForSsh({
host: gcpServerIp,
user: username,
maxAttempts,
extraSshOpts: keyOpts,
});
}
export async function waitForCloudInit(maxAttempts = 60): Promise<void> {
await waitForSsh();
logStep("Waiting for startup script completion...");
const username = resolveUsername();
const keyOpts = getSshKeyOpts(await ensureSshKeys());
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const proc = Bun.spawn(
[
"ssh",
...SSH_BASE_OPTS,
...keyOpts,
`${username}@${gcpServerIp}`,
"test -f /tmp/.cloud-init-complete",
],
{
stdio: [
"ignore",
"pipe",
"pipe",
],
},
);
// Drain both pipes before awaiting exit to prevent pipe buffer deadlock
await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
if ((await proc.exited) === 0) {
logStepDone();
logInfo("Startup script completed");
return;
}
} catch {
// ignore
}
logStepInline(`Startup script running (${attempt}/${maxAttempts})`);
await sleep(5000);
}
logStepDone();
logWarn("Startup script may not have completed, continuing...");
}
export async function runServer(cmd: string, timeoutSecs?: number): Promise<void> {
const username = resolveUsername();
const fullCmd = `export PATH="$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH" && ${cmd}`;
const keyOpts = getSshKeyOpts(await ensureSshKeys());
const proc = Bun.spawn(
[
"ssh",
...SSH_BASE_OPTS,
...keyOpts,
`${username}@${gcpServerIp}`,
`bash -c ${shellQuote(fullCmd)}`,
],
{
stdio: [
"ignore",
"inherit",
"inherit",
],
env: process.env,
},
);
const timeout = (timeoutSecs || 300) * 1000;
const timer = setTimeout(() => killWithTimeout(proc), timeout);
try {
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`run_server failed (exit ${exitCode}): ${cmd}`);
}
} finally {
clearTimeout(timer);
}
}
export async function runServerCapture(cmd: string, timeoutSecs?: number): Promise<string> {
const username = resolveUsername();
const fullCmd = `export PATH="$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH" && ${cmd}`;
const keyOpts = getSshKeyOpts(await ensureSshKeys());
const proc = Bun.spawn(
[
"ssh",
...SSH_BASE_OPTS,
...keyOpts,
`${username}@${gcpServerIp}`,
`bash -c ${shellQuote(fullCmd)}`,
],
{
stdio: [
"ignore",
"pipe",
"pipe",
],
env: process.env,
},
);
const timeout = (timeoutSecs || 300) * 1000;
const timer = setTimeout(() => killWithTimeout(proc), timeout);
try {
// Drain both pipes before awaiting exit to prevent pipe buffer deadlock
const [stdout] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`run_server_capture failed (exit ${exitCode})`);
}
return stdout.trim();
} finally {
clearTimeout(timer);
}
}
export async function uploadFile(localPath: string, remotePath: string): Promise<void> {
if (
!/^[a-zA-Z0-9/_.~$-]+$/.test(remotePath) ||
remotePath.includes("..") ||
remotePath.split("/").some((s) => s.startsWith("-"))
) {
logError(`Invalid remote path: ${remotePath}`);
throw new Error("Invalid remote path");
}
const username = resolveUsername();
// Expand $HOME on remote side
const expandedPath = remotePath.replace(/^\$HOME/, "~");
const keyOpts = getSshKeyOpts(await ensureSshKeys());
const proc = Bun.spawn(
[
"scp",
...SSH_BASE_OPTS,
...keyOpts,
localPath,
`${username}@${gcpServerIp}:${expandedPath}`,
],
{
stdio: [
"ignore",
"inherit",
"inherit",
],
env: process.env,
},
);
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`upload_file failed for ${remotePath}`);
}
}
export async function interactiveSession(cmd: string): Promise<number> {
const username = resolveUsername();
const term = sanitizeTermValue(process.env.TERM || "xterm-256color");
// Single-quote escaping prevents premature shell expansion of $variables in cmd
const shellEscapedCmd = cmd.replace(/'/g, "'\\''");
const fullCmd = `export TERM=${term} PATH="$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH" && exec bash -l -c '${shellEscapedCmd}'`;
const keyOpts = getSshKeyOpts(await ensureSshKeys());
const exitCode = spawnInteractive([
"ssh",
...SSH_INTERACTIVE_OPTS,
...keyOpts,
`${username}@${gcpServerIp}`,
fullCmd,
]);
// Post-session summary
process.stderr.write("\n");
logWarn(`Session ended. Your GCP instance '${gcpInstanceName}' is still running.`);
logWarn("Remember to delete it when you're done to avoid ongoing charges.");
logWarn("");
logWarn("Manage or delete it in your dashboard:");
logWarn(` ${DASHBOARD_URL}`);
logWarn("");
logInfo("To delete from CLI:");
logInfo(" spawn delete");
logInfo("To reconnect:");
logInfo(` gcloud compute ssh ${gcpInstanceName} --zone=${gcpZone} --project=${gcpProject}`);
return exitCode;
}
// ─── Lifecycle ──────────────────────────────────────────────────────────────
export async function destroyInstance(name?: string): Promise<void> {
const instanceName = name || gcpInstanceName;
const zone = gcpZone || process.env.GCP_ZONE || DEFAULT_ZONE;
if (!instanceName) {
logError("destroy: no instance name provided");
throw new Error("No instance name");
}
logStep(`Destroying GCP instance '${instanceName}'...`);
const result = await gcloud([
"compute",
"instances",
"delete",
instanceName,
`--zone=${zone}`,
`--project=${gcpProject}`,
"--quiet",
]);
if (result.exitCode !== 0) {
logError(`Failed to destroy GCP instance '${instanceName}'`);
logWarn("The instance may still be running and incurring charges.");
logWarn(`Delete it manually: ${DASHBOARD_URL}`);
throw new Error("Instance deletion failed");
}
logInfo(`Instance '${instanceName}' destroyed`);
}
// ─── Shell Quoting ──────────────────────────────────────────────────────────
function shellQuote(s: string): string {
return "'" + s.replace(/'/g, "'\\''") + "'";
}