diff --git a/packages/cli/src/aws/aws.ts b/packages/cli/src/aws/aws.ts index fbfedd49..cf12e86b 100644 --- a/packages/cli/src/aws/aws.ts +++ b/packages/cli/src/aws/aws.ts @@ -166,20 +166,48 @@ export const REGIONS: Region[] = [ // ─── State ────────────────────────────────────────────────────────────────── -let awsAccessKeyId = ""; -let awsSecretAccessKey = ""; -let awsSessionToken = ""; -let awsRegion = "us-east-1"; -let lightsailMode: "cli" | "rest" = "cli"; -let instanceName = ""; -let instanceIp = ""; +export interface AwsState { + accessKeyId: string; + secretAccessKey: string; + sessionToken: string; + region: string; + lightsailMode: "cli" | "rest"; + instanceName: string; + instanceIp: string; + selectedBundle: string; +} + +let _state: AwsState = { + accessKeyId: "", + secretAccessKey: "", + sessionToken: "", + region: "us-east-1", + lightsailMode: "cli", + instanceName: "", + instanceIp: "", + selectedBundle: DEFAULT_BUNDLE.id, +}; + +/** Reset session state — used in tests for isolation. */ +export function resetAwsState(): void { + _state = { + accessKeyId: "", + secretAccessKey: "", + sessionToken: "", + region: "us-east-1", + lightsailMode: "cli", + instanceName: "", + instanceIp: "", + selectedBundle: DEFAULT_BUNDLE.id, + }; +} export function getState() { return { - awsRegion, - lightsailMode, - instanceName, - instanceIp, + awsRegion: _state.region, + lightsailMode: _state.lightsailMode, + instanceName: _state.instanceName, + instanceIp: _state.instanceIp, }; } @@ -256,11 +284,11 @@ async function awsCli(args: string[]): Promise { // ─── SigV4 REST API ───────────────────────────────────────────────────────── async function lightsailRest(target: string, body = "{}"): Promise { - if (!awsAccessKeyId || !awsSecretAccessKey) { + if (!_state.accessKeyId || !_state.secretAccessKey) { throw new Error("AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must be set for REST API calls"); } - const region = awsRegion; + const region = _state.region; const service = "lightsail"; const host = `lightsail.${region}.amazonaws.com`; @@ -291,14 +319,14 @@ async function lightsailRest(target: string, body = "{}"): Promise { "x-amz-date", amzDate, ], - ...(awsSessionToken + ...(_state.sessionToken ? (() => { const tokenHeader: [ string, string, ] = [ "x-amz-security-token", - awsSessionToken, + _state.sessionToken, ]; return [ tokenHeader, @@ -326,13 +354,13 @@ async function lightsailRest(target: string, body = "{}"): Promise { const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`; const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${credentialScope}\n${sha256(canonicalRequest)}`; - const kDate = hmac(`AWS4${awsSecretAccessKey}`, dateStamp); + const kDate = hmac(`AWS4${_state.secretAccessKey}`, dateStamp); const kRegion = hmac(kDate, region); const kService = hmac(kRegion, service); const kSigning = hmac(kService, "aws4_request"); const sig = hmac(kSigning, stringToSign).toString("hex"); - const authHeader = `AWS4-HMAC-SHA256 Credential=${awsAccessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${sig}`; + const authHeader = `AWS4-HMAC-SHA256 Credential=${_state.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${sig}`; const reqHeaders: Record = Object.fromEntries(allHeaders.filter(([k]) => k !== "host")); reqHeaders["Authorization"] = authHeader; @@ -492,7 +520,7 @@ export async function authenticate(): Promise { if (!validateRegionName(region)) { throw new Error(`Invalid AWS region: ${region}. Must match /^[a-zA-Z0-9_-]{1,63}$/`); } - awsRegion = region; + _state.region = region; const skipCache = process.env.SPAWN_REAUTH === "1"; // 1. Try existing CLI with valid credentials @@ -502,7 +530,7 @@ export async function authenticate(): Promise { "get-caller-identity", ]); if (result.exitCode === 0) { - lightsailMode = "cli"; + _state.lightsailMode = "cli"; process.env.AWS_DEFAULT_REGION = region; logInfo(`AWS CLI ready, using region: ${region}`); return; @@ -512,20 +540,20 @@ export async function authenticate(): Promise { // 2. Check env vars for REST mode if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) { - awsAccessKeyId = process.env.AWS_ACCESS_KEY_ID; - awsSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; - awsSessionToken = process.env.AWS_SESSION_TOKEN || ""; + _state.accessKeyId = process.env.AWS_ACCESS_KEY_ID; + _state.secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; + _state.sessionToken = process.env.AWS_SESSION_TOKEN || ""; if (hasAwsCli()) { - lightsailMode = "cli"; + _state.lightsailMode = "cli"; process.env.AWS_DEFAULT_REGION = region; - await saveCredsToConfig(awsAccessKeyId, awsSecretAccessKey, region); + await saveCredsToConfig(_state.accessKeyId, _state.secretAccessKey, region); logInfo(`AWS CLI ready with env credentials, using region: ${region}`); return; } - lightsailMode = "rest"; - await saveCredsToConfig(awsAccessKeyId, awsSecretAccessKey, region); + _state.lightsailMode = "rest"; + await saveCredsToConfig(_state.accessKeyId, _state.secretAccessKey, region); logInfo("AWS CLI not available \u2014 using Lightsail REST API directly"); logInfo(`Using region: ${region}`); return; @@ -542,9 +570,9 @@ export async function authenticate(): Promise { process.env.AWS_ACCESS_KEY_ID = cached.accessKeyId; process.env.AWS_SECRET_ACCESS_KEY = cached.secretAccessKey; process.env.AWS_DEFAULT_REGION = cachedRegion; - awsRegion = cachedRegion; - awsAccessKeyId = cached.accessKeyId; - awsSecretAccessKey = cached.secretAccessKey; + _state.region = cachedRegion; + _state.accessKeyId = cached.accessKeyId; + _state.secretAccessKey = cached.secretAccessKey; if (hasAwsCli()) { const result = awsCliSync([ @@ -552,17 +580,17 @@ export async function authenticate(): Promise { "get-caller-identity", ]); if (result.exitCode === 0) { - lightsailMode = "cli"; + _state.lightsailMode = "cli"; logInfo(`AWS CLI ready with credentials cached by spawn. Using region: ${cachedRegion}`); return; } logWarn("Credentials cached by spawn are invalid or expired"); - awsAccessKeyId = ""; - awsSecretAccessKey = ""; + _state.accessKeyId = ""; + _state.secretAccessKey = ""; delete process.env.AWS_ACCESS_KEY_ID; delete process.env.AWS_SECRET_ACCESS_KEY; } else { - lightsailMode = "rest"; + _state.lightsailMode = "rest"; logInfo("Using cached AWS credentials with Lightsail REST API"); logInfo(`Using region: ${cachedRegion}`); return; @@ -593,8 +621,8 @@ export async function authenticate(): Promise { process.env.AWS_ACCESS_KEY_ID = accessKey; process.env.AWS_SECRET_ACCESS_KEY = secretKey; process.env.AWS_DEFAULT_REGION = region; - awsAccessKeyId = accessKey; - awsSecretAccessKey = secretKey; + _state.accessKeyId = accessKey; + _state.secretAccessKey = secretKey; if (hasAwsCli()) { const result = awsCliSync([ @@ -602,14 +630,14 @@ export async function authenticate(): Promise { "get-caller-identity", ]); if (result.exitCode === 0) { - lightsailMode = "cli"; + _state.lightsailMode = "cli"; await saveCredsToConfig(accessKey, secretKey, region); logInfo(`AWS CLI configured, using region: ${region}`); return; } } - lightsailMode = "rest"; + _state.lightsailMode = "rest"; await saveCredsToConfig(accessKey, secretKey, region); logInfo("Using Lightsail REST API directly"); logInfo(`Using region: ${region}`); @@ -623,7 +651,7 @@ export async function promptRegion(): Promise { if (!validateRegionName(envRegion)) { throw new Error(`Invalid AWS region: ${envRegion}. Must match /^[a-zA-Z0-9_-]{1,63}$/`); } - awsRegion = envRegion; + _state.region = envRegion; return; } if (process.env.SPAWN_CUSTOM !== "1") { @@ -636,18 +664,16 @@ export async function promptRegion(): Promise { process.stderr.write("\n"); const items = REGIONS.map((r) => `${r.id}|${r.label}`); const selected = await selectFromList(items, "AWS region", "us-east-1"); - awsRegion = selected; + _state.region = selected; process.env.AWS_DEFAULT_REGION = selected; logInfo(`Using region: ${selected}`); } // ─── Bundle Prompt ────────────────────────────────────────────────────────── -let selectedBundle = DEFAULT_BUNDLE.id; - export async function promptBundle(agentName?: string): Promise { if (process.env.LIGHTSAIL_BUNDLE) { - selectedBundle = process.env.LIGHTSAIL_BUNDLE; + _state.selectedBundle = process.env.LIGHTSAIL_BUNDLE; return; } @@ -656,14 +682,14 @@ export async function promptBundle(agentName?: string): Promise { const defaultId = agentDefault ?? DEFAULT_BUNDLE.id; if (process.env.SPAWN_NON_INTERACTIVE === "1") { - selectedBundle = defaultId; + _state.selectedBundle = defaultId; return; } process.stderr.write("\n"); const items = BUNDLES.map((b) => `${b.id}|${b.label}`); const selected = await selectFromList(items, "instance size", defaultId); - selectedBundle = selected; + _state.selectedBundle = selected; logInfo(`Using bundle: ${selected}`); } @@ -682,7 +708,7 @@ export async function ensureSshKey(): Promise { const keyName = "spawn-key"; const pubKey = readFileSync(pubPath, "utf-8").trim(); - if (lightsailMode === "cli") { + if (_state.lightsailMode === "cli") { // Check if already registered const check = awsCliSync([ "lightsail", @@ -815,8 +841,8 @@ function getCloudInitUserdata(tier: CloudInitTier = "full"): string { // ─── Provisioning ─────────────────────────────────────────────────────────── export async function createInstance(name: string, tier?: CloudInitTier): Promise { - const bundle = selectedBundle; - const region = awsRegion; + const bundle = _state.selectedBundle; + const region = _state.region; const az = `${region}a`; const blueprint = "ubuntu_24_04"; @@ -828,7 +854,7 @@ export async function createInstance(name: string, tier?: CloudInitTier): Promis const userdata = getCloudInitUserdata(tier); - if (lightsailMode === "cli") { + if (_state.lightsailMode === "cli") { try { await awsCli([ "lightsail", @@ -883,7 +909,7 @@ export async function createInstance(name: string, tier?: CloudInitTier): Promis } } - instanceName = name; + _state.instanceName = name; logInfo(`Instance creation initiated: ${name}`); } @@ -898,12 +924,12 @@ export async function waitForInstance(maxAttempts = 60): Promise { let ip = ""; try { - if (lightsailMode === "cli") { + if (_state.lightsailMode === "cli") { const resp = await awsCli([ "lightsail", "get-instance", "--instance-name", - instanceName, + _state.instanceName, "--query", "instance.state.name", "--output", @@ -914,7 +940,7 @@ export async function waitForInstance(maxAttempts = 60): Promise { const resp = await lightsailRest( "Lightsail_20161128.GetInstance", JSON.stringify({ - instanceName, + instanceName: _state.instanceName, }), ); const data = parseJsonWith(resp, InstanceStateSchema); @@ -926,12 +952,12 @@ export async function waitForInstance(maxAttempts = 60): Promise { if (state === "running") { try { - if (lightsailMode === "cli") { + if (_state.lightsailMode === "cli") { ip = await awsCli([ "lightsail", "get-instance", "--instance-name", - instanceName, + _state.instanceName, "--query", "instance.publicIpAddress", "--output", @@ -941,7 +967,7 @@ export async function waitForInstance(maxAttempts = 60): Promise { const resp = await lightsailRest( "Lightsail_20161128.GetInstance", JSON.stringify({ - instanceName, + instanceName: _state.instanceName, }), ); const data = parseJsonWith(resp, InstanceStateSchema); @@ -951,16 +977,16 @@ export async function waitForInstance(maxAttempts = 60): Promise { // ignore } - instanceIp = ip.trim(); + _state.instanceIp = ip.trim(); logStepDone(); - logInfo(`Instance running: IP=${instanceIp}`); + logInfo(`Instance running: IP=${_state.instanceIp}`); // Save connection info saveVmConnection( - instanceIp, + _state.instanceIp, SSH_USER, "", - instanceName, + _state.instanceName, "aws", undefined, undefined, @@ -983,7 +1009,7 @@ export async function waitForInstance(maxAttempts = 60): Promise { async function waitForSsh(maxAttempts = 36): Promise { const keyOpts = getSshKeyOpts(await ensureSshKeys()); await sharedWaitForSsh({ - host: instanceIp, + host: _state.instanceIp, user: SSH_USER, maxAttempts, extraSshOpts: keyOpts, @@ -1002,7 +1028,7 @@ export async function waitForCloudInit(maxAttempts = 60): Promise { "ssh", ...SSH_BASE_OPTS, ...keyOpts, - `${SSH_USER}@${instanceIp}`, + `${SSH_USER}@${_state.instanceIp}`, "test -f /home/ubuntu/.cloud-init-complete && echo done", ], { @@ -1043,7 +1069,7 @@ export async function runServer(cmd: string, timeoutSecs?: number): Promise { "ssh", ...SSH_INTERACTIVE_OPTS, ...keyOpts, - `${SSH_USER}@${instanceIp}`, + `${SSH_USER}@${_state.instanceIp}`, fullCmd, ]); // Post-session summary process.stderr.write("\n"); - logWarn(`Session ended. Your Lightsail instance '${instanceName}' is still running.`); + logWarn(`Session ended. Your Lightsail instance '${_state.instanceName}' 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:"); @@ -1160,7 +1186,7 @@ export async function interactiveSession(cmd: string): Promise { logInfo("To delete from CLI:"); logInfo(" spawn delete"); logInfo("To reconnect:"); - logInfo(` ssh ${SSH_USER}@${instanceIp}`); + logInfo(` ssh ${SSH_USER}@${_state.instanceIp}`); return exitCode; } @@ -1206,14 +1232,14 @@ export async function promptSpawnName(): Promise { // ─── Lifecycle ────────────────────────────────────────────────────────────── export async function destroyServer(name?: string): Promise { - const target = name || instanceName; + const target = name || _state.instanceName; if (!target) { throw new Error("destroy_server: no instance name provided"); } logStep(`Destroying Lightsail instance '${target}'...`); - if (lightsailMode === "cli") { + if (_state.lightsailMode === "cli") { try { await awsCli([ "lightsail", diff --git a/packages/cli/src/daytona/daytona.ts b/packages/cli/src/daytona/daytona.ts index 86b99087..f2dc12a9 100644 --- a/packages/cli/src/daytona/daytona.ts +++ b/packages/cli/src/daytona/daytona.ts @@ -31,11 +31,32 @@ const DAYTONA_DASHBOARD_URL = "https://app.daytona.io/"; // ─── State ─────────────────────────────────────────────────────────────────── -let daytonaApiKey = ""; -let sandboxId = ""; -let sshToken = ""; -let sshHost = ""; -let sshPort = ""; +export interface DaytonaState { + apiKey: string; + sandboxId: string; + sshToken: string; + sshHost: string; + sshPort: string; +} + +let _state: DaytonaState = { + apiKey: "", + sandboxId: "", + sshToken: "", + sshHost: "", + sshPort: "", +}; + +/** Reset session state — used in tests for isolation. */ +export function resetDaytonaState(): void { + _state = { + apiKey: "", + sandboxId: "", + sshToken: "", + sshHost: "", + sshPort: "", + }; +} // ─── API Client ────────────────────────────────────────────────────────────── @@ -47,7 +68,7 @@ async function daytonaApi(method: string, endpoint: string, body?: string, maxRe try { const headers: Record = { "Content-Type": "application/json", - Authorization: `Bearer ${daytonaApiKey}`, + Authorization: `Bearer ${_state.apiKey}`, }; const opts: RequestInit = { method, @@ -109,7 +130,7 @@ async function saveTokenToConfig(token: string): Promise { } async function testDaytonaToken(): Promise { - if (!daytonaApiKey) { + if (!_state.apiKey) { return false; } try { @@ -123,26 +144,26 @@ async function testDaytonaToken(): Promise { export async function ensureDaytonaToken(): Promise { // 1. Env var if (process.env.DAYTONA_API_KEY) { - daytonaApiKey = process.env.DAYTONA_API_KEY.trim(); + _state.apiKey = process.env.DAYTONA_API_KEY.trim(); if (await testDaytonaToken()) { logInfo("Using Daytona API key from environment"); - await saveTokenToConfig(daytonaApiKey); + await saveTokenToConfig(_state.apiKey); return; } logWarn("DAYTONA_API_KEY from environment is invalid"); - daytonaApiKey = ""; + _state.apiKey = ""; } // 2. Saved config const saved = loadApiToken("daytona"); if (saved) { - daytonaApiKey = saved; + _state.apiKey = saved; if (await testDaytonaToken()) { logInfo("Using saved Daytona API key"); return; } logWarn("Saved Daytona token is invalid or expired"); - daytonaApiKey = ""; + _state.apiKey = ""; } // 3. Manual token entry @@ -152,13 +173,13 @@ export async function ensureDaytonaToken(): Promise { if (!token) { throw new Error("No token provided"); } - daytonaApiKey = token.trim(); + _state.apiKey = token.trim(); if (!(await testDaytonaToken())) { logError("Token is invalid"); - daytonaApiKey = ""; + _state.apiKey = ""; throw new Error("Invalid Daytona token"); } - await saveTokenToConfig(daytonaApiKey); + await saveTokenToConfig(_state.apiKey); logInfo("Using manually entered Daytona API key"); } @@ -185,8 +206,8 @@ function sshBaseArgs(): string[] { "-o", "PubkeyAuthentication=no", ]; - if (sshPort) { - args.push("-o", `Port=${sshPort}`); + if (_state.sshPort) { + args.push("-o", `Port=${_state.sshPort}`); } return args; } @@ -260,28 +281,28 @@ export async function promptSandboxSize(): Promise { async function setupSshAccess(): Promise { logStep("Setting up SSH access..."); - const sshResp = await daytonaApi("POST", `/sandbox/${sandboxId}/ssh-access?expiresInMinutes=480`); + const sshResp = await daytonaApi("POST", `/sandbox/${_state.sandboxId}/ssh-access?expiresInMinutes=480`); const data = parseJsonObj(sshResp); if (!data) { logError("Failed to parse SSH access response"); throw new Error("SSH access parse failure"); } - sshToken = isString(data.token) ? data.token : ""; + _state.sshToken = isString(data.token) ? data.token : ""; const sshCommand = isString(data.sshCommand) ? data.sshCommand : ""; - if (!sshToken) { + if (!_state.sshToken) { logError(`Failed to get SSH access: ${extractApiError(sshResp)}`); throw new Error("SSH access failed"); } // Parse host from sshCommand (e.g., "ssh -p 2222 TOKEN@HOST" or "ssh TOKEN@HOST") const hostMatch = sshCommand.match(/[^@ ]+$/); - sshHost = hostMatch ? hostMatch[0] : "ssh.app.daytona.io"; + _state.sshHost = hostMatch ? hostMatch[0] : "ssh.app.daytona.io"; // Parse port if present const portMatch = sshCommand.match(/-p\s+(\d+)/); - sshPort = portMatch ? portMatch[1] : ""; + _state.sshPort = portMatch ? portMatch[1] : ""; logInfo("SSH access ready"); } @@ -315,20 +336,20 @@ export async function createServer(name: string, sandboxSize?: SandboxSize): Pro const response = await daytonaApi("POST", "/sandbox", body); const data = parseJsonObj(response); - sandboxId = isString(data?.id) ? data.id : ""; - if (!sandboxId) { + _state.sandboxId = isString(data?.id) ? data.id : ""; + if (!_state.sandboxId) { logError(`Failed to create sandbox: ${extractApiError(response)}`); throw new Error("Sandbox creation failed"); } - logInfo(`Sandbox created: ${sandboxId}`); + logInfo(`Sandbox created: ${_state.sandboxId}`); // Wait for sandbox to reach started state logStep("Waiting for sandbox to start..."); const maxWait = 120; let waited = 0; while (waited < maxWait) { - const statusResp = await daytonaApi("GET", `/sandbox/${sandboxId}`); + const statusResp = await daytonaApi("GET", `/sandbox/${_state.sandboxId}`); const statusData = parseJsonObj(statusResp); const state = isString(statusData?.state) ? statusData.state : ""; @@ -357,7 +378,7 @@ export async function createServer(name: string, sandboxSize?: SandboxSize): Pro saveVmConnection( "daytona-sandbox", "daytona", - sandboxId, + _state.sandboxId, name, "daytona", undefined, @@ -378,7 +399,7 @@ export async function runServer(cmd: string, timeoutSecs?: number): Promise '${remotePath}'`, ]; @@ -514,7 +535,7 @@ export async function interactiveSession(cmd: string): Promise { const args = [ ...sshBaseArgs(), "-t", // Force PTY allocation - `${sshToken}@${sshHost}`, + `${_state.sshToken}@${_state.sshHost}`, "--", fullCmd, ]; @@ -523,7 +544,7 @@ export async function interactiveSession(cmd: string): Promise { // Post-session summary process.stderr.write("\n"); - logWarn(`Session ended. Your sandbox '${sandboxId}' may still be running.`); + logWarn(`Session ended. Your sandbox '${_state.sandboxId}' may still be running.`); logWarn("Remember to delete it when you're done to avoid ongoing charges."); logWarn(""); logWarn("Manage or delete it in your dashboard:"); @@ -628,7 +649,7 @@ export async function promptSpawnName(): Promise { // ─── Lifecycle ─────────────────────────────────────────────────────────────── export async function destroyServer(id?: string): Promise { - const targetId = id || sandboxId; + const targetId = id || _state.sandboxId; if (!targetId) { logWarn("No sandbox ID to destroy"); return; diff --git a/packages/cli/src/digitalocean/digitalocean.ts b/packages/cli/src/digitalocean/digitalocean.ts index a70f1175..4b6dd4cd 100644 --- a/packages/cli/src/digitalocean/digitalocean.ts +++ b/packages/cli/src/digitalocean/digitalocean.ts @@ -89,9 +89,27 @@ const DO_SCOPES = [ const DO_OAUTH_CALLBACK_PORT = 5190; // ─── State ─────────────────────────────────────────────────────────────────── -let doToken = ""; -let doDropletId = ""; -let doServerIp = ""; + +export interface DigitalOceanState { + token: string; + dropletId: string; + serverIp: string; +} + +let _state: DigitalOceanState = { + token: "", + dropletId: "", + serverIp: "", +}; + +/** Reset session state — used in tests for isolation. */ +export function resetDigitalOceanState(): void { + _state = { + token: "", + dropletId: "", + serverIp: "", + }; +} // ─── API Client ────────────────────────────────────────────────────────────── @@ -103,7 +121,7 @@ async function doApi(method: string, endpoint: string, body?: string, maxRetries try { const headers: Record = { "Content-Type": "application/json", - Authorization: `Bearer ${doToken}`, + Authorization: `Bearer ${_state.token}`, }; const opts: RequestInit = { method, @@ -208,7 +226,7 @@ function isTokenExpired(): boolean { // ─── Token Validation ──────────────────────────────────────────────────────── async function testDoToken(): Promise { - if (!doToken) { + if (!_state.token) { return false; } try { @@ -474,14 +492,14 @@ async function tryDoOAuth(): Promise { export async function ensureDoToken(): Promise { // 1. Env var if (process.env.DO_API_TOKEN) { - doToken = process.env.DO_API_TOKEN.trim(); + _state.token = process.env.DO_API_TOKEN.trim(); if (await testDoToken()) { logInfo("Using DigitalOcean API token from environment"); - await saveTokenToConfig(doToken); + await saveTokenToConfig(_state.token); return false; } logWarn("DO_API_TOKEN from environment is invalid"); - doToken = ""; + _state.token = ""; } // 2. Saved config (check expiry first, try refresh if needed) @@ -491,14 +509,14 @@ export async function ensureDoToken(): Promise { logWarn("Saved DigitalOcean token has expired, trying refresh..."); const refreshed = await tryRefreshDoToken(); if (refreshed) { - doToken = refreshed; + _state.token = refreshed; if (await testDoToken()) { logInfo("Using refreshed DigitalOcean token"); return false; } } } else { - doToken = saved; + _state.token = saved; if (await testDoToken()) { logInfo("Using saved DigitalOcean API token"); return false; @@ -507,26 +525,26 @@ export async function ensureDoToken(): Promise { // Try refresh as fallback const refreshed = await tryRefreshDoToken(); if (refreshed) { - doToken = refreshed; + _state.token = refreshed; if (await testDoToken()) { logInfo("Using refreshed DigitalOcean token"); return false; } } } - doToken = ""; + _state.token = ""; } // 3. Try OAuth browser flow const oauthToken = await tryDoOAuth(); if (oauthToken) { - doToken = oauthToken; + _state.token = oauthToken; if (await testDoToken()) { logInfo("Using DigitalOcean token from OAuth"); return true; } logWarn("OAuth token failed validation"); - doToken = ""; + _state.token = ""; } // 4. Manual entry (fallback) @@ -539,14 +557,14 @@ export async function ensureDoToken(): Promise { logError("Token cannot be empty"); continue; } - doToken = token.trim(); + _state.token = token.trim(); if (await testDoToken()) { - await saveTokenToConfig(doToken); + await saveTokenToConfig(_state.token); logInfo("DigitalOcean API token validated and saved"); return false; } logError("Token is invalid"); - doToken = ""; + _state.token = ""; } logError("No valid token after 3 attempts"); @@ -816,16 +834,16 @@ export async function createServer( throw new Error("Droplet creation failed"); } - doDropletId = String(createData.droplet.id); - logInfo(`Droplet created: ID=${doDropletId}`); + _state.dropletId = String(createData.droplet.id); + logInfo(`Droplet created: ID=${_state.dropletId}`); // Wait for droplet to become active and get IP - await waitForDropletActive(doDropletId); + await waitForDropletActive(_state.dropletId); saveVmConnection( - doServerIp, + _state.serverIp, "root", - doDropletId, + _state.dropletId, name, "digitalocean", undefined, @@ -846,9 +864,9 @@ async function waitForDropletActive(dropletId: string, maxAttempts = 60): Promis const v4Networks = toObjectArray(data?.droplet?.networks?.v4); const publicNet = v4Networks.find((n) => n.type === "public"); if (publicNet?.ip_address) { - doServerIp = isString(publicNet.ip_address) ? publicNet.ip_address : ""; + _state.serverIp = isString(publicNet.ip_address) ? publicNet.ip_address : ""; logStepDone(); - logInfo(`Droplet active, IP: ${doServerIp}`); + logInfo(`Droplet active, IP: ${_state.serverIp}`); return; } } @@ -867,7 +885,7 @@ async function waitForDropletActive(dropletId: string, maxAttempts = 60): Promis // ─── SSH Execution ─────────────────────────────────────────────────────────── export async function waitForCloudInit(ip?: string, _maxAttempts = 60): Promise { - const serverIp = ip || doServerIp; + const serverIp = ip || _state.serverIp; const selectedKeys = await ensureSshKeys(); const keyOpts = getSshKeyOpts(selectedKeys); await sharedWaitForSsh({ @@ -958,7 +976,7 @@ export async function waitForCloudInit(ip?: string, _maxAttempts = 60): Promise< } export async function runServer(cmd: string, timeoutSecs?: number, ip?: string): Promise { - const serverIp = ip || doServerIp; + const serverIp = ip || _state.serverIp; const fullCmd = `export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH" && ${cmd}`; const keyOpts = getSshKeyOpts(await ensureSshKeys()); @@ -992,7 +1010,7 @@ export async function runServer(cmd: string, timeoutSecs?: number, ip?: string): } export async function runServerCapture(cmd: string, timeoutSecs?: number, ip?: string): Promise { - const serverIp = ip || doServerIp; + const serverIp = ip || _state.serverIp; const fullCmd = `export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH" && ${cmd}`; const keyOpts = getSshKeyOpts(await ensureSshKeys()); @@ -1032,7 +1050,7 @@ export async function runServerCapture(cmd: string, timeoutSecs?: number, ip?: s } export async function uploadFile(localPath: string, remotePath: string, ip?: string): Promise { - const serverIp = ip || doServerIp; + const serverIp = ip || _state.serverIp; if ( !/^[a-zA-Z0-9/_.~-]+$/.test(remotePath) || remotePath.includes("..") || @@ -1066,7 +1084,7 @@ export async function uploadFile(localPath: string, remotePath: string, ip?: str } export async function interactiveSession(cmd: string, ip?: string): Promise { - const serverIp = ip || doServerIp; + 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, "'\\''"); @@ -1083,7 +1101,7 @@ export async function interactiveSession(cmd: string, ip?: string): Promise { // ─── Lifecycle ─────────────────────────────────────────────────────────────── export async function destroyServer(dropletId?: string): Promise { - const id = dropletId || doDropletId; + const id = dropletId || _state.dropletId; if (!id) { logError("destroy_server: no droplet ID provided"); throw new Error("No droplet ID"); diff --git a/packages/cli/src/gcp/gcp.ts b/packages/cli/src/gcp/gcp.ts index 6b2a7186..80122610 100644 --- a/packages/cli/src/gcp/gcp.ts +++ b/packages/cli/src/gcp/gcp.ts @@ -139,11 +139,32 @@ export const DEFAULT_ZONE = "us-central1-a"; // ─── State ────────────────────────────────────────────────────────────────── -let gcpProject = ""; -let gcpZone = ""; -let gcpInstanceName = ""; -let gcpServerIp = ""; -let gcpUsername = ""; +export interface GcpState { + project: string; + zone: string; + instanceName: string; + serverIp: string; + username: string; +} + +let _state: GcpState = { + project: "", + zone: "", + instanceName: "", + serverIp: "", + username: "", +}; + +/** Reset session state — used in tests for isolation. */ +export function resetGcpState(): void { + _state = { + project: "", + zone: "", + instanceName: "", + serverIp: "", + username: "", + }; +} // ─── gcloud CLI Wrapper ───────────────────────────────────────────────────── @@ -426,8 +447,8 @@ export async function authenticate(): Promise { export async function resolveProject(): Promise { // 1. Env var if (process.env.GCP_PROJECT) { - gcpProject = process.env.GCP_PROJECT; - logInfo(`Using GCP project from environment: ${gcpProject}`); + _state.project = process.env.GCP_PROJECT; + logInfo(`Using GCP project from environment: ${_state.project}`); return; } @@ -498,8 +519,8 @@ export async function resolveProject(): Promise { throw new Error("No GCP project"); } - gcpProject = project; - logInfo(`Using GCP project: ${gcpProject}`); + _state.project = project; + logInfo(`Using GCP project: ${_state.project}`); } // ─── Interactive Pickers ──────────────────────────────────────────────────── @@ -559,8 +580,8 @@ async function ensureSshKey(): Promise { // ─── Username ─────────────────────────────────────────────────────────────── function resolveUsername(): string { - if (gcpUsername) { - return gcpUsername; + if (_state.username) { + return _state.username; } const result = Bun.spawnSync( [ @@ -579,7 +600,7 @@ function resolveUsername(): string { logError("Invalid username detected"); throw new Error("Invalid username"); } - gcpUsername = username; + _state.username = username; return username; } @@ -690,7 +711,7 @@ export async function createInstance( `--subnet=${process.env.GCP_SUBNET ?? "default"}`, `--metadata-from-file=startup-script=${tmpFile}`, `--metadata=ssh-keys=${sshKeysMetadata}`, - `--project=${gcpProject}`, + `--project=${_state.project}`, "--quiet", ]; @@ -711,7 +732,7 @@ export async function createInstance( "config", "set", "project", - gcpProject, + _state.project, ]); logInfo("Re-authenticated, retrying instance creation..."); result = await gcloud(args); @@ -749,19 +770,19 @@ export async function createInstance( "describe", name, `--zone=${zone}`, - `--project=${gcpProject}`, + `--project=${_state.project}`, "--format=get(networkInterfaces[0].accessConfigs[0].natIP)", ]); - gcpInstanceName = name; - gcpZone = zone; - gcpServerIp = ipResult.stdout; + _state.instanceName = name; + _state.zone = zone; + _state.serverIp = ipResult.stdout; - logInfo(`Instance created: IP=${gcpServerIp}`); + logInfo(`Instance created: IP=${_state.serverIp}`); // Save connection info with zone/project for later deletion saveVmConnection( - gcpServerIp, + _state.serverIp, username, "", name, @@ -769,7 +790,7 @@ export async function createInstance( undefined, { zone, - project: gcpProject, + project: _state.project, }, process.env.SPAWN_ID || undefined, ); @@ -781,7 +802,7 @@ async function waitForSsh(maxAttempts = 36): Promise { const username = resolveUsername(); const keyOpts = getSshKeyOpts(await ensureSshKeys()); await sharedWaitForSsh({ - host: gcpServerIp, + host: _state.serverIp, user: username, maxAttempts, extraSshOpts: keyOpts, @@ -802,7 +823,7 @@ export async function waitForCloudInit(maxAttempts = 60): Promise { "ssh", ...SSH_BASE_OPTS, ...keyOpts, - `${username}@${gcpServerIp}`, + `${username}@${_state.serverIp}`, "test -f /tmp/.cloud-init-complete", ], { @@ -843,7 +864,7 @@ export async function runServer(cmd: string, timeoutSecs?: number): Promise { "ssh", ...SSH_INTERACTIVE_OPTS, ...keyOpts, - `${username}@${gcpServerIp}`, + `${username}@${_state.serverIp}`, fullCmd, ]); // Post-session summary process.stderr.write("\n"); - logWarn(`Session ended. Your GCP instance '${gcpInstanceName}' is still running.`); + logWarn(`Session ended. Your GCP instance '${_state.instanceName}' 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:"); @@ -971,7 +992,7 @@ export async function interactiveSession(cmd: string): Promise { logInfo("To delete from CLI:"); logInfo(" spawn delete"); logInfo("To reconnect:"); - logInfo(` gcloud compute ssh ${gcpInstanceName} --zone=${gcpZone} --project=${gcpProject}`); + logInfo(` gcloud compute ssh ${_state.instanceName} --zone=${_state.zone} --project=${_state.project}`); return exitCode; } @@ -979,8 +1000,8 @@ export async function interactiveSession(cmd: string): Promise { // ─── Lifecycle ────────────────────────────────────────────────────────────── export async function destroyInstance(name?: string): Promise { - const instanceName = name || gcpInstanceName; - const zone = gcpZone || process.env.GCP_ZONE || DEFAULT_ZONE; + const instanceName = name || _state.instanceName; + const zone = _state.zone || process.env.GCP_ZONE || DEFAULT_ZONE; if (!instanceName) { logError("destroy: no instance name provided"); @@ -994,7 +1015,7 @@ export async function destroyInstance(name?: string): Promise { "delete", instanceName, `--zone=${zone}`, - `--project=${gcpProject}`, + `--project=${_state.project}`, "--quiet", ]); diff --git a/packages/cli/src/hetzner/hetzner.ts b/packages/cli/src/hetzner/hetzner.ts index a452125e..afcb8dc1 100644 --- a/packages/cli/src/hetzner/hetzner.ts +++ b/packages/cli/src/hetzner/hetzner.ts @@ -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 = { "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 { // ─── Token Validation ──────────────────────────────────────────────────────── async function testHcloudToken(): Promise { - if (!hcloudToken) { + if (!_state.hcloudToken) { return false; } try { @@ -131,26 +149,26 @@ async function testHcloudToken(): Promise { export async function ensureHcloudToken(): Promise { // 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 { 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 { - 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 { - 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 { - 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 { - 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 { - 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 { // ─── Lifecycle ─────────────────────────────────────────────────────────────── export async function destroyServer(serverId?: string): Promise { - const id = serverId || hetznerServerId; + const id = serverId || _state.serverId; if (!id) { logError("destroy_server: no server ID provided"); throw new Error("No server ID"); diff --git a/packages/cli/src/sprite/sprite.ts b/packages/cli/src/sprite/sprite.ts index 157c7c75..c50311ec 100644 --- a/packages/cli/src/sprite/sprite.ts +++ b/packages/cli/src/sprite/sprite.ts @@ -25,8 +25,23 @@ const CONNECTIVITY_POLL_DELAY = Number.parseInt(process.env.SPRITE_CONNECTIVITY_ // ─── State ─────────────────────────────────────────────────────────────────── -let spriteName = ""; -let spriteOrg = ""; +export interface SpriteState { + name: string; + org: string; +} + +let _state: SpriteState = { + name: "", + org: "", +}; + +/** Reset session state — used in tests for isolation. */ +export function resetSpriteState(): void { + _state = { + name: "", + org: "", + }; +} // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -231,20 +246,20 @@ export async function ensureSpriteAuthenticated(): Promise { function detectOrg(output: string): void { if (process.env.SPRITE_ORG) { - spriteOrg = process.env.SPRITE_ORG; + _state.org = process.env.SPRITE_ORG; return; } const match = output.match(/Currently selected org: (\S+)/); if (match) { - spriteOrg = match[1]; + _state.org = match[1]; } } function orgFlags(): string[] { - if (spriteOrg) { + if (_state.org) { return [ "-o", - spriteOrg, + _state.org, ]; } return []; @@ -305,7 +320,7 @@ export async function createSprite(name: string): Promise { const firstToken = line.split(/\s/)[0]; if (firstToken === name) { logInfo(`Sprite '${name}' already exists`); - spriteName = name; + _state.name = name; return; } } @@ -353,7 +368,7 @@ export async function createSprite(name: string): Promise { const firstToken = line.split(/\s/)[0]; if (firstToken === name) { logInfo(`Sprite '${name}' provisioned`); - spriteName = name; + _state.name = name; return; } } @@ -376,14 +391,14 @@ export async function verifySpriteConnectivity(maxAttempts = 6): Promise { ...orgFlags(), "exec", "-s", - spriteName, + _state.name, "--", "echo", "ok", ]); if (proc.exitCode === 0) { logStepDone(); - logInfo(`Sprite '${spriteName}' is ready`); + logInfo(`Sprite '${_state.name}' is ready`); return; } logStepInline(`Sprite not ready, retrying (${attempt}/${maxAttempts})...`); @@ -391,7 +406,7 @@ export async function verifySpriteConnectivity(maxAttempts = 6): Promise { } logStepDone(); - logError(`Sprite '${spriteName}' failed to respond after ${maxAttempts} attempts`); + logError(`Sprite '${_state.name}' failed to respond after ${maxAttempts} attempts`); logError("Try: sprite list, sprite logs, or recreate the sprite"); throw new Error("Sprite connectivity timeout"); } @@ -431,7 +446,7 @@ export function saveVmConnection(): void { "sprite-console", process.env.USER || "root", "", - spriteName, + _state.name, "sprite", undefined, undefined, @@ -453,7 +468,7 @@ export async function runSprite(cmd: string, timeoutSecs?: number): Promise { ...orgFlags(), "exec", "-s", - spriteName, + _state.name, "--", "bash", "-c", @@ -542,7 +557,7 @@ export async function uploadFileSprite(localPath: string, remotePath: string): P ...orgFlags(), "exec", "-s", - spriteName, + _state.name, "-file", `${localPath}:${tempRemote}`, "--", @@ -580,7 +595,7 @@ export async function interactiveSession(cmd: string): Promise { ...orgFlags(), "exec", "-s", - spriteName, + _state.name, "--", "bash", "-c", @@ -591,7 +606,7 @@ export async function interactiveSession(cmd: string): Promise { ...orgFlags(), "exec", "-s", - spriteName, + _state.name, "-tty", "--", "bash", @@ -603,13 +618,13 @@ export async function interactiveSession(cmd: string): Promise { // Post-session summary process.stderr.write("\n"); - logWarn(`Session ended. Your sprite '${spriteName}' is still running.`); + logWarn(`Session ended. Your sprite '${_state.name}' is still running.`); logWarn("Remember to destroy it when you're done to avoid ongoing charges."); logWarn(""); logInfo("To destroy:"); - logInfo(` sprite destroy ${spriteName}`); + logInfo(` sprite destroy ${_state.name}`); logInfo("To reconnect:"); - logInfo(` sprite console -s ${spriteName}`); + logInfo(` sprite console -s ${_state.name}`); return exitCode; } @@ -617,7 +632,7 @@ export async function interactiveSession(cmd: string): Promise { // ─── Lifecycle ─────────────────────────────────────────────────────────────── export async function destroyServer(name?: string): Promise { - const target = name || spriteName; + const target = name || _state.name; if (!target) { logError("destroy_server: no sprite name provided"); throw new Error("No sprite name");