diff --git a/packages/cli/src/__tests__/do-marketplace.test.ts b/packages/cli/src/__tests__/do-marketplace.test.ts new file mode 100644 index 000000000..1527d2f27 --- /dev/null +++ b/packages/cli/src/__tests__/do-marketplace.test.ts @@ -0,0 +1,50 @@ +// Unit tests for DO marketplace image resolution (resolveMarketplaceImageSlug). + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { resolveMarketplaceImageSlug } from "../digitalocean/digitalocean.js"; + +describe("resolveMarketplaceImageSlug", () => { + const originalForceUbuntu = process.env.SPAWN_DO_FORCE_UBUNTU; + const originalBeta = process.env.SPAWN_BETA; + + beforeEach(() => { + delete process.env.SPAWN_DO_FORCE_UBUNTU; + delete process.env.SPAWN_BETA; + }); + + afterEach(() => { + if (originalForceUbuntu === undefined) { + delete process.env.SPAWN_DO_FORCE_UBUNTU; + } else { + process.env.SPAWN_DO_FORCE_UBUNTU = originalForceUbuntu; + } + if (originalBeta === undefined) { + delete process.env.SPAWN_BETA; + } else { + process.env.SPAWN_BETA = originalBeta; + } + }); + + it("returns slug for mapped agents with no flags", () => { + expect(resolveMarketplaceImageSlug("claude")).toBe("openrouter-spawnclaude"); + }); + + it("returns undefined for unmapped agents", () => { + expect(resolveMarketplaceImageSlug("cursor")).toBeUndefined(); + }); + + it("returns undefined when SPAWN_DO_FORCE_UBUNTU=1", () => { + process.env.SPAWN_DO_FORCE_UBUNTU = "1"; + expect(resolveMarketplaceImageSlug("claude")).toBeUndefined(); + }); + + it("returns undefined when SPAWN_BETA includes no-images", () => { + process.env.SPAWN_BETA = "no-images"; + expect(resolveMarketplaceImageSlug("claude")).toBeUndefined(); + }); + + it("still returns slug when SPAWN_BETA includes legacy images flag", () => { + process.env.SPAWN_BETA = "images"; + expect(resolveMarketplaceImageSlug("claude")).toBe("openrouter-spawnclaude"); + }); +}); diff --git a/packages/cli/src/__tests__/feature-flags.test.ts b/packages/cli/src/__tests__/feature-flags.test.ts index 4674f4d7b..900b1a941 100644 --- a/packages/cli/src/__tests__/feature-flags.test.ts +++ b/packages/cli/src/__tests__/feature-flags.test.ts @@ -232,9 +232,8 @@ describe("feature flags", () => { // list in feature-flags.ts forces an explicit test update — drifting the // bundle silently is the failure mode we're guarding against. - it("returns the full provisioning-speed bundle for the test variant", () => { + it("returns the docker bundle for the test variant", () => { expect(expandFastProvisionVariant("test")).toEqual([ - "images", "docker", ]); }); diff --git a/packages/cli/src/digitalocean/digitalocean.ts b/packages/cli/src/digitalocean/digitalocean.ts index 9e771d104..0ff15466c 100644 --- a/packages/cli/src/digitalocean/digitalocean.ts +++ b/packages/cli/src/digitalocean/digitalocean.ts @@ -1047,6 +1047,29 @@ export const AGENT_MIN_SIZE: Record = { openclaw: "s-2vcpu-4gb", }; +/** DO marketplace image slugs — hardcoded from vendor portal (approved 2026-03-13) */ +export const MARKETPLACE_IMAGES: Record = { + claude: "openrouter-spawnclaude", + codex: "openrouter-spawncodex", + openclaw: "openrouter-spawnopenclaw", + opencode: "openrouter-spawnopencode", + kilocode: "openrouter-spawnkilocode", + hermes: "openrouter-spawnhermes", + junie: "openrouter-spawnjunie", +}; + +/** Resolve DO marketplace slug for an agent, honoring opt-out flags. */ +export function resolveMarketplaceImageSlug(agentName: string): string | undefined { + if (process.env.SPAWN_DO_FORCE_UBUNTU === "1") { + return undefined; + } + const beta = (process.env.SPAWN_BETA ?? "").split(","); + if (beta.includes("no-images")) { + return undefined; + } + return MARKETPLACE_IMAGES[agentName]; +} + // ─── Region Options ────────────────────────────────────────────────────────── interface DoRegion { diff --git a/packages/cli/src/digitalocean/main.ts b/packages/cli/src/digitalocean/main.ts index 1ebb9cebe..cad5a39d4 100644 --- a/packages/cli/src/digitalocean/main.ts +++ b/packages/cli/src/digitalocean/main.ts @@ -17,9 +17,11 @@ import { getConnectionInfo, getServerName, interactiveSession, + MARKETPLACE_IMAGES, promptDoRegion, promptDropletSize, promptSpawnName, + resolveMarketplaceImageSlug, runServer, slugRamGb, uploadFile, @@ -28,17 +30,6 @@ import { } from "./digitalocean.js"; import { runDigitalOceanReadinessGate } from "./readiness.js"; -/** DO marketplace image slugs — hardcoded from vendor portal (approved 2026-03-13) */ -const MARKETPLACE_IMAGES: Record = { - claude: "openrouter-spawnclaude", - codex: "openrouter-spawncodex", - openclaw: "openrouter-spawnopenclaw", - opencode: "openrouter-spawnopencode", - kilocode: "openrouter-spawnkilocode", - hermes: "openrouter-spawnhermes", - junie: "openrouter-spawnjunie", -}; - async function main() { const agentName = process.argv[2]; if (!agentName) { @@ -81,17 +72,13 @@ async function main() { region = await promptDoRegion(); }, async createServer(name: string) { - // Use pre-built marketplace image when --beta images is active - const betaFeatures = (process.env.SPAWN_BETA ?? "").split(","); - if (betaFeatures.includes("images")) { - const slug = MARKETPLACE_IMAGES[agentName]; - if (slug) { - marketplaceImage = slug; - cloud.skipAgentInstall = true; - logInfo(`Using marketplace image: ${slug}`); - } else { - logInfo(`No marketplace image for ${agentName}, using fresh install`); - } + const slug = resolveMarketplaceImageSlug(agentName); + if (slug) { + marketplaceImage = slug; + cloud.skipAgentInstall = true; + logInfo(`Using marketplace image: ${slug}`); + } else if (!MARKETPLACE_IMAGES[agentName]) { + logInfo(`No marketplace image for ${agentName}, using fresh install`); } return await createDroplet(name, agent.cloudInitTier, dropletSize, region, marketplaceImage); }, diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e81df6047..5fb190643 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -151,7 +151,7 @@ function checkUnknownFlags(args: string[]): void { console.error(` ${pc.cyan("--steps ")} Comma-separated setup steps to enable`); console.error(` ${pc.cyan("--repo ")} Clone a template repo and apply spawn.md`); console.error(` ${pc.cyan("--beta tarball")} Use pre-built tarball for agent install (repeatable)`); - console.error(` ${pc.cyan("--beta images")} Use pre-built DO marketplace images (faster boot)`); + console.error(` ${pc.cyan("--beta no-images")} Force Ubuntu fresh install on DigitalOcean`); console.error(` ${pc.cyan("--beta parallel")} Parallelize server boot with setup prompts`); console.error(` ${pc.cyan("--beta docker")} Use Docker CE app image on Hetzner/GCP (faster boot)`); console.error(` ${pc.cyan("--beta recursive")} Install spawn CLI on VM for recursive spawning`); @@ -948,6 +948,7 @@ async function main(): Promise { const VALID_BETA_FEATURES = new Set([ "tarball", "images", + "no-images", "parallel", "docker", "recursive", @@ -960,7 +961,7 @@ async function main(): Promise { console.error(pc.red(`Unknown beta feature: ${pc.bold(flag)}`)); console.error("\nAvailable beta features:"); console.error(` ${pc.cyan("tarball")} Use pre-built tarball for agent installation`); - console.error(` ${pc.cyan("images")} Use pre-built DO marketplace images (faster boot)`); + console.error(` ${pc.cyan("no-images")} Force Ubuntu fresh install on DigitalOcean`); console.error(` ${pc.cyan("parallel")} Parallelize server boot with setup prompts`); console.error(` ${pc.cyan("docker")} Use Docker CE app image on Hetzner/GCP (faster boot)`); console.error(` ${pc.cyan("skills")} Pre-install MCP servers and tools on the VM`); @@ -975,9 +976,9 @@ async function main(): Promise { // fast_provision experiment: if the user did NOT pass --beta or --fast, // bucket them on the PostHog `fast_provision` flag. The `test` variant - // turns on images + docker by default; control behaves as before. - // - images: pre-built DO marketplace images (cloud-side faster boot) + // turns on docker by default; control behaves as before. // - docker: Docker CE host image on Hetzner/GCP (cloud-side faster boot) + // DO marketplace images are always-on when a slug exists (see resolveMarketplaceImageSlug). // Exposure is captured for both variants so PostHog can compute conversion. // Bundle composition lives in expandFastProvisionVariant() for unit testing. if (!userOptedIntoBeta) { diff --git a/packages/cli/src/shared/feature-flags.ts b/packages/cli/src/shared/feature-flags.ts index c957e2d0b..0883ca892 100644 --- a/packages/cli/src/shared/feature-flags.ts +++ b/packages/cli/src/shared/feature-flags.ts @@ -213,7 +213,6 @@ export function getFeatureFlag(key: string, fallback export function expandFastProvisionVariant(variant: string): readonly string[] { if (variant === "test") { return [ - "images", "docker", ]; } diff --git a/sh/digitalocean/README.md b/sh/digitalocean/README.md index 2f97bce38..aae3bbb86 100644 --- a/sh/digitalocean/README.md +++ b/sh/digitalocean/README.md @@ -74,6 +74,31 @@ bash <(curl -fsSL https://openrouter.ai/labs/spawn/digitalocean/t3code.sh) | `DO_DROPLET_SIZE` | Droplet size slug (see sizes below) | `s-2vcpu-2gb` | | `SPAWN_JSON_READINESS` | Set to `1` with `SPAWN_NON_INTERACTIVE=1` to print machine-readable JSON when readiness is blocked | — | | `SPAWN_CLI_DIR` | Absolute path to the Spawn repo root when developing locally — makes the cloud shim run `packages/cli/src/{cloud}/main.ts` instead of downloading a release bundle | — | +| `SPAWN_DO_FORCE_UBUNTU` | Set to `1` to skip DO marketplace images and use Ubuntu 24.04 + cloud-init (same as `--beta no-images`) | — | + +### Marketplace images (default) + +Spawn uses DigitalOcean 1-click marketplace images when a slug is available. The agent is pre-installed on the image, so provisioning skips cloud-init and agent install — only SSH readiness is waited on. + +| Agent | Marketplace slug | +|---|---| +| claude | `openrouter-spawnclaude` | +| codex | `openrouter-spawncodex` | +| openclaw | `openrouter-spawnopenclaw` | +| opencode | `openrouter-spawnopencode` | +| kilocode | `openrouter-spawnkilocode` | +| hermes | `openrouter-spawnhermes` | +| junie | `openrouter-spawnjunie` | + +Agents without a marketplace mapping (e.g. `cursor`, `pi`, `t3code`) use Ubuntu 24.04 with cloud-init and a full agent install. + +To force a fresh Ubuntu install for testing or E2E: + +```bash +spawn claude digitalocean --beta no-images +# or +SPAWN_DO_FORCE_UBUNTU=1 spawn claude digitalocean +``` ### Pre-flight readiness