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

@ -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<string> {
// ─── SigV4 REST API ─────────────────────────────────────────────────────────
async function lightsailRest(target: string, body = "{}"): Promise<string> {
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<string> {
"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<string> {
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<string, string> = Object.fromEntries(allHeaders.filter(([k]) => k !== "host"));
reqHeaders["Authorization"] = authHeader;
@ -492,7 +520,7 @@ export async function authenticate(): Promise<void> {
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<void> {
"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<void> {
// 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<void> {
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<void> {
"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<void> {
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<void> {
"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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
// 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<void> {
async function waitForSsh(maxAttempts = 36): Promise<void> {
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<void> {
"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<void
"ssh",
...SSH_BASE_OPTS,
...keyOpts,
`${SSH_USER}@${instanceIp}`,
`${SSH_USER}@${_state.instanceIp}`,
`bash -c '${fullCmd.replace(/'/g, "'\\''")}'`,
],
{
@ -1074,7 +1100,7 @@ export async function runServerCapture(cmd: string, timeoutSecs?: number): Promi
"ssh",
...SSH_BASE_OPTS,
...keyOpts,
`${SSH_USER}@${instanceIp}`,
`${SSH_USER}@${_state.instanceIp}`,
`bash -c '${fullCmd.replace(/'/g, "'\\''")}'`,
],
{
@ -1118,7 +1144,7 @@ export async function uploadFile(localPath: string, remotePath: string): Promise
...SSH_BASE_OPTS,
...keyOpts,
localPath,
`${SSH_USER}@${instanceIp}:${remotePath}`,
`${SSH_USER}@${_state.instanceIp}:${remotePath}`,
],
{
stdio: [
@ -1145,13 +1171,13 @@ export async function interactiveSession(cmd: string): Promise<number> {
"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<number> {
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<void> {
// ─── Lifecycle ──────────────────────────────────────────────────────────────
export async function destroyServer(name?: string): Promise<void> {
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",

View file

@ -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<string, string> = {
"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<void> {
}
async function testDaytonaToken(): Promise<boolean> {
if (!daytonaApiKey) {
if (!_state.apiKey) {
return false;
}
try {
@ -123,26 +144,26 @@ async function testDaytonaToken(): Promise<boolean> {
export async function ensureDaytonaToken(): Promise<void> {
// 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<void> {
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<SandboxSize> {
async function setupSshAccess(): Promise<void> {
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<void
...sshBaseArgs(),
"-o",
"BatchMode=yes",
`${sshToken}@${sshHost}`,
`${_state.sshToken}@${_state.sshHost}`,
"--",
fullCmd,
];
@ -417,7 +438,7 @@ export async function runServerCapture(cmd: string, timeoutSecs?: number): Promi
...sshBaseArgs(),
"-o",
"BatchMode=yes",
`${sshToken}@${sshHost}`,
`${_state.sshToken}@${_state.sshHost}`,
"--",
fullCmd,
];
@ -474,7 +495,7 @@ export async function uploadFile(localPath: string, remotePath: string): Promise
...sshBaseArgs(),
"-o",
"BatchMode=yes",
`${sshToken}@${sshHost}`,
`${_state.sshToken}@${_state.sshHost}`,
"--",
`base64 -d > '${remotePath}'`,
];
@ -514,7 +535,7 @@ export async function interactiveSession(cmd: string): Promise<number> {
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<number> {
// 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<void> {
// ─── Lifecycle ───────────────────────────────────────────────────────────────
export async function destroyServer(id?: string): Promise<void> {
const targetId = id || sandboxId;
const targetId = id || _state.sandboxId;
if (!targetId) {
logWarn("No sandbox ID to destroy");
return;

View file

@ -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<string, string> = {
"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<boolean> {
if (!doToken) {
if (!_state.token) {
return false;
}
try {
@ -474,14 +492,14 @@ async function tryDoOAuth(): Promise<string | null> {
export async function ensureDoToken(): Promise<boolean> {
// 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<boolean> {
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<boolean> {
// 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<boolean> {
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<void> {
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<void> {
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<string> {
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<void> {
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<number> {
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<numb
// Post-session summary
process.stderr.write("\n");
logWarn(`Session ended. Your DigitalOcean droplet (ID: ${doDropletId}) is still running.`);
logWarn(`Session ended. Your DigitalOcean droplet (ID: ${_state.dropletId}) 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:");
@ -1150,7 +1168,7 @@ export async function promptSpawnName(): Promise<void> {
// ─── Lifecycle ───────────────────────────────────────────────────────────────
export async function destroyServer(dropletId?: string): Promise<void> {
const id = dropletId || doDropletId;
const id = dropletId || _state.dropletId;
if (!id) {
logError("destroy_server: no droplet ID provided");
throw new Error("No droplet ID");

View file

@ -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<void> {
export async function resolveProject(): Promise<void> {
// 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<void> {
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<string> {
// ─── 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<void> {
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<void> {
"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<void
"ssh",
...SSH_BASE_OPTS,
...keyOpts,
`${username}@${gcpServerIp}`,
`${username}@${_state.serverIp}`,
`bash -c ${shellQuote(fullCmd)}`,
],
{
@ -877,7 +898,7 @@ export async function runServerCapture(cmd: string, timeoutSecs?: number): Promi
"ssh",
...SSH_BASE_OPTS,
...keyOpts,
`${username}@${gcpServerIp}`,
`${username}@${_state.serverIp}`,
`bash -c ${shellQuote(fullCmd)}`,
],
{
@ -927,7 +948,7 @@ export async function uploadFile(localPath: string, remotePath: string): Promise
...SSH_BASE_OPTS,
...keyOpts,
localPath,
`${username}@${gcpServerIp}:${expandedPath}`,
`${username}@${_state.serverIp}:${expandedPath}`,
],
{
stdio: [
@ -956,13 +977,13 @@ export async function interactiveSession(cmd: string): Promise<number> {
"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<number> {
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<number> {
// ─── Lifecycle ──────────────────────────────────────────────────────────────
export async function destroyInstance(name?: string): Promise<void> {
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<void> {
"delete",
instanceName,
`--zone=${zone}`,
`--project=${gcpProject}`,
`--project=${_state.project}`,
"--quiet",
]);

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

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
...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<void> {
}
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<void
...orgFlags(),
"exec",
"-s",
spriteName,
_state.name,
"--",
"bash",
"-c",
@ -489,7 +504,7 @@ async function runSpriteSilent(cmd: string): Promise<void> {
...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<number> {
...orgFlags(),
"exec",
"-s",
spriteName,
_state.name,
"--",
"bash",
"-c",
@ -591,7 +606,7 @@ export async function interactiveSession(cmd: string): Promise<number> {
...orgFlags(),
"exec",
"-s",
spriteName,
_state.name,
"-tty",
"--",
"bash",
@ -603,13 +618,13 @@ export async function interactiveSession(cmd: string): Promise<number> {
// 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<number> {
// ─── Lifecycle ───────────────────────────────────────────────────────────────
export async function destroyServer(name?: string): Promise<void> {
const target = name || spriteName;
const target = name || _state.name;
if (!target) {
logError("destroy_server: no sprite name provided");
throw new Error("No sprite name");