mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 03:49:31 +00:00
* 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>
262 lines
8.3 KiB
TypeScript
262 lines
8.3 KiB
TypeScript
import type { SpawnRecord } from "../history.js";
|
|
import type { Manifest } from "../manifest.js";
|
|
|
|
import * as p from "@clack/prompts";
|
|
import pc from "picocolors";
|
|
import { authenticate as awsAuthenticate, destroyServer as awsDestroyServer, ensureAwsCli } from "../aws/aws.js";
|
|
import { destroyServer as daytonaDestroyServer, ensureDaytonaToken } from "../daytona/daytona.js";
|
|
import { destroyServer as doDestroyServer, ensureDoToken } from "../digitalocean/digitalocean.js";
|
|
import {
|
|
authenticate as gcpAuthenticate,
|
|
destroyInstance as gcpDestroyInstance,
|
|
ensureGcloudCli as gcpEnsureGcloudCli,
|
|
resolveProject as gcpResolveProject,
|
|
} from "../gcp/gcp.js";
|
|
import { ensureHcloudToken, destroyServer as hetznerDestroyServer } from "../hetzner/hetzner.js";
|
|
import { getActiveServers, getHistoryPath, markRecordDeleted } from "../history.js";
|
|
import { loadManifest } from "../manifest.js";
|
|
import { validateMetadataValue, validateServerIdentifier } from "../security.js";
|
|
import { ensureSpriteAuthenticated, ensureSpriteCli, destroyServer as spriteDestroyServer } from "../sprite/sprite.js";
|
|
import { activeServerPicker, resolveListFilters } from "./list.js";
|
|
import { getErrorMessage, isInteractiveTTY } from "./shared.js";
|
|
|
|
/**
|
|
* Ensure credentials are available for a record's cloud provider.
|
|
* This may prompt the user interactively and must be called BEFORE
|
|
* starting any spinner to avoid overlapping UI elements.
|
|
*/
|
|
async function ensureDeleteCredentials(record: SpawnRecord): Promise<void> {
|
|
const conn = record.connection;
|
|
if (!conn?.cloud || conn.cloud === "local") {
|
|
return;
|
|
}
|
|
|
|
switch (conn.cloud) {
|
|
case "hetzner":
|
|
await ensureHcloudToken();
|
|
break;
|
|
case "digitalocean":
|
|
await ensureDoToken();
|
|
break;
|
|
case "gcp": {
|
|
const zone = conn.metadata?.zone || "us-central1-a";
|
|
const project = conn.metadata?.project || "";
|
|
validateMetadataValue(zone, "GCP zone");
|
|
if (project) {
|
|
validateMetadataValue(project, "GCP project");
|
|
}
|
|
process.env.GCP_ZONE = zone;
|
|
if (project) {
|
|
process.env.GCP_PROJECT = project;
|
|
}
|
|
await gcpEnsureGcloudCli();
|
|
await gcpAuthenticate();
|
|
break;
|
|
}
|
|
case "aws":
|
|
await ensureAwsCli();
|
|
await awsAuthenticate();
|
|
break;
|
|
case "daytona":
|
|
await ensureDaytonaToken();
|
|
break;
|
|
case "sprite":
|
|
await ensureSpriteCli();
|
|
await ensureSpriteAuthenticated();
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
/** Execute server deletion for a given record using TypeScript cloud modules */
|
|
async function execDeleteServer(record: SpawnRecord): Promise<boolean> {
|
|
const conn = record.connection;
|
|
if (!conn?.cloud || conn.cloud === "local") {
|
|
return false;
|
|
}
|
|
|
|
const id = conn.server_id || conn.server_name || "";
|
|
|
|
// SECURITY: Validate server ID to prevent command injection
|
|
// This protects against corrupted or tampered history files
|
|
try {
|
|
validateServerIdentifier(id);
|
|
} catch (err) {
|
|
throw new Error(
|
|
`Invalid server identifier in history: ${getErrorMessage(err)}\n\n` +
|
|
"Your spawn history file may be corrupted or tampered with.\n" +
|
|
`Location: ${getHistoryPath()}\n` +
|
|
"To fix: edit the file and remove the invalid entry, or run 'spawn list --clear'",
|
|
);
|
|
}
|
|
|
|
const isAlreadyGone = (msg: string) =>
|
|
msg.includes("404") || msg.includes("not found") || msg.includes("Not Found") || msg.includes("Could not find");
|
|
|
|
const tryDelete = async (deleteFn: () => Promise<void>): Promise<boolean> => {
|
|
try {
|
|
await deleteFn();
|
|
markRecordDeleted(record);
|
|
return true;
|
|
} catch (err) {
|
|
const errMsg = getErrorMessage(err);
|
|
if (isAlreadyGone(errMsg)) {
|
|
p.log.warn("Server already deleted or not found. Marking as deleted.");
|
|
markRecordDeleted(record);
|
|
return true;
|
|
}
|
|
p.log.error(`Delete failed: ${errMsg}`);
|
|
p.log.info("The server may still be running. Check your cloud provider dashboard.");
|
|
return false;
|
|
}
|
|
};
|
|
|
|
switch (conn.cloud) {
|
|
case "hetzner":
|
|
return tryDelete(async () => {
|
|
await ensureHcloudToken();
|
|
await hetznerDestroyServer(id);
|
|
});
|
|
|
|
case "digitalocean":
|
|
return tryDelete(async () => {
|
|
await ensureDoToken();
|
|
await doDestroyServer(id);
|
|
});
|
|
|
|
case "gcp": {
|
|
const zone = conn.metadata?.zone || "us-central1-a";
|
|
const project = conn.metadata?.project || "";
|
|
// SECURITY: Validate metadata values to prevent injection via tampered history
|
|
validateMetadataValue(zone, "GCP zone");
|
|
if (project) {
|
|
validateMetadataValue(project, "GCP project");
|
|
}
|
|
return tryDelete(async () => {
|
|
process.env.GCP_ZONE = zone;
|
|
if (project) {
|
|
process.env.GCP_PROJECT = project;
|
|
}
|
|
await gcpEnsureGcloudCli();
|
|
await gcpAuthenticate();
|
|
// Deletion runs under a spinner — suppress interactive prompts
|
|
const prevNonInteractive = process.env.SPAWN_NON_INTERACTIVE;
|
|
process.env.SPAWN_NON_INTERACTIVE = "1";
|
|
try {
|
|
await gcpResolveProject();
|
|
} finally {
|
|
if (prevNonInteractive === undefined) {
|
|
delete process.env.SPAWN_NON_INTERACTIVE;
|
|
} else {
|
|
process.env.SPAWN_NON_INTERACTIVE = prevNonInteractive;
|
|
}
|
|
}
|
|
await gcpDestroyInstance(id);
|
|
});
|
|
}
|
|
|
|
case "aws":
|
|
return tryDelete(async () => {
|
|
await ensureAwsCli();
|
|
await awsAuthenticate();
|
|
await awsDestroyServer(id);
|
|
});
|
|
|
|
case "daytona":
|
|
return tryDelete(async () => {
|
|
await ensureDaytonaToken();
|
|
await daytonaDestroyServer(id);
|
|
});
|
|
|
|
case "sprite":
|
|
return tryDelete(async () => {
|
|
await ensureSpriteCli();
|
|
await ensureSpriteAuthenticated();
|
|
await spriteDestroyServer(id);
|
|
});
|
|
|
|
default:
|
|
p.log.error(`No delete handler for cloud: ${conn.cloud}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Prompt for delete confirmation and execute. Returns true if deleted. */
|
|
export async function confirmAndDelete(record: SpawnRecord, manifest: Manifest | null): Promise<boolean> {
|
|
const conn = record.connection!;
|
|
const label = conn.server_name || conn.server_id || conn.ip;
|
|
const cloudLabel = manifest?.clouds[conn.cloud!]?.name || conn.cloud;
|
|
|
|
const confirmed = await p.confirm({
|
|
message: `Delete server "${label}" on ${cloudLabel}? This will permanently destroy the server and all data on it.`,
|
|
initialValue: false,
|
|
});
|
|
|
|
if (p.isCancel(confirmed) || !confirmed) {
|
|
p.log.info("Delete cancelled.");
|
|
return false;
|
|
}
|
|
|
|
// Ensure credentials before starting the spinner so interactive
|
|
// prompts (e.g. expired API key entry) don't overlap with it.
|
|
await ensureDeleteCredentials(record);
|
|
|
|
const s = p.spinner();
|
|
s.start(`Deleting ${label}...`);
|
|
|
|
const success = await execDeleteServer(record);
|
|
|
|
if (success) {
|
|
s.stop(`Server "${label}" deleted.`);
|
|
} else {
|
|
s.stop("Delete failed.");
|
|
}
|
|
return success;
|
|
}
|
|
|
|
export async function cmdDelete(agentFilter?: string, cloudFilter?: string): Promise<void> {
|
|
const resolved = await resolveListFilters(agentFilter, cloudFilter);
|
|
agentFilter = resolved.agentFilter;
|
|
cloudFilter = resolved.cloudFilter;
|
|
|
|
const servers = getActiveServers();
|
|
|
|
let filtered = servers;
|
|
if (agentFilter) {
|
|
const lower = agentFilter.toLowerCase();
|
|
filtered = filtered.filter((r) => r.agent.toLowerCase() === lower);
|
|
}
|
|
if (cloudFilter) {
|
|
const lower = cloudFilter.toLowerCase();
|
|
filtered = filtered.filter((r) => r.cloud.toLowerCase() === lower);
|
|
}
|
|
|
|
if (filtered.length === 0) {
|
|
p.log.info("No active servers to delete.");
|
|
if (servers.length > 0) {
|
|
p.log.info(
|
|
pc.dim(
|
|
`${servers.length} active server${servers.length !== 1 ? "s" : ""} found, but none matched your filters.`,
|
|
),
|
|
);
|
|
}
|
|
p.log.info(`Run ${pc.cyan("spawn <agent> <cloud>")} to create a spawn first.`);
|
|
return;
|
|
}
|
|
|
|
let manifest: Manifest | null = null;
|
|
try {
|
|
manifest = await loadManifest();
|
|
} catch {
|
|
// Manifest unavailable
|
|
}
|
|
|
|
if (!isInteractiveTTY()) {
|
|
p.log.error("spawn delete requires an interactive terminal.");
|
|
p.log.info(`Use ${pc.cyan("spawn list")} to see your servers.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
await activeServerPicker(filtered, manifest);
|
|
}
|