diff --git a/cli/package.json b/cli/package.json index 10889bbd..33817d63 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.7.12", + "version": "0.8.0", "type": "module", "bin": { "spawn": "cli.js" diff --git a/cli/src/__tests__/custom-flag.test.ts b/cli/src/__tests__/custom-flag.test.ts new file mode 100644 index 00000000..6921f05c --- /dev/null +++ b/cli/src/__tests__/custom-flag.test.ts @@ -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]; + } +} diff --git a/cli/src/__tests__/unknown-flags.test.ts b/cli/src/__tests__/unknown-flags.test.ts index fd32c089..36ed7933 100644 --- a/cli/src/__tests__/unknown-flags.test.ts +++ b/cli/src/__tests__/unknown-flags.test.ts @@ -338,6 +338,7 @@ describe("KNOWN_FLAGS completeness", () => { "--agent", "--cloud", "--clear", + "--custom", ]; for (const flag of expected) { expect(KNOWN_FLAGS.has(flag)).toBe(true); diff --git a/cli/src/aws/aws.ts b/cli/src/aws/aws.ts index a1c24191..0e8aec08 100644 --- a/cli/src/aws/aws.ts +++ b/cli/src/aws/aws.ts @@ -516,6 +516,9 @@ export async function promptRegion(): Promise { 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 { selectedBundle = process.env.LIGHTSAIL_BUNDLE; return; } + if (process.env.SPAWN_CUSTOM !== "1") { + return; + } if (process.env.SPAWN_NON_INTERACTIVE === "1") { return; } diff --git a/cli/src/commands.ts b/cli/src/commands.ts index 9e4cc3ee..5674a618 100644 --- a/cli/src/commands.ts +++ b/cli/src/commands.ts @@ -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 Launch agent on cloud directly spawn --dry-run Preview what would be provisioned (or -n) + spawn --custom Show interactive size/region pickers spawn --headless Provision and exit (no interactive session) spawn --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 { diff --git a/cli/src/daytona/daytona.ts b/cli/src/daytona/daytona.ts index 7cd24786..727b0ed9 100644 --- a/cli/src/daytona/daytona.ts +++ b/cli/src/daytona/daytona.ts @@ -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 { + 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 { @@ -273,10 +338,10 @@ async function setupSshAccess(): Promise { logInfo("SSH access ready"); } -export async function createServer(name: string): Promise { - 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 { + 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)...`); diff --git a/cli/src/daytona/main.ts b/cli/src/daytona/main.ts index ac3951ab..9ff89bd7 100644 --- a/cli/src/daytona/main.ts +++ b/cli/src/daytona/main.ts @@ -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() { diff --git a/cli/src/digitalocean/digitalocean.ts b/cli/src/digitalocean/digitalocean.ts index 4082f95e..9adf7f02 100644 --- a/cli/src/digitalocean/digitalocean.ts +++ b/cli/src/digitalocean/digitalocean.ts @@ -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 { } } +// ─── 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 { + 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 { + 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 { - 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 { + 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, diff --git a/cli/src/digitalocean/main.ts b/cli/src/digitalocean/main.ts index 137e0b51..2a9fb0b6 100644 --- a/cli/src/digitalocean/main.ts +++ b/cli/src/digitalocean/main.ts @@ -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() { diff --git a/cli/src/flags.ts b/cli/src/flags.ts index c272f74d..4a41b520 100644 --- a/cli/src/flags.ts +++ b/cli/src/flags.ts @@ -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 */ diff --git a/cli/src/fly/main.ts b/cli/src/fly/main.ts index 85abcd71..423ca3ac 100644 --- a/cli/src/fly/main.ts +++ b/cli/src/fly/main.ts @@ -34,6 +34,14 @@ async function promptVmOptions(): Promise { }; } + 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, diff --git a/cli/src/gcp/gcp.ts b/cli/src/gcp/gcp.ts index d7142db8..6e5b2fff 100644 --- a/cli/src/gcp/gcp.ts +++ b/cli/src/gcp/gcp.ts @@ -487,6 +487,10 @@ export async function promptMachineType(): Promise { 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 { return process.env.GCP_ZONE; } + if (process.env.SPAWN_CUSTOM !== "1") { + return DEFAULT_ZONE; + } + if (process.env.SPAWN_NON_INTERACTIVE === "1") { return DEFAULT_ZONE; } diff --git a/cli/src/hetzner/hetzner.ts b/cli/src/hetzner/hetzner.ts index 5081058c..575a39f9 100644 --- a/cli/src/hetzner/hetzner.ts +++ b/cli/src/hetzner/hetzner.ts @@ -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 { + 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 { + 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( diff --git a/cli/src/hetzner/main.ts b/cli/src/hetzner/main.ts index 9a1f5c10..cb9f0b8d 100644 --- a/cli/src/hetzner/main.ts +++ b/cli/src/hetzner/main.ts @@ -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() { diff --git a/cli/src/index.ts b/cli/src/index.ts index d4c703b3..655ad15e 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -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 { 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 flag const [outputFormat, outputFilteredArgs] = extractFlagValue( filteredArgs, @@ -739,6 +748,25 @@ async function main(): Promise { // --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") {