Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions packages/cli/src/__tests__/do-marketplace.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
3 changes: 1 addition & 2 deletions packages/cli/src/__tests__/feature-flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Comment thread
AhmedTMM marked this conversation as resolved.
expect(expandFastProvisionVariant("test")).toEqual([
"images",
"docker",
]);
});
Expand Down
23 changes: 23 additions & 0 deletions packages/cli/src/digitalocean/digitalocean.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1047,6 +1047,29 @@ export const AGENT_MIN_SIZE: Record<string, string> = {
openclaw: "s-2vcpu-4gb",
};

/** DO marketplace image slugs — hardcoded from vendor portal (approved 2026-03-13) */
export const MARKETPLACE_IMAGES: Record<string, string> = {
claude: "openrouter-spawnclaude",
codex: "openrouter-spawncodex",
openclaw: "openrouter-spawnopenclaw",
opencode: "openrouter-spawnopencode",
kilocode: "openrouter-spawnkilocode",
hermes: "openrouter-spawnhermes",
junie: "openrouter-spawnjunie",
};
Comment thread
AhmedTMM marked this conversation as resolved.

/** 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 {
Expand Down
31 changes: 9 additions & 22 deletions packages/cli/src/digitalocean/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ import {
getConnectionInfo,
getServerName,
interactiveSession,
MARKETPLACE_IMAGES,
promptDoRegion,
promptDropletSize,
promptSpawnName,
resolveMarketplaceImageSlug,
runServer,
slugRamGb,
uploadFile,
Expand All @@ -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<string, string> = {
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) {
Expand Down Expand Up @@ -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);
},
Expand Down
9 changes: 5 additions & 4 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ function checkUnknownFlags(args: string[]): void {
console.error(` ${pc.cyan("--steps <list>")} Comma-separated setup steps to enable`);
console.error(` ${pc.cyan("--repo <slug|url>")} 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`);
Expand Down Expand Up @@ -948,6 +948,7 @@ async function main(): Promise<void> {
const VALID_BETA_FEATURES = new Set([
"tarball",
"images",
"no-images",
"parallel",
"docker",
"recursive",
Expand All @@ -960,7 +961,7 @@ async function main(): Promise<void> {
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`);
Expand All @@ -975,9 +976,9 @@ async function main(): Promise<void> {

// 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) {
Expand Down
1 change: 0 additions & 1 deletion packages/cli/src/shared/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,6 @@ export function getFeatureFlag<T extends string | boolean>(key: string, fallback
export function expandFastProvisionVariant(variant: string): readonly string[] {
if (variant === "test") {
return [
"images",
"docker",
];
}
Expand Down
25 changes: 25 additions & 0 deletions sh/digitalocean/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading