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:
A 2026-02-23 09:29:51 -08:00 committed by GitHub
parent b51b5aa11e
commit 463c7a2efb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 745 additions and 18 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.7.12",
"version": "0.8.0",
"type": "module",
"bin": {
"spawn": "cli.js"

View 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];
}
}

View file

@ -338,6 +338,7 @@ describe("KNOWN_FLAGS completeness", () => {
"--agent",
"--cloud",
"--clear",
"--custom",
];
for (const flag of expected) {
expect(KNOWN_FLAGS.has(flag)).toBe(true);

View file

@ -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;
}

View file

@ -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 {

View file

@ -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)...`);

View file

@ -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() {

View file

@ -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,

View file

@ -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() {

View file

@ -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 */

View file

@ -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,

View file

@ -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;
}

View file

@ -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(

View file

@ -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() {

View file

@ -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") {