mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-20 01:11:18 +00:00
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:
parent
6ee81b7515
commit
245a2a46f9
7 changed files with 296 additions and 11 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "0.18.5",
|
||||
"version": "0.18.6",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue