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:
A 2026-03-06 15:11:46 -08:00 committed by GitHub
parent 141254c4e1
commit f862ee563e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 332 additions and 213 deletions

View file

@ -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");