feat: offer delete or remap when server is gone from cloud provider (#2641)

* feat: offer delete or remap when server is gone from cloud provider

When a user tries to connect to a server that no longer exists, instead
of silently marking it as deleted, present an interactive picker that
lets them remap the history entry to an existing instance on the same
cloud or explicitly remove it from history.

- Add listServers() to Hetzner, DigitalOcean, AWS, and GCP providers
- Add updateRecordConnection() to history for remapping server details
- Add handleGoneServer() interactive flow in list.ts
- Fall back to silent deletion in non-interactive mode (SPAWN_NON_INTERACTIVE)

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

* refactor: move InstancesListSchema to module level

Declare valibot schema at module top level per project convention,
not inside the listServers() function body.

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

* refactor: extract shared CloudInstance type from duplicated inline types

The { id, name, ip, status } shape was declared inline 9 times across
5 files. Extract it as a shared CloudInstance interface in history.ts
and import it in all cloud providers and list.ts.

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

---------

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
A 2026-03-14 17:05:51 -07:00 committed by GitHub
parent 6ee81b7515
commit 245a2a46f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 296 additions and 11 deletions

View file

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

View file

@ -1,6 +1,6 @@
// aws/aws.ts — Core AWS Lightsail provider: auth, provisioning, SSH execution
import type { VMConnection } from "../history.js";
import type { CloudInstance, VMConnection } from "../history.js";
import type { CloudInitTier } from "../shared/agents";
import { createHash, createHmac } from "node:crypto";
@ -229,6 +229,22 @@ const InstanceStateSchema = v.object({
}),
});
const InstancesListSchema = v.object({
instances: v.optional(
v.array(
v.object({
name: v.string(),
publicIpAddress: v.optional(v.string()),
state: v.optional(
v.object({
name: v.string(),
}),
),
}),
),
),
});
// ─── AWS CLI Wrapper ────────────────────────────────────────────────────────
function awsCliSync(args: string[]): {
@ -1238,6 +1254,29 @@ export async function getServerIp(instanceName: string): Promise<string | null>
return ip || null;
}
/** List all Lightsail instances. Returns simplified instance info for the remap picker. */
export async function listServers(): Promise<CloudInstance[]> {
let resp: string;
if (_state.lightsailMode === "cli") {
resp = await awsCli([
"lightsail",
"get-instances",
"--output",
"json",
]);
} else {
resp = await lightsailRest("Lightsail_20161128.GetInstances");
}
const data = parseJsonWith(resp, InstancesListSchema);
const instances = data?.instances ?? [];
return instances.map((inst) => ({
id: inst.name,
name: inst.name,
ip: inst.publicIpAddress ?? "",
status: inst.state?.name ?? "",
}));
}
export async function destroyServer(name?: string): Promise<void> {
const target = name || _state.instanceName;
if (!target) {

View file

@ -1,5 +1,5 @@
import type { ValueOf } from "@openrouter/spawn-shared";
import type { SpawnRecord } from "../history.js";
import type { CloudInstance, SpawnRecord } from "../history.js";
import type { Manifest } from "../manifest.js";
import * as p from "@clack/prompts";
@ -10,6 +10,7 @@ import {
getActiveServers,
markRecordDeleted,
removeRecord,
updateRecordConnection,
updateRecordIp,
} from "../history.js";
import { agentKeys, cloudKeys, loadManifest } from "../manifest.js";
@ -249,6 +250,131 @@ export async function resolveListFilters(
};
}
// ── Gone server handling ────────────────────────────────────────────────────
/** Fetch live instances from a cloud provider. */
async function fetchCloudInstances(cloud: string, record: SpawnRecord): Promise<CloudInstance[]> {
switch (cloud) {
case "hetzner": {
const { listServers } = await import("../hetzner/hetzner.js");
return listServers();
}
case "digitalocean": {
const { listServers } = await import("../digitalocean/digitalocean.js");
return listServers();
}
case "aws": {
const { listServers } = await import("../aws/aws.js");
return listServers();
}
case "gcp": {
const zone = record.connection?.metadata?.zone || "us-central1-a";
const project = record.connection?.metadata?.project || "";
if (!project) {
return [];
}
const { listServers } = await import("../gcp/gcp.js");
return listServers(zone, project);
}
default:
return [];
}
}
/**
* Handle a server that no longer exists on the cloud provider.
* Offers the user a choice: remap to an existing instance, delete from history, or cancel.
* In non-interactive mode, falls back to silent deletion (previous behavior).
*/
async function handleGoneServer(record: SpawnRecord, cloud: string): Promise<"deleted" | "remapped" | "cancelled"> {
p.log.warn("Server no longer exists on the cloud provider.");
// Non-interactive: fall back to silent deletion
if (process.env.SPAWN_NON_INTERACTIVE === "1" || !isInteractiveTTY()) {
markRecordDeleted(record);
if (record.connection) {
record.connection.deleted = true;
}
return "deleted";
}
// Try to fetch live instances
const instancesResult = await asyncTryCatch(() => fetchCloudInstances(cloud, record));
const instances = instancesResult.ok ? instancesResult.data : [];
const options: {
value: string;
label: string;
hint?: string;
}[] = [];
for (let i = 0; i < instances.length; i++) {
const inst = instances[i];
options.push({
value: `remap-${i}`,
label: `${inst.name} (${inst.ip || "no IP"})`,
hint: inst.status,
});
}
options.push({
value: "delete",
label: "Remove from history",
hint: "mark this entry as deleted",
});
options.push({
value: "cancel",
label: "Cancel",
hint: "go back without changes",
});
const action = await p.select({
message:
instances.length > 0
? "Remap to an existing instance or remove from history?"
: "No live instances found. What would you like to do?",
options,
});
if (p.isCancel(action) || action === "cancel") {
return "cancelled";
}
if (action === "delete") {
markRecordDeleted(record);
if (record.connection) {
record.connection.deleted = true;
}
p.log.success("Removed from history.");
return "deleted";
}
// Remap to selected instance
const actionStr = String(action);
if (actionStr.startsWith("remap-")) {
const idx = Number.parseInt(action.slice(6), 10);
const inst = instances[idx];
if (inst) {
updateRecordConnection(record, {
ip: inst.ip,
server_id: inst.id,
server_name: inst.name,
});
// Update in-memory connection too
if (record.connection) {
record.connection.ip = inst.ip;
record.connection.server_id = inst.id;
record.connection.server_name = inst.name;
}
p.log.success(`Remapped to ${inst.name} (${inst.ip})`);
return "remapped";
}
}
return "cancelled";
}
// ── IP refresh ──────────────────────────────────────────────────────────────
/**
@ -321,11 +447,10 @@ async function refreshConnectionIp(record: SpawnRecord): Promise<"ok" | "gone" |
}
if (currentIp === null) {
// Server no longer exists
p.log.warn("Server no longer exists on the cloud provider.");
markRecordDeleted(record);
if (conn) {
conn.deleted = true;
// Server no longer exists — let user decide
const result = await handleGoneServer(record, conn.cloud);
if (result === "remapped") {
return "ok";
}
return "gone";
}

View file

@ -1,6 +1,6 @@
// digitalocean/digitalocean.ts — Core DigitalOcean provider: API, auth, SSH, provisioning
import type { VMConnection } from "../history.js";
import type { CloudInstance, VMConnection } from "../history.js";
import type { CloudInitTier } from "../shared/agents";
import { mkdirSync, readFileSync } from "node:fs";
@ -1463,6 +1463,26 @@ export async function getServerIp(dropletId: string): Promise<string | null> {
return publicNet?.ip_address && isString(publicNet.ip_address) ? publicNet.ip_address : null;
}
/** List all DigitalOcean droplets. Returns simplified instance info for the remap picker. */
export async function listServers(): Promise<CloudInstance[]> {
const resp = await doApi("GET", "/droplets");
const data = parseJsonObj(resp);
const droplets = toObjectArray(data?.droplets);
const results: CloudInstance[] = [];
for (const d of droplets) {
const v4Networks = toObjectArray(d?.networks?.v4);
const publicNet = v4Networks.find((n) => n.type === "public");
const ip = publicNet?.ip_address && isString(publicNet.ip_address) ? publicNet.ip_address : "";
results.push({
id: String(d.id ?? ""),
name: isString(d.name) ? d.name : "",
ip,
status: isString(d.status) ? d.status : "",
});
}
return results;
}
export async function destroyServer(dropletId?: string): Promise<void> {
const id = dropletId || _state.dropletId;
if (!id) {

View file

@ -1,10 +1,11 @@
// gcp/gcp.ts — Core GCP Compute Engine provider: gcloud CLI wrapper, auth, provisioning, SSH
import type { VMConnection } from "../history.js";
import type { CloudInstance, VMConnection } from "../history.js";
import type { CloudInitTier } from "../shared/agents";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { isString, toObjectArray } from "@openrouter/spawn-shared";
import { handleBillingError, isBillingError, showNonBillingError } from "../shared/billing-guidance";
import { getPackagesForTier, NODE_INSTALL_CMD, needsBun, needsNode } from "../shared/cloud-init";
import { getUserHome } from "../shared/paths";
@ -1119,6 +1120,47 @@ export async function getServerIp(instanceName: string, zone: string, project: s
return ip || null;
}
/** List all GCP instances in the current project/zone. Returns simplified instance info for the remap picker. */
export async function listServers(zone: string, project: string): Promise<CloudInstance[]> {
const result = await gcloud([
"compute",
"instances",
"list",
`--project=${project}`,
`--zones=${zone}`,
"--format=json(name,networkInterfaces[0].accessConfigs[0].natIP,status)",
]);
if (result.exitCode !== 0) {
return [];
}
const parsed = tryCatch((): unknown => JSON.parse(result.stdout));
if (!parsed.ok || !Array.isArray(parsed.data)) {
return [];
}
const items = toObjectArray(parsed.data);
const results: CloudInstance[] = [];
for (const item of items) {
const name = isString(item.name) ? item.name : "";
const status = isString(item.status) ? item.status : "";
// GCP nested: networkInterfaces[0].accessConfigs[0].natIP
let ip = "";
const ni = toObjectArray(item.networkInterfaces)[0];
if (ni) {
const ac = toObjectArray(ni.accessConfigs)[0];
if (ac) {
ip = isString(ac.natIP) ? ac.natIP : "";
}
}
results.push({
id: name,
name,
ip,
status,
});
}
return results;
}
export async function destroyInstance(name?: string): Promise<void> {
const instanceName = name || _state.instanceName;
const zone = _state.zone || process.env.GCP_ZONE || DEFAULT_ZONE;

View file

@ -1,6 +1,6 @@
// hetzner/hetzner.ts — Core Hetzner Cloud provider: API, auth, SSH, provisioning
import type { VMConnection } from "../history.js";
import type { CloudInstance, VMConnection } from "../history.js";
import type { CloudInitTier } from "../shared/agents";
import { mkdirSync, readFileSync } from "node:fs";
@ -760,6 +760,26 @@ export async function getServerIp(serverId: string): Promise<string | null> {
return isString(ipv4?.ip) ? ipv4.ip : null;
}
/** List all Hetzner servers. Returns simplified instance info for the remap picker. */
export async function listServers(): Promise<CloudInstance[]> {
const resp = await hetznerApi("GET", "/servers");
const data = parseJsonObj(resp);
const servers = toObjectArray(data?.servers);
const results: CloudInstance[] = [];
for (const s of servers) {
const publicNet = toRecord(s.public_net);
const ipv4 = toRecord(publicNet?.ipv4);
const ip = isString(ipv4?.ip) ? ipv4.ip : "";
results.push({
id: String(s.id ?? ""),
name: isString(s.name) ? s.name : "",
ip,
status: isString(s.status) ? s.status : "",
});
}
return results;
}
export async function destroyServer(serverId?: string): Promise<void> {
const id = serverId || _state.serverId;
if (!id) {

View file

@ -38,6 +38,14 @@ export interface SpawnRecord {
connection?: VMConnection;
}
/** Simplified cloud instance info returned by each provider's listServers(). */
export interface CloudInstance {
id: string;
name: string;
ip: string;
status: string;
}
// ── Schema versioning ──────────────────────────────────────────────────────
export const HISTORY_SCHEMA_VERSION = 1;
@ -432,6 +440,37 @@ export function updateRecordIp(record: SpawnRecord, newIp: string): boolean {
return true;
}
/** Update connection fields (ip, server_id, server_name) on a history record. Used for remapping to a different instance. */
export function updateRecordConnection(
record: SpawnRecord,
updates: {
ip?: string;
server_id?: string;
server_name?: string;
},
): boolean {
const history = loadHistory();
const index = findRecordIndex(history, record);
if (index < 0) {
return false;
}
const found = history[index];
if (!found.connection) {
return false;
}
if (updates.ip !== undefined) {
found.connection.ip = updates.ip;
}
if (updates.server_id !== undefined) {
found.connection.server_id = updates.server_id;
}
if (updates.server_name !== undefined) {
found.connection.server_name = updates.server_name;
}
writeHistory(history);
return true;
}
export function getActiveServers(): SpawnRecord[] {
const records = loadHistory();
return records.filter((r) => r.connection?.cloud && r.connection.cloud !== "local" && !r.connection.deleted);