mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 11:59:29 +00:00
refactor: replace module-level mutable globals with typed state objects in cloud providers (#2255)
Each cloud module (aws, daytona, digitalocean, gcp, hetzner, sprite) previously stored per-operation state in bare module-level `let` variables, making them process-global singletons. This is safe for single-cloud CLI invocations today but creates latent bugs for multi-cloud orchestration and test isolation. Replace scattered `let` globals with a single typed `_state` object per module: - `AwsState` / `resetAwsState()` — 8 fields including `selectedBundle` - `DaytonaState` / `resetDaytonaState()` — 5 fields - `DigitalOceanState` / `resetDigitalOceanState()` — 3 fields - `GcpState` / `resetGcpState()` — 5 fields - `HetznerState` / `resetHetznerState()` — 3 fields - `SpriteState` / `resetSpriteState()` — 2 fields Each module exports a `resetXxxState()` function for test isolation. No function signatures or existing exports were changed. Fixes #2251 Agent: issue-fixer Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
141254c4e1
commit
f862ee563e
6 changed files with 332 additions and 213 deletions
|
|
@ -39,9 +39,27 @@ const HETZNER_API_BASE = "https://api.hetzner.cloud/v1";
|
|||
const HETZNER_DASHBOARD_URL = "https://console.hetzner.cloud/";
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────────────
|
||||
let hcloudToken = "";
|
||||
let hetznerServerId = "";
|
||||
let hetznerServerIp = "";
|
||||
|
||||
export interface HetznerState {
|
||||
hcloudToken: string;
|
||||
serverId: string;
|
||||
serverIp: string;
|
||||
}
|
||||
|
||||
let _state: HetznerState = {
|
||||
hcloudToken: "",
|
||||
serverId: "",
|
||||
serverIp: "",
|
||||
};
|
||||
|
||||
/** Reset session state — used in tests for isolation. */
|
||||
export function resetHetznerState(): void {
|
||||
_state = {
|
||||
hcloudToken: "",
|
||||
serverId: "",
|
||||
serverIp: "",
|
||||
};
|
||||
}
|
||||
|
||||
// ─── API Client ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -53,7 +71,7 @@ async function hetznerApi(method: string, endpoint: string, body?: string, maxRe
|
|||
try {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${hcloudToken}`,
|
||||
Authorization: `Bearer ${_state.hcloudToken}`,
|
||||
};
|
||||
const opts: RequestInit = {
|
||||
method,
|
||||
|
|
@ -108,7 +126,7 @@ async function saveTokenToConfig(token: string): Promise<void> {
|
|||
// ─── Token Validation ────────────────────────────────────────────────────────
|
||||
|
||||
async function testHcloudToken(): Promise<boolean> {
|
||||
if (!hcloudToken) {
|
||||
if (!_state.hcloudToken) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
|
|
@ -131,26 +149,26 @@ async function testHcloudToken(): Promise<boolean> {
|
|||
export async function ensureHcloudToken(): Promise<void> {
|
||||
// 1. Env var
|
||||
if (process.env.HCLOUD_TOKEN) {
|
||||
hcloudToken = process.env.HCLOUD_TOKEN.trim();
|
||||
_state.hcloudToken = process.env.HCLOUD_TOKEN.trim();
|
||||
if (await testHcloudToken()) {
|
||||
logInfo("Using Hetzner Cloud token from environment");
|
||||
await saveTokenToConfig(hcloudToken);
|
||||
await saveTokenToConfig(_state.hcloudToken);
|
||||
return;
|
||||
}
|
||||
logWarn("HCLOUD_TOKEN from environment is invalid");
|
||||
hcloudToken = "";
|
||||
_state.hcloudToken = "";
|
||||
}
|
||||
|
||||
// 2. Saved config
|
||||
const saved = loadApiToken("hetzner");
|
||||
if (saved) {
|
||||
hcloudToken = saved;
|
||||
_state.hcloudToken = saved;
|
||||
if (await testHcloudToken()) {
|
||||
logInfo("Using saved Hetzner Cloud token");
|
||||
return;
|
||||
}
|
||||
logWarn("Saved Hetzner token is invalid or expired");
|
||||
hcloudToken = "";
|
||||
_state.hcloudToken = "";
|
||||
}
|
||||
|
||||
// 3. Manual entry
|
||||
|
|
@ -163,14 +181,14 @@ export async function ensureHcloudToken(): Promise<void> {
|
|||
logError("Token cannot be empty");
|
||||
continue;
|
||||
}
|
||||
hcloudToken = token.trim();
|
||||
_state.hcloudToken = token.trim();
|
||||
if (await testHcloudToken()) {
|
||||
await saveTokenToConfig(hcloudToken);
|
||||
await saveTokenToConfig(_state.hcloudToken);
|
||||
logInfo("Hetzner Cloud token validated and saved");
|
||||
return;
|
||||
}
|
||||
logError("Token is invalid");
|
||||
hcloudToken = "";
|
||||
_state.hcloudToken = "";
|
||||
}
|
||||
|
||||
logError("No valid token after 3 attempts");
|
||||
|
|
@ -413,25 +431,25 @@ export async function createServer(
|
|||
throw new Error(`Server creation failed: ${errMsg}`);
|
||||
}
|
||||
|
||||
hetznerServerId = String(server.id);
|
||||
_state.serverId = String(server.id);
|
||||
const publicNet = toRecord(server.public_net);
|
||||
const ipv4 = toRecord(publicNet?.ipv4);
|
||||
hetznerServerIp = isString(ipv4?.ip) ? ipv4.ip : "";
|
||||
_state.serverIp = isString(ipv4?.ip) ? ipv4.ip : "";
|
||||
|
||||
if (!hetznerServerId || hetznerServerId === "null") {
|
||||
if (!_state.serverId || _state.serverId === "null") {
|
||||
logError("Failed to extract server ID from API response");
|
||||
throw new Error("No server ID");
|
||||
}
|
||||
if (!hetznerServerIp || hetznerServerIp === "null") {
|
||||
if (!_state.serverIp || _state.serverIp === "null") {
|
||||
logError("Failed to extract server IP from API response");
|
||||
throw new Error("No server IP");
|
||||
}
|
||||
|
||||
logInfo(`Server created: ID=${hetznerServerId}, IP=${hetznerServerIp}`);
|
||||
logInfo(`Server created: ID=${_state.serverId}, IP=${_state.serverIp}`);
|
||||
saveVmConnection(
|
||||
hetznerServerIp,
|
||||
_state.serverIp,
|
||||
"root",
|
||||
hetznerServerId,
|
||||
_state.serverId,
|
||||
name,
|
||||
"hetzner",
|
||||
undefined,
|
||||
|
|
@ -443,7 +461,7 @@ export async function createServer(
|
|||
// ─── SSH Execution ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function waitForCloudInit(ip?: string, _maxAttempts = 60): Promise<void> {
|
||||
const serverIp = ip || hetznerServerIp;
|
||||
const serverIp = ip || _state.serverIp;
|
||||
const selectedKeys = await ensureSshKeys();
|
||||
const keyOpts = getSshKeyOpts(selectedKeys);
|
||||
await sharedWaitForSsh({
|
||||
|
|
@ -497,7 +515,7 @@ export async function waitForCloudInit(ip?: string, _maxAttempts = 60): Promise<
|
|||
}
|
||||
|
||||
export async function runServer(cmd: string, timeoutSecs?: number, ip?: string): Promise<void> {
|
||||
const serverIp = ip || hetznerServerIp;
|
||||
const serverIp = ip || _state.serverIp;
|
||||
const fullCmd = `export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH" && ${cmd}`;
|
||||
const keyOpts = getSshKeyOpts(await ensureSshKeys());
|
||||
|
||||
|
|
@ -531,7 +549,7 @@ export async function runServer(cmd: string, timeoutSecs?: number, ip?: string):
|
|||
}
|
||||
|
||||
export async function runServerCapture(cmd: string, timeoutSecs?: number, ip?: string): Promise<string> {
|
||||
const serverIp = ip || hetznerServerIp;
|
||||
const serverIp = ip || _state.serverIp;
|
||||
const fullCmd = `export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH" && ${cmd}`;
|
||||
const keyOpts = getSshKeyOpts(await ensureSshKeys());
|
||||
|
||||
|
|
@ -571,7 +589,7 @@ export async function runServerCapture(cmd: string, timeoutSecs?: number, ip?: s
|
|||
}
|
||||
|
||||
export async function uploadFile(localPath: string, remotePath: string, ip?: string): Promise<void> {
|
||||
const serverIp = ip || hetznerServerIp;
|
||||
const serverIp = ip || _state.serverIp;
|
||||
if (
|
||||
!/^[a-zA-Z0-9/_.~-]+$/.test(remotePath) ||
|
||||
remotePath.includes("..") ||
|
||||
|
|
@ -606,7 +624,7 @@ export async function uploadFile(localPath: string, remotePath: string, ip?: str
|
|||
}
|
||||
|
||||
export async function interactiveSession(cmd: string, ip?: string): Promise<number> {
|
||||
const serverIp = ip || hetznerServerIp;
|
||||
const serverIp = ip || _state.serverIp;
|
||||
const term = sanitizeTermValue(process.env.TERM || "xterm-256color");
|
||||
// Single-quote escaping prevents premature shell expansion of $variables in cmd
|
||||
const shellEscapedCmd = cmd.replace(/'/g, "'\\''");
|
||||
|
|
@ -624,7 +642,7 @@ export async function interactiveSession(cmd: string, ip?: string): Promise<numb
|
|||
|
||||
// Post-session summary
|
||||
process.stderr.write("\n");
|
||||
logWarn(`Session ended. Your Hetzner server (ID: ${hetznerServerId}) is still running.`);
|
||||
logWarn(`Session ended. Your Hetzner server (ID: ${_state.serverId}) 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:");
|
||||
|
|
@ -679,7 +697,7 @@ export async function promptSpawnName(): Promise<void> {
|
|||
// ─── Lifecycle ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function destroyServer(serverId?: string): Promise<void> {
|
||||
const id = serverId || hetznerServerId;
|
||||
const id = serverId || _state.serverId;
|
||||
if (!id) {
|
||||
logError("destroy_server: no server ID provided");
|
||||
throw new Error("No server ID");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue