mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-19 08:01:17 +00:00
feat: add --custom flag for machine type/region selection (#1810)
* feat: add --custom flag for interactive machine type/region selection By default, all clouds now skip size/region prompts and use sensible defaults for faster provisioning. The --custom flag enables interactive pickers on all clouds, unifying the previously inconsistent behavior where some clouds always prompted and others never did. - AWS: promptRegion/promptBundle gated on SPAWN_CUSTOM - GCP: promptMachineType/promptZone gated on SPAWN_CUSTOM - Fly: promptVmOptions gated on SPAWN_CUSTOM - Hetzner: new promptServerType/promptLocation with type/location arrays - DigitalOcean: new promptDropletSize/promptDoRegion with size/region arrays - Daytona: new promptSandboxSize with cpu/memory/disk presets - Sprite: no change (managed platform, no meaningful size options) - --custom + --headless is an error (incompatible modes) - Version bump to 0.8.0 (new feature) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: fix biome format violations in --custom flag code Auto-format object literals in arrays (expand to multi-line), wrap long console.error line, and expand inline array in test assertion. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: lab <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b51b5aa11e
commit
463c7a2efb
15 changed files with 745 additions and 18 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "0.7.12",
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
340
cli/src/__tests__/custom-flag.test.ts
Normal file
340
cli/src/__tests__/custom-flag.test.ts
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import { describe, it, expect, afterEach } from "bun:test";
|
||||
import { KNOWN_FLAGS, findUnknownFlag } from "../flags";
|
||||
|
||||
describe("--custom flag", () => {
|
||||
describe("flag registration", () => {
|
||||
it("should be in KNOWN_FLAGS", () => {
|
||||
expect(KNOWN_FLAGS.has("--custom")).toBe(true);
|
||||
});
|
||||
|
||||
it("should not be detected as unknown flag", () => {
|
||||
expect(
|
||||
findUnknownFlag([
|
||||
"claude",
|
||||
"sprite",
|
||||
"--custom",
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SPAWN_CUSTOM env var propagation", () => {
|
||||
const savedCustom = process.env.SPAWN_CUSTOM;
|
||||
|
||||
afterEach(() => {
|
||||
if (savedCustom !== undefined) {
|
||||
process.env.SPAWN_CUSTOM = savedCustom;
|
||||
} else {
|
||||
delete process.env.SPAWN_CUSTOM;
|
||||
}
|
||||
});
|
||||
|
||||
it("should be readable from process.env", () => {
|
||||
process.env.SPAWN_CUSTOM = "1";
|
||||
expect(process.env.SPAWN_CUSTOM).toBe("1");
|
||||
});
|
||||
|
||||
it("should be unset by default", () => {
|
||||
delete process.env.SPAWN_CUSTOM;
|
||||
expect(process.env.SPAWN_CUSTOM).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AWS --custom prompts", () => {
|
||||
const savedCustom = process.env.SPAWN_CUSTOM;
|
||||
const savedRegion = process.env.AWS_DEFAULT_REGION;
|
||||
const savedLsRegion = process.env.LIGHTSAIL_REGION;
|
||||
const savedBundle = process.env.LIGHTSAIL_BUNDLE;
|
||||
const savedNonInteractive = process.env.SPAWN_NON_INTERACTIVE;
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv("SPAWN_CUSTOM", savedCustom);
|
||||
restoreEnv("AWS_DEFAULT_REGION", savedRegion);
|
||||
restoreEnv("LIGHTSAIL_REGION", savedLsRegion);
|
||||
restoreEnv("LIGHTSAIL_BUNDLE", savedBundle);
|
||||
restoreEnv("SPAWN_NON_INTERACTIVE", savedNonInteractive);
|
||||
});
|
||||
|
||||
it("promptRegion should skip prompt without --custom", async () => {
|
||||
delete process.env.AWS_DEFAULT_REGION;
|
||||
delete process.env.LIGHTSAIL_REGION;
|
||||
delete process.env.SPAWN_CUSTOM;
|
||||
const { promptRegion, getState } = await import("../aws/aws");
|
||||
await promptRegion();
|
||||
// Should use default without prompting
|
||||
expect(getState().awsRegion).toBe("us-east-1");
|
||||
});
|
||||
|
||||
it("promptBundle should skip prompt without --custom", async () => {
|
||||
delete process.env.LIGHTSAIL_BUNDLE;
|
||||
delete process.env.SPAWN_CUSTOM;
|
||||
const { promptBundle } = await import("../aws/aws");
|
||||
// Should return without prompting (no error)
|
||||
await promptBundle();
|
||||
});
|
||||
|
||||
it("promptRegion should respect env var over --custom", async () => {
|
||||
process.env.AWS_DEFAULT_REGION = "eu-west-1";
|
||||
process.env.SPAWN_CUSTOM = "1";
|
||||
const { promptRegion, getState } = await import("../aws/aws");
|
||||
await promptRegion();
|
||||
expect(getState().awsRegion).toBe("eu-west-1");
|
||||
});
|
||||
|
||||
it("promptBundle should respect env var over --custom", async () => {
|
||||
process.env.LIGHTSAIL_BUNDLE = "small_3_0";
|
||||
process.env.SPAWN_CUSTOM = "1";
|
||||
const { promptBundle } = await import("../aws/aws");
|
||||
// Should use env var without prompting
|
||||
await promptBundle();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GCP --custom prompts", () => {
|
||||
const savedCustom = process.env.SPAWN_CUSTOM;
|
||||
const savedMachineType = process.env.GCP_MACHINE_TYPE;
|
||||
const savedZone = process.env.GCP_ZONE;
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv("SPAWN_CUSTOM", savedCustom);
|
||||
restoreEnv("GCP_MACHINE_TYPE", savedMachineType);
|
||||
restoreEnv("GCP_ZONE", savedZone);
|
||||
});
|
||||
|
||||
it("promptMachineType should return default without --custom", async () => {
|
||||
delete process.env.GCP_MACHINE_TYPE;
|
||||
delete process.env.SPAWN_CUSTOM;
|
||||
const { promptMachineType, DEFAULT_MACHINE_TYPE } = await import("../gcp/gcp");
|
||||
const result = await promptMachineType();
|
||||
expect(result).toBe(DEFAULT_MACHINE_TYPE);
|
||||
});
|
||||
|
||||
it("promptZone should return default without --custom", async () => {
|
||||
delete process.env.GCP_ZONE;
|
||||
delete process.env.SPAWN_CUSTOM;
|
||||
const { promptZone, DEFAULT_ZONE } = await import("../gcp/gcp");
|
||||
const result = await promptZone();
|
||||
expect(result).toBe(DEFAULT_ZONE);
|
||||
});
|
||||
|
||||
it("promptMachineType should respect env var", async () => {
|
||||
process.env.GCP_MACHINE_TYPE = "n2-standard-4";
|
||||
process.env.SPAWN_CUSTOM = "1";
|
||||
const { promptMachineType } = await import("../gcp/gcp");
|
||||
const result = await promptMachineType();
|
||||
expect(result).toBe("n2-standard-4");
|
||||
});
|
||||
|
||||
it("promptZone should respect env var", async () => {
|
||||
process.env.GCP_ZONE = "europe-west1-b";
|
||||
process.env.SPAWN_CUSTOM = "1";
|
||||
const { promptZone } = await import("../gcp/gcp");
|
||||
const result = await promptZone();
|
||||
expect(result).toBe("europe-west1-b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fly --custom prompts", () => {
|
||||
const savedCustom = process.env.SPAWN_CUSTOM;
|
||||
const savedMemory = process.env.FLY_VM_MEMORY;
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv("SPAWN_CUSTOM", savedCustom);
|
||||
restoreEnv("FLY_VM_MEMORY", savedMemory);
|
||||
});
|
||||
|
||||
it("should return defaults without --custom", async () => {
|
||||
delete process.env.FLY_VM_MEMORY;
|
||||
delete process.env.SPAWN_CUSTOM;
|
||||
const { DEFAULT_VM_TIER } = await import("../fly/fly");
|
||||
// The promptVmOptions is local to main.ts, so we test the behavior
|
||||
// via the exported DEFAULT_VM_TIER and the env-var pattern
|
||||
expect(DEFAULT_VM_TIER.cpuKind).toBeDefined();
|
||||
expect(DEFAULT_VM_TIER.cpus).toBeGreaterThan(0);
|
||||
expect(DEFAULT_VM_TIER.memoryMb).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hetzner --custom prompts", () => {
|
||||
const savedCustom = process.env.SPAWN_CUSTOM;
|
||||
const savedServerType = process.env.HETZNER_SERVER_TYPE;
|
||||
const savedLocation = process.env.HETZNER_LOCATION;
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv("SPAWN_CUSTOM", savedCustom);
|
||||
restoreEnv("HETZNER_SERVER_TYPE", savedServerType);
|
||||
restoreEnv("HETZNER_LOCATION", savedLocation);
|
||||
});
|
||||
|
||||
it("promptServerType should return default without --custom", async () => {
|
||||
delete process.env.HETZNER_SERVER_TYPE;
|
||||
delete process.env.SPAWN_CUSTOM;
|
||||
const { promptServerType, DEFAULT_SERVER_TYPE } = await import("../hetzner/hetzner");
|
||||
const result = await promptServerType();
|
||||
expect(result).toBe(DEFAULT_SERVER_TYPE);
|
||||
});
|
||||
|
||||
it("promptLocation should return default without --custom", async () => {
|
||||
delete process.env.HETZNER_LOCATION;
|
||||
delete process.env.SPAWN_CUSTOM;
|
||||
const { promptLocation, DEFAULT_LOCATION } = await import("../hetzner/hetzner");
|
||||
const result = await promptLocation();
|
||||
expect(result).toBe(DEFAULT_LOCATION);
|
||||
});
|
||||
|
||||
it("promptServerType should respect env var", async () => {
|
||||
process.env.HETZNER_SERVER_TYPE = "cx32";
|
||||
process.env.SPAWN_CUSTOM = "1";
|
||||
const { promptServerType } = await import("../hetzner/hetzner");
|
||||
const result = await promptServerType();
|
||||
expect(result).toBe("cx32");
|
||||
});
|
||||
|
||||
it("promptLocation should respect env var", async () => {
|
||||
process.env.HETZNER_LOCATION = "ash";
|
||||
process.env.SPAWN_CUSTOM = "1";
|
||||
const { promptLocation } = await import("../hetzner/hetzner");
|
||||
const result = await promptLocation();
|
||||
expect(result).toBe("ash");
|
||||
});
|
||||
|
||||
it("SERVER_TYPES should have entries", async () => {
|
||||
const { SERVER_TYPES } = await import("../hetzner/hetzner");
|
||||
expect(SERVER_TYPES.length).toBeGreaterThan(0);
|
||||
for (const t of SERVER_TYPES) {
|
||||
expect(t.id).toBeDefined();
|
||||
expect(t.label).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("LOCATIONS should have entries", async () => {
|
||||
const { LOCATIONS } = await import("../hetzner/hetzner");
|
||||
expect(LOCATIONS.length).toBeGreaterThan(0);
|
||||
for (const l of LOCATIONS) {
|
||||
expect(l.id).toBeDefined();
|
||||
expect(l.label).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DigitalOcean --custom prompts", () => {
|
||||
const savedCustom = process.env.SPAWN_CUSTOM;
|
||||
const savedSize = process.env.DO_DROPLET_SIZE;
|
||||
const savedRegion = process.env.DO_REGION;
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv("SPAWN_CUSTOM", savedCustom);
|
||||
restoreEnv("DO_DROPLET_SIZE", savedSize);
|
||||
restoreEnv("DO_REGION", savedRegion);
|
||||
});
|
||||
|
||||
it("promptDropletSize should return default without --custom", async () => {
|
||||
delete process.env.DO_DROPLET_SIZE;
|
||||
delete process.env.SPAWN_CUSTOM;
|
||||
const { promptDropletSize, DEFAULT_DROPLET_SIZE } = await import("../digitalocean/digitalocean");
|
||||
const result = await promptDropletSize();
|
||||
expect(result).toBe(DEFAULT_DROPLET_SIZE);
|
||||
});
|
||||
|
||||
it("promptDoRegion should return default without --custom", async () => {
|
||||
delete process.env.DO_REGION;
|
||||
delete process.env.SPAWN_CUSTOM;
|
||||
const { promptDoRegion, DEFAULT_DO_REGION } = await import("../digitalocean/digitalocean");
|
||||
const result = await promptDoRegion();
|
||||
expect(result).toBe(DEFAULT_DO_REGION);
|
||||
});
|
||||
|
||||
it("promptDropletSize should respect env var", async () => {
|
||||
process.env.DO_DROPLET_SIZE = "s-4vcpu-8gb";
|
||||
process.env.SPAWN_CUSTOM = "1";
|
||||
const { promptDropletSize } = await import("../digitalocean/digitalocean");
|
||||
const result = await promptDropletSize();
|
||||
expect(result).toBe("s-4vcpu-8gb");
|
||||
});
|
||||
|
||||
it("promptDoRegion should respect env var", async () => {
|
||||
process.env.DO_REGION = "lon1";
|
||||
process.env.SPAWN_CUSTOM = "1";
|
||||
const { promptDoRegion } = await import("../digitalocean/digitalocean");
|
||||
const result = await promptDoRegion();
|
||||
expect(result).toBe("lon1");
|
||||
});
|
||||
|
||||
it("DROPLET_SIZES should have entries", async () => {
|
||||
const { DROPLET_SIZES } = await import("../digitalocean/digitalocean");
|
||||
expect(DROPLET_SIZES.length).toBeGreaterThan(0);
|
||||
for (const s of DROPLET_SIZES) {
|
||||
expect(s.id).toBeDefined();
|
||||
expect(s.label).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("DO_REGIONS should have entries", async () => {
|
||||
const { DO_REGIONS } = await import("../digitalocean/digitalocean");
|
||||
expect(DO_REGIONS.length).toBeGreaterThan(0);
|
||||
for (const r of DO_REGIONS) {
|
||||
expect(r.id).toBeDefined();
|
||||
expect(r.label).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Daytona --custom prompts", () => {
|
||||
const savedCustom = process.env.SPAWN_CUSTOM;
|
||||
const savedCpu = process.env.DAYTONA_CPU;
|
||||
const savedMemory = process.env.DAYTONA_MEMORY;
|
||||
const savedDisk = process.env.DAYTONA_DISK;
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv("SPAWN_CUSTOM", savedCustom);
|
||||
restoreEnv("DAYTONA_CPU", savedCpu);
|
||||
restoreEnv("DAYTONA_MEMORY", savedMemory);
|
||||
restoreEnv("DAYTONA_DISK", savedDisk);
|
||||
});
|
||||
|
||||
it("promptSandboxSize should return default without --custom", async () => {
|
||||
delete process.env.DAYTONA_CPU;
|
||||
delete process.env.DAYTONA_MEMORY;
|
||||
delete process.env.DAYTONA_DISK;
|
||||
delete process.env.SPAWN_CUSTOM;
|
||||
const { promptSandboxSize, DEFAULT_SANDBOX_SIZE } = await import("../daytona/daytona");
|
||||
const result = await promptSandboxSize();
|
||||
expect(result.cpu).toBe(DEFAULT_SANDBOX_SIZE.cpu);
|
||||
expect(result.memory).toBe(DEFAULT_SANDBOX_SIZE.memory);
|
||||
expect(result.disk).toBe(DEFAULT_SANDBOX_SIZE.disk);
|
||||
});
|
||||
|
||||
it("promptSandboxSize should respect env vars", async () => {
|
||||
process.env.DAYTONA_CPU = "4";
|
||||
process.env.DAYTONA_MEMORY = "8";
|
||||
process.env.DAYTONA_DISK = "50";
|
||||
process.env.SPAWN_CUSTOM = "1";
|
||||
const { promptSandboxSize } = await import("../daytona/daytona");
|
||||
const result = await promptSandboxSize();
|
||||
expect(result.cpu).toBe(4);
|
||||
expect(result.memory).toBe(8);
|
||||
expect(result.disk).toBe(50);
|
||||
});
|
||||
|
||||
it("SANDBOX_SIZES should have entries", async () => {
|
||||
const { SANDBOX_SIZES } = await import("../daytona/daytona");
|
||||
expect(SANDBOX_SIZES.length).toBeGreaterThan(0);
|
||||
for (const s of SANDBOX_SIZES) {
|
||||
expect(s.id).toBeDefined();
|
||||
expect(s.cpu).toBeGreaterThan(0);
|
||||
expect(s.memory).toBeGreaterThan(0);
|
||||
expect(s.disk).toBeGreaterThan(0);
|
||||
expect(s.label).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** Helper to restore or delete an env var */
|
||||
function restoreEnv(key: string, savedValue: string | undefined): void {
|
||||
if (savedValue !== undefined) {
|
||||
process.env[key] = savedValue;
|
||||
} else {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
|
|
@ -338,6 +338,7 @@ describe("KNOWN_FLAGS completeness", () => {
|
|||
"--agent",
|
||||
"--cloud",
|
||||
"--clear",
|
||||
"--custom",
|
||||
];
|
||||
for (const flag of expected) {
|
||||
expect(KNOWN_FLAGS.has(flag)).toBe(true);
|
||||
|
|
|
|||
|
|
@ -516,6 +516,9 @@ export async function promptRegion(): Promise<void> {
|
|||
awsRegion = process.env.AWS_DEFAULT_REGION || process.env.LIGHTSAIL_REGION || "us-east-1";
|
||||
return;
|
||||
}
|
||||
if (process.env.SPAWN_CUSTOM !== "1") {
|
||||
return;
|
||||
}
|
||||
if (process.env.SPAWN_NON_INTERACTIVE === "1") {
|
||||
return;
|
||||
}
|
||||
|
|
@ -537,6 +540,9 @@ export async function promptBundle(): Promise<void> {
|
|||
selectedBundle = process.env.LIGHTSAIL_BUNDLE;
|
||||
return;
|
||||
}
|
||||
if (process.env.SPAWN_CUSTOM !== "1") {
|
||||
return;
|
||||
}
|
||||
if (process.env.SPAWN_NON_INTERACTIVE === "1") {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1785,6 +1785,9 @@ function runBash(script: string, prompt?: string, debug?: boolean, spawnName?: s
|
|||
if (spawnName) {
|
||||
env.SPAWN_NAME = spawnName;
|
||||
}
|
||||
if (process.env.SPAWN_CUSTOM === "1") {
|
||||
env.SPAWN_CUSTOM = "1";
|
||||
}
|
||||
|
||||
return spawnBash(script, env);
|
||||
}
|
||||
|
|
@ -3274,6 +3277,7 @@ function getHelpUsageSection(): string {
|
|||
spawn Interactive agent + cloud picker
|
||||
spawn <agent> <cloud> Launch agent on cloud directly
|
||||
spawn <agent> <cloud> --dry-run Preview what would be provisioned (or -n)
|
||||
spawn <agent> <cloud> --custom Show interactive size/region pickers
|
||||
spawn <agent> <cloud> --headless Provision and exit (no interactive session)
|
||||
spawn <agent> <cloud> --output json
|
||||
Headless mode with structured JSON on stdout
|
||||
|
|
@ -3357,7 +3361,8 @@ function getHelpEnvVarsSection(): string {
|
|||
${pc.cyan("SPAWN_UNICODE=1")} Force Unicode output (override auto-detection)
|
||||
${pc.cyan("SPAWN_HOME")} Override spawn data directory (default: ~/.spawn)
|
||||
${pc.cyan("SPAWN_DEBUG=1")} Show debug output (unicode detection, etc.)
|
||||
${pc.cyan("SPAWN_HEADLESS=1")} Set automatically in --headless mode (for scripts)`;
|
||||
${pc.cyan("SPAWN_HEADLESS=1")} Set automatically in --headless mode (for scripts)
|
||||
${pc.cyan("SPAWN_CUSTOM=1")} Set automatically in --custom mode (show size/region pickers)`;
|
||||
}
|
||||
|
||||
function getHelpFooterSection(): string {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
toKebabCase,
|
||||
defaultSpawnName,
|
||||
sanitizeTermValue,
|
||||
selectFromList,
|
||||
} from "../shared/ui";
|
||||
import type { CloudInitTier } from "../shared/agents";
|
||||
import { getPackagesForTier, needsNode, needsBun, NODE_INSTALL_CMD } from "../shared/cloud-init";
|
||||
|
|
@ -242,6 +243,70 @@ function sshBaseArgs(): string[] {
|
|||
return args;
|
||||
}
|
||||
|
||||
// ─── Sandbox Size Options ────────────────────────────────────────────────────
|
||||
|
||||
export interface SandboxSize {
|
||||
id: string;
|
||||
cpu: number;
|
||||
memory: number;
|
||||
disk: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const SANDBOX_SIZES: SandboxSize[] = [
|
||||
{
|
||||
id: "small",
|
||||
cpu: 2,
|
||||
memory: 4,
|
||||
disk: 30,
|
||||
label: "2 vCPU \u00b7 4 GiB RAM \u00b7 30 GiB disk",
|
||||
},
|
||||
{
|
||||
id: "medium",
|
||||
cpu: 4,
|
||||
memory: 8,
|
||||
disk: 50,
|
||||
label: "4 vCPU \u00b7 8 GiB RAM \u00b7 50 GiB disk",
|
||||
},
|
||||
{
|
||||
id: "large",
|
||||
cpu: 8,
|
||||
memory: 16,
|
||||
disk: 100,
|
||||
label: "8 vCPU \u00b7 16 GiB RAM \u00b7 100 GiB disk",
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_SANDBOX_SIZE = SANDBOX_SIZES[0];
|
||||
|
||||
export async function promptSandboxSize(): Promise<SandboxSize> {
|
||||
if (process.env.DAYTONA_CPU || process.env.DAYTONA_MEMORY) {
|
||||
const cpu = Number.parseInt(process.env.DAYTONA_CPU || "2", 10);
|
||||
const memory = Number.parseInt(process.env.DAYTONA_MEMORY || "4", 10);
|
||||
const disk = Number.parseInt(process.env.DAYTONA_DISK || "30", 10);
|
||||
return {
|
||||
id: "env",
|
||||
cpu,
|
||||
memory,
|
||||
disk,
|
||||
label: `${cpu} vCPU \u00b7 ${memory} GiB RAM \u00b7 ${disk} GiB disk`,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.env.SPAWN_CUSTOM !== "1") {
|
||||
return DEFAULT_SANDBOX_SIZE;
|
||||
}
|
||||
|
||||
if (process.env.SPAWN_NON_INTERACTIVE === "1") {
|
||||
return DEFAULT_SANDBOX_SIZE;
|
||||
}
|
||||
|
||||
process.stderr.write("\n");
|
||||
const items = SANDBOX_SIZES.map((s) => `${s.id}|${s.label}`);
|
||||
const selectedId = await selectFromList(items, "Daytona sandbox size", DEFAULT_SANDBOX_SIZE.id);
|
||||
return SANDBOX_SIZES.find((s) => s.id === selectedId) || DEFAULT_SANDBOX_SIZE;
|
||||
}
|
||||
|
||||
// ─── Provisioning ────────────────────────────────────────────────────────────
|
||||
|
||||
async function setupSshAccess(): Promise<void> {
|
||||
|
|
@ -273,10 +338,10 @@ async function setupSshAccess(): Promise<void> {
|
|||
logInfo("SSH access ready");
|
||||
}
|
||||
|
||||
export async function createServer(name: string): Promise<void> {
|
||||
const cpu = Number.parseInt(process.env.DAYTONA_CPU || "2", 10);
|
||||
const memory = Number.parseInt(process.env.DAYTONA_MEMORY || "4", 10);
|
||||
const disk = Number.parseInt(process.env.DAYTONA_DISK || "30", 10);
|
||||
export async function createServer(name: string, sandboxSize?: SandboxSize): Promise<void> {
|
||||
const cpu = sandboxSize?.cpu ?? Number.parseInt(process.env.DAYTONA_CPU || "2", 10);
|
||||
const memory = sandboxSize?.memory ?? Number.parseInt(process.env.DAYTONA_MEMORY || "4", 10);
|
||||
const disk = sandboxSize?.disk ?? Number.parseInt(process.env.DAYTONA_DISK || "30", 10);
|
||||
|
||||
logStep(`Creating Daytona sandbox '${name}' (${cpu} vCPU, ${memory} GiB RAM, ${disk} GiB disk)...`);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import {
|
||||
ensureDaytonaToken,
|
||||
promptSpawnName,
|
||||
promptSandboxSize,
|
||||
getServerName,
|
||||
createServer as createDaytonaServer,
|
||||
waitForCloudInit,
|
||||
|
|
@ -11,6 +12,7 @@ import {
|
|||
uploadFile,
|
||||
interactiveSession,
|
||||
} from "./daytona";
|
||||
import type { SandboxSize } from "./daytona";
|
||||
import { resolveAgent } from "./agents";
|
||||
import { saveLaunchCmd } from "../history.js";
|
||||
import { runOrchestration } from "../shared/orchestrate";
|
||||
|
|
@ -26,6 +28,8 @@ async function main() {
|
|||
|
||||
const agent = resolveAgent(agentName);
|
||||
|
||||
let sandboxSize: SandboxSize | undefined;
|
||||
|
||||
const cloud: CloudOrchestrator = {
|
||||
cloudName: "daytona",
|
||||
cloudLabel: "Daytona",
|
||||
|
|
@ -37,9 +41,11 @@ async function main() {
|
|||
await promptSpawnName();
|
||||
await ensureDaytonaToken();
|
||||
},
|
||||
async promptSize() {},
|
||||
async promptSize() {
|
||||
sandboxSize = await promptSandboxSize();
|
||||
},
|
||||
async createServer(name: string) {
|
||||
await createDaytonaServer(name);
|
||||
await createDaytonaServer(name, sandboxSize);
|
||||
},
|
||||
getServerName,
|
||||
async waitForReady() {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
toKebabCase,
|
||||
defaultSpawnName,
|
||||
sanitizeTermValue,
|
||||
selectFromList,
|
||||
} from "../shared/ui";
|
||||
import type { CloudInitTier } from "../shared/agents";
|
||||
import { getPackagesForTier, needsNode, needsBun, NODE_INSTALL_CMD } from "../shared/cloud-init";
|
||||
|
|
@ -621,6 +622,134 @@ export async function ensureSshKey(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── Droplet Size Options ────────────────────────────────────────────────────
|
||||
|
||||
export interface DropletSize {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const DROPLET_SIZES: DropletSize[] = [
|
||||
{
|
||||
id: "s-1vcpu-1gb",
|
||||
label: "1 vCPU \u00b7 1 GB RAM \u00b7 $6/mo",
|
||||
},
|
||||
{
|
||||
id: "s-1vcpu-2gb",
|
||||
label: "1 vCPU \u00b7 2 GB RAM \u00b7 $12/mo",
|
||||
},
|
||||
{
|
||||
id: "s-2vcpu-2gb",
|
||||
label: "2 vCPU \u00b7 2 GB RAM \u00b7 $18/mo",
|
||||
},
|
||||
{
|
||||
id: "s-2vcpu-4gb",
|
||||
label: "2 vCPU \u00b7 4 GB RAM \u00b7 $24/mo",
|
||||
},
|
||||
{
|
||||
id: "s-4vcpu-8gb",
|
||||
label: "4 vCPU \u00b7 8 GB RAM \u00b7 $48/mo",
|
||||
},
|
||||
{
|
||||
id: "s-8vcpu-16gb",
|
||||
label: "8 vCPU \u00b7 16 GB RAM \u00b7 $96/mo",
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_DROPLET_SIZE = "s-2vcpu-4gb";
|
||||
|
||||
// ─── Region Options ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface DoRegion {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const DO_REGIONS: DoRegion[] = [
|
||||
{
|
||||
id: "nyc1",
|
||||
label: "New York 1",
|
||||
},
|
||||
{
|
||||
id: "nyc3",
|
||||
label: "New York 3",
|
||||
},
|
||||
{
|
||||
id: "sfo3",
|
||||
label: "San Francisco 3",
|
||||
},
|
||||
{
|
||||
id: "ams3",
|
||||
label: "Amsterdam 3",
|
||||
},
|
||||
{
|
||||
id: "sgp1",
|
||||
label: "Singapore 1",
|
||||
},
|
||||
{
|
||||
id: "lon1",
|
||||
label: "London 1",
|
||||
},
|
||||
{
|
||||
id: "fra1",
|
||||
label: "Frankfurt 1",
|
||||
},
|
||||
{
|
||||
id: "tor1",
|
||||
label: "Toronto 1",
|
||||
},
|
||||
{
|
||||
id: "blr1",
|
||||
label: "Bangalore 1",
|
||||
},
|
||||
{
|
||||
id: "syd1",
|
||||
label: "Sydney 1",
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_DO_REGION = "nyc3";
|
||||
|
||||
// ─── Interactive Pickers ─────────────────────────────────────────────────────
|
||||
|
||||
export async function promptDropletSize(): Promise<string> {
|
||||
if (process.env.DO_DROPLET_SIZE) {
|
||||
logInfo(`Using droplet size from environment: ${process.env.DO_DROPLET_SIZE}`);
|
||||
return process.env.DO_DROPLET_SIZE;
|
||||
}
|
||||
|
||||
if (process.env.SPAWN_CUSTOM !== "1") {
|
||||
return DEFAULT_DROPLET_SIZE;
|
||||
}
|
||||
|
||||
if (process.env.SPAWN_NON_INTERACTIVE === "1") {
|
||||
return DEFAULT_DROPLET_SIZE;
|
||||
}
|
||||
|
||||
process.stderr.write("\n");
|
||||
const items = DROPLET_SIZES.map((s) => `${s.id}|${s.label}`);
|
||||
return selectFromList(items, "DigitalOcean droplet size", DEFAULT_DROPLET_SIZE);
|
||||
}
|
||||
|
||||
export async function promptDoRegion(): Promise<string> {
|
||||
if (process.env.DO_REGION) {
|
||||
logInfo(`Using region from environment: ${process.env.DO_REGION}`);
|
||||
return process.env.DO_REGION;
|
||||
}
|
||||
|
||||
if (process.env.SPAWN_CUSTOM !== "1") {
|
||||
return DEFAULT_DO_REGION;
|
||||
}
|
||||
|
||||
if (process.env.SPAWN_NON_INTERACTIVE === "1") {
|
||||
return DEFAULT_DO_REGION;
|
||||
}
|
||||
|
||||
process.stderr.write("\n");
|
||||
const items = DO_REGIONS.map((r) => `${r.id}|${r.label}`);
|
||||
return selectFromList(items, "DigitalOcean region", DEFAULT_DO_REGION);
|
||||
}
|
||||
|
||||
// ─── Provisioning ────────────────────────────────────────────────────────────
|
||||
|
||||
function getCloudInitUserdata(tier: CloudInitTier = "full"): string {
|
||||
|
|
@ -649,17 +778,22 @@ function getCloudInitUserdata(tier: CloudInitTier = "full"): string {
|
|||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export async function createServer(name: string, tier?: CloudInitTier): Promise<void> {
|
||||
const size = process.env.DO_DROPLET_SIZE || "s-2vcpu-4gb";
|
||||
const region = process.env.DO_REGION || "nyc3";
|
||||
export async function createServer(
|
||||
name: string,
|
||||
tier?: CloudInitTier,
|
||||
dropletSize?: string,
|
||||
region?: string,
|
||||
): Promise<void> {
|
||||
const size = dropletSize || process.env.DO_DROPLET_SIZE || "s-2vcpu-4gb";
|
||||
const effectiveRegion = region || process.env.DO_REGION || "nyc3";
|
||||
const image = "ubuntu-24-04-x64";
|
||||
|
||||
if (!validateRegionName(region)) {
|
||||
if (!validateRegionName(effectiveRegion)) {
|
||||
logError("Invalid DO_REGION");
|
||||
throw new Error("Invalid region");
|
||||
}
|
||||
|
||||
logStep(`Creating DigitalOcean droplet '${name}' (size: ${size}, region: ${region})...`);
|
||||
logStep(`Creating DigitalOcean droplet '${name}' (size: ${size}, region: ${effectiveRegion})...`);
|
||||
|
||||
// Get all SSH key IDs
|
||||
const { text: keysText } = await doApi("GET", "/account/keys");
|
||||
|
|
@ -671,7 +805,7 @@ export async function createServer(name: string, tier?: CloudInitTier): Promise<
|
|||
const userdata = getCloudInitUserdata(tier);
|
||||
const body = JSON.stringify({
|
||||
name,
|
||||
region,
|
||||
region: effectiveRegion,
|
||||
size,
|
||||
image,
|
||||
ssh_keys: sshKeyIds,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import {
|
|||
ensureDoToken,
|
||||
ensureSshKey,
|
||||
promptSpawnName,
|
||||
promptDropletSize,
|
||||
promptDoRegion,
|
||||
createServer as createDroplet,
|
||||
getServerName,
|
||||
waitForCloudInit,
|
||||
|
|
@ -28,6 +30,9 @@ async function main() {
|
|||
|
||||
const agent = resolveAgent(agentName);
|
||||
|
||||
let dropletSize = "";
|
||||
let region = "";
|
||||
|
||||
const cloud: CloudOrchestrator = {
|
||||
cloudName: "digitalocean",
|
||||
cloudLabel: "DigitalOcean",
|
||||
|
|
@ -44,9 +49,12 @@ async function main() {
|
|||
await new Promise((r) => setTimeout(r, 5000));
|
||||
}
|
||||
},
|
||||
async promptSize() {},
|
||||
async promptSize() {
|
||||
dropletSize = await promptDropletSize();
|
||||
region = await promptDoRegion();
|
||||
},
|
||||
async createServer(name: string) {
|
||||
await createDroplet(name, agent.cloudInitTier);
|
||||
await createDroplet(name, agent.cloudInitTier, dropletSize, region);
|
||||
},
|
||||
getServerName,
|
||||
async waitForReady() {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export const KNOWN_FLAGS = new Set([
|
|||
"--agent",
|
||||
"--cloud",
|
||||
"--clear",
|
||||
"--custom",
|
||||
]);
|
||||
|
||||
/** Return the first unknown flag in args, or null if all are known/positional */
|
||||
|
|
|
|||
|
|
@ -34,6 +34,14 @@ async function promptVmOptions(): Promise<ServerOptions> {
|
|||
};
|
||||
}
|
||||
|
||||
if (process.env.SPAWN_CUSTOM !== "1") {
|
||||
return {
|
||||
cpuKind: DEFAULT_VM_TIER.cpuKind,
|
||||
cpus: DEFAULT_VM_TIER.cpus,
|
||||
memoryMb: DEFAULT_VM_TIER.memoryMb,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.env.SPAWN_NON_INTERACTIVE === "1") {
|
||||
return {
|
||||
cpuKind: DEFAULT_VM_TIER.cpuKind,
|
||||
|
|
|
|||
|
|
@ -487,6 +487,10 @@ export async function promptMachineType(): Promise<string> {
|
|||
return process.env.GCP_MACHINE_TYPE;
|
||||
}
|
||||
|
||||
if (process.env.SPAWN_CUSTOM !== "1") {
|
||||
return DEFAULT_MACHINE_TYPE;
|
||||
}
|
||||
|
||||
if (process.env.SPAWN_NON_INTERACTIVE === "1") {
|
||||
return DEFAULT_MACHINE_TYPE;
|
||||
}
|
||||
|
|
@ -502,6 +506,10 @@ export async function promptZone(): Promise<string> {
|
|||
return process.env.GCP_ZONE;
|
||||
}
|
||||
|
||||
if (process.env.SPAWN_CUSTOM !== "1") {
|
||||
return DEFAULT_ZONE;
|
||||
}
|
||||
|
||||
if (process.env.SPAWN_NON_INTERACTIVE === "1") {
|
||||
return DEFAULT_ZONE;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
toKebabCase,
|
||||
defaultSpawnName,
|
||||
sanitizeTermValue,
|
||||
selectFromList,
|
||||
} from "../shared/ui";
|
||||
import type { CloudInitTier } from "../shared/agents";
|
||||
import { getPackagesForTier, needsNode, needsBun, NODE_INSTALL_CMD } from "../shared/cloud-init";
|
||||
|
|
@ -291,6 +292,114 @@ function getCloudInitUserdata(tier: CloudInitTier = "full"): string {
|
|||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ─── Server Type Options ─────────────────────────────────────────────────────
|
||||
|
||||
export interface ServerTypeTier {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const SERVER_TYPES: ServerTypeTier[] = [
|
||||
{
|
||||
id: "cx22",
|
||||
label: "2 vCPU \u00b7 4 GB RAM \u00b7 40 GB (~\u20AC3.29/mo)",
|
||||
},
|
||||
{
|
||||
id: "cx32",
|
||||
label: "4 vCPU \u00b7 8 GB RAM \u00b7 80 GB (~\u20AC5.39/mo)",
|
||||
},
|
||||
{
|
||||
id: "cx42",
|
||||
label: "8 vCPU \u00b7 16 GB RAM \u00b7 160 GB (~\u20AC14.49/mo)",
|
||||
},
|
||||
{
|
||||
id: "cx52",
|
||||
label: "16 vCPU \u00b7 32 GB RAM \u00b7 320 GB (~\u20AC28.49/mo)",
|
||||
},
|
||||
{
|
||||
id: "cpx21",
|
||||
label: "3 AMD vCPU \u00b7 4 GB RAM \u00b7 80 GB (~\u20AC4.35/mo)",
|
||||
},
|
||||
{
|
||||
id: "cpx31",
|
||||
label: "4 AMD vCPU \u00b7 8 GB RAM \u00b7 160 GB (~\u20AC7.59/mo)",
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_SERVER_TYPE = "cx22";
|
||||
|
||||
// ─── Location Options ────────────────────────────────────────────────────────
|
||||
|
||||
export interface LocationOption {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const LOCATIONS: LocationOption[] = [
|
||||
{
|
||||
id: "fsn1",
|
||||
label: "Falkenstein, Germany",
|
||||
},
|
||||
{
|
||||
id: "nbg1",
|
||||
label: "Nuremberg, Germany",
|
||||
},
|
||||
{
|
||||
id: "hel1",
|
||||
label: "Helsinki, Finland",
|
||||
},
|
||||
{
|
||||
id: "ash",
|
||||
label: "Ashburn, VA, US",
|
||||
},
|
||||
{
|
||||
id: "hil",
|
||||
label: "Hillsboro, OR, US",
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_LOCATION = "nbg1";
|
||||
|
||||
// ─── Interactive Pickers ─────────────────────────────────────────────────────
|
||||
|
||||
export async function promptServerType(): Promise<string> {
|
||||
if (process.env.HETZNER_SERVER_TYPE) {
|
||||
logInfo(`Using server type from environment: ${process.env.HETZNER_SERVER_TYPE}`);
|
||||
return process.env.HETZNER_SERVER_TYPE;
|
||||
}
|
||||
|
||||
if (process.env.SPAWN_CUSTOM !== "1") {
|
||||
return DEFAULT_SERVER_TYPE;
|
||||
}
|
||||
|
||||
if (process.env.SPAWN_NON_INTERACTIVE === "1") {
|
||||
return DEFAULT_SERVER_TYPE;
|
||||
}
|
||||
|
||||
process.stderr.write("\n");
|
||||
const items = SERVER_TYPES.map((t) => `${t.id}|${t.label}`);
|
||||
return selectFromList(items, "Hetzner server type", DEFAULT_SERVER_TYPE);
|
||||
}
|
||||
|
||||
export async function promptLocation(): Promise<string> {
|
||||
if (process.env.HETZNER_LOCATION) {
|
||||
logInfo(`Using location from environment: ${process.env.HETZNER_LOCATION}`);
|
||||
return process.env.HETZNER_LOCATION;
|
||||
}
|
||||
|
||||
if (process.env.SPAWN_CUSTOM !== "1") {
|
||||
return DEFAULT_LOCATION;
|
||||
}
|
||||
|
||||
if (process.env.SPAWN_NON_INTERACTIVE === "1") {
|
||||
return DEFAULT_LOCATION;
|
||||
}
|
||||
|
||||
process.stderr.write("\n");
|
||||
const items = LOCATIONS.map((l) => `${l.id}|${l.label}`);
|
||||
return selectFromList(items, "Hetzner location", DEFAULT_LOCATION);
|
||||
}
|
||||
|
||||
// ─── Provisioning ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createServer(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import {
|
|||
ensureHcloudToken,
|
||||
ensureSshKey,
|
||||
promptSpawnName,
|
||||
promptServerType,
|
||||
promptLocation,
|
||||
createServer as createHetznerServer,
|
||||
getServerName,
|
||||
waitForCloudInit,
|
||||
|
|
@ -27,6 +29,9 @@ async function main() {
|
|||
|
||||
const agent = resolveAgent(agentName);
|
||||
|
||||
let serverType = "";
|
||||
let location = "";
|
||||
|
||||
const cloud: CloudOrchestrator = {
|
||||
cloudName: "hetzner",
|
||||
cloudLabel: "Hetzner Cloud",
|
||||
|
|
@ -39,9 +44,12 @@ async function main() {
|
|||
await ensureHcloudToken();
|
||||
await ensureSshKey();
|
||||
},
|
||||
async promptSize() {},
|
||||
async promptSize() {
|
||||
serverType = await promptServerType();
|
||||
location = await promptLocation();
|
||||
},
|
||||
async createServer(name: string) {
|
||||
await createHetznerServer(name, undefined, undefined, agent.cloudInitTier);
|
||||
await createHetznerServer(name, serverType, location, agent.cloudInitTier);
|
||||
},
|
||||
getServerName,
|
||||
async waitForReady() {
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ function checkUnknownFlags(args: string[]): void {
|
|||
console.error(` ${pc.cyan("--debug")} Show all commands being executed`);
|
||||
console.error(` ${pc.cyan("--headless")} Non-interactive mode (no prompts, no SSH session)`);
|
||||
console.error(` ${pc.cyan("--output json")} Output structured JSON to stdout`);
|
||||
console.error(` ${pc.cyan("--custom")} Show interactive size/region pickers`);
|
||||
console.error(` ${pc.cyan("--name")} Set the spawn/resource name`);
|
||||
console.error(` ${pc.cyan("--help, -h")} Show help information`);
|
||||
console.error(` ${pc.cyan("--version, -v")} Show version`);
|
||||
|
|
@ -703,6 +704,14 @@ async function main(): Promise<void> {
|
|||
filteredArgs.splice(headlessIdx, 1);
|
||||
}
|
||||
|
||||
// Extract --custom boolean flag
|
||||
const customIdx = filteredArgs.indexOf("--custom");
|
||||
const custom = customIdx !== -1;
|
||||
if (custom) {
|
||||
filteredArgs.splice(customIdx, 1);
|
||||
process.env.SPAWN_CUSTOM = "1";
|
||||
}
|
||||
|
||||
// Extract --output <format> flag
|
||||
const [outputFormat, outputFilteredArgs] = extractFlagValue(
|
||||
filteredArgs,
|
||||
|
|
@ -739,6 +748,25 @@ async function main(): Promise<void> {
|
|||
// --output implies --headless
|
||||
const effectiveHeadless = headless || !!outputFormat;
|
||||
|
||||
// Validate --custom + --headless incompatibility
|
||||
if (custom && effectiveHeadless) {
|
||||
if (outputFormat === "json") {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
status: "error",
|
||||
error_code: "VALIDATION_ERROR",
|
||||
error_message: "--custom and --headless cannot be used together",
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
console.error(pc.red("Error: --custom and --headless cannot be used together"));
|
||||
console.error(
|
||||
`\n${pc.cyan("--custom")} enables interactive pickers, but ${pc.cyan("--headless")} disables all prompts.`,
|
||||
);
|
||||
}
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
// Validate headless-incompatible flags
|
||||
if (effectiveHeadless && dryRun) {
|
||||
if (outputFormat === "json") {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue