mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-19 16:39:50 +00:00
refactor: replace module-level mutable globals with typed state objects in cloud providers (#2255)
Each cloud module (aws, daytona, digitalocean, gcp, hetzner, sprite) previously stored per-operation state in bare module-level `let` variables, making them process-global singletons. This is safe for single-cloud CLI invocations today but creates latent bugs for multi-cloud orchestration and test isolation. Replace scattered `let` globals with a single typed `_state` object per module: - `AwsState` / `resetAwsState()` — 8 fields including `selectedBundle` - `DaytonaState` / `resetDaytonaState()` — 5 fields - `DigitalOceanState` / `resetDigitalOceanState()` — 3 fields - `GcpState` / `resetGcpState()` — 5 fields - `HetznerState` / `resetHetznerState()` — 3 fields - `SpriteState` / `resetSpriteState()` — 2 fields Each module exports a `resetXxxState()` function for test isolation. No function signatures or existing exports were changed. Fixes #2251 Agent: issue-fixer Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
141254c4e1
commit
f862ee563e
6 changed files with 332 additions and 213 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue