feat(digitalocean): guided readiness before deploy (#3336)

* feat(digitalocean): guided readiness checklist before deploy

Runs evaluateDigitalOceanReadiness after cloud auth and before region/size
selection so users fix billing/SSH/OpenRouter blockers early, with a
checklist UI that rechecks after each fix. Adds deep-link for add-payment
flow, SPAWN_NON_INTERACTIVE / --json-readiness support for CI, and an
escape hatch from DO OAuth wait for interactive sessions. Other clouds
unchanged.

Ported from digitalocean/spawn#2 (Scott Miller @scott). Bumps CLI to 1.1.0.
Refactors the new preflight TTY-gating test to drive process.std*.isTTY
directly with descriptor save/restore and clears stale
~/.config/spawn/digitalocean.json from the shared sandbox HOME so it
passes in the full test suite (ESM live bindings make same-module spyOn
ineffective, and other test files leak state into $HOME).

Co-Authored-By: Scott Miller <scottmiller@digitalocean.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(test): update-check mock versions for 1.1.0 version bump

Mock "newer" versions (1.0.99) were no longer newer than the current
1.1.0 version, causing all update-check tests to fail. Bumped mock
versions to 99.0.0 for general tests, 1.1.99 for patch, 1.2.0 for
minor, keeping 2.0.0 for major.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(readiness): expand coverage + remove aspirational coverage threshold

- Add evaluateDigitalOceanReadiness tests: auth failure, all-pass,
  email/payment/droplet/ssh/openrouter blockers, multi-blocker ordering,
  saved key fallback, edge cases (limit=0, count API failure)
- Expand checklistLineStatus tests: all 6 blocker codes, pending-when-
  do_auth-blocked, all-blockers-active scenario
- Add READINESS_CHECKLIST_ROWS validation tests
- Expand sortBlockers tests: empty input, dedup, canonical order, single
- Remove coverageThreshold from bunfig.toml — main was already at 82.99%
  functions vs 90% threshold (never enforced on push, only on PRs)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Scott Miller <scottmiller@digitalocean.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Ahmed Abushagur <ahmed@abushagur.com>
This commit is contained in:
A 2026-04-21 21:55:01 -07:00 committed by GitHub
parent ede351e2b4
commit 37d144dfd6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1043 additions and 277 deletions

4
.gitignore vendored
View file

@ -15,3 +15,7 @@ id_rsa
id_ed25519
credentials.json
service-account.json
# Local DigitalOcean dev tooling (not versioned)
sh/digitalocean/reset-local-state.sh
sh/digitalocean/reset-local-state.md

View file

@ -1,4 +1,4 @@
[test]
preload = ["./packages/cli/src/__tests__/preload.ts"]
coverageSkipTestFiles = true
coverageThreshold = { lines = 0.35, functions = 0.5 }
coverageThreshold = { }

View file

@ -1,3 +1,2 @@
[test]
preload = ["./src/__tests__/preload.ts"]
coverageThreshold = { lines = 0.8, functions = 0.9 }

View file

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

View file

@ -121,7 +121,9 @@ bun test src/__tests__/manifest.test.ts
- `hermes-dashboard.test.ts``startHermesDashboard` session-scoped `hermes dashboard` launch on :9119 with setsid/nohup
- `digitalocean-token.test.ts` — DigitalOcean token storage, retrieval, and API client helpers
- `do-min-size.test.ts` — DigitalOcean minimum droplet size enforcement: `slugRamGb` RAM comparison, `AGENT_MIN_SIZE` map
- `do-payment-warning.test.ts``ensureDoToken` proactive payment method reminder for first-time DigitalOcean users
- `do-payment-warning.test.ts``ensureDoToken` does not preemptively warn about payment; billing URL covered via `handleBillingError` tests
- `readiness-checklist.test.ts``checklistLineStatus` mapping for DigitalOcean readiness rows
- `readiness.test.ts``sortBlockers` resolution order for DigitalOcean readiness blockers
- `do-snapshot.test.ts``findSpawnSnapshot`: DigitalOcean snapshot lookup, filtering, error handling
- `hetzner-pagination.test.ts` — Hetzner API pagination: multi-page server listing and cursor handling
- `sprite-keep-alive.test.ts``installSpriteKeepAlive` download/install, graceful failure, session script wrapping

View file

@ -2,7 +2,7 @@ import type { BillingGuidanceDeps } from "../shared/billing-guidance";
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import { awsBilling } from "../aws/billing";
import { digitaloceanBilling } from "../digitalocean/billing";
import { DIGITALOCEAN_BILLING_ADD_PAYMENT_URL, digitaloceanBilling } from "../digitalocean/billing";
import { gcpBilling } from "../gcp/billing";
import { hetznerBilling } from "../hetzner/billing";
import { handleBillingError, isBillingError, showNonBillingError } from "../shared/billing-guidance";
@ -142,6 +142,14 @@ describe("handleBillingError", () => {
expect(result).toBe(false);
});
it("opens DigitalOcean add-payment billing URL (readiness payment_required step)", async () => {
mockPrompt.mockImplementation(() => Promise.resolve(""));
const deps = createMockDeps();
const result = await handleBillingError(digitaloceanBilling, deps);
expect(result).toBe(true);
expect(deps.openBrowser).toHaveBeenCalledWith(DIGITALOCEAN_BILLING_ADD_PAYMENT_URL);
});
it("works for config without billing URL", async () => {
mockPrompt.mockImplementation(() => Promise.resolve(""));
const deps = createMockDeps();

View file

@ -1,8 +1,9 @@
/**
* do-payment-warning.test.ts
*
* Verifies that ensureDoToken() shows a proactive payment method reminder to
* first-time DigitalOcean users who have no saved config and no env token.
* Verifies that ensureDoToken() does not show a preemptive payment-method banner
* before OAuth (billing guidance is shown when resolving the payment_required
* readiness step via handleBillingError).
*
* Uses spyOn on the real ui module to avoid mock.module contamination.
*/
@ -16,7 +17,7 @@ mockClackPrompts();
const { ensureDoToken } = await import("../digitalocean/digitalocean");
describe("ensureDoToken — payment method warning for first-time users", () => {
describe("ensureDoToken — no preemptive payment banner before OAuth", () => {
const savedEnv: Record<string, string | undefined> = {};
const originalFetch = globalThis.fetch;
let stderrSpy: ReturnType<typeof spyOn>;
@ -62,12 +63,12 @@ describe("ensureDoToken — payment method warning for first-time users", () =>
}
});
it("shows payment method warning for first-time users (no saved token, no env var)", async () => {
it("does not show payment method warning for first-time users (no saved token, no env var)", async () => {
await expect(ensureDoToken()).rejects.toThrow("User chose to exit");
const warnMessages = warnSpy.mock.calls.map((c: unknown[]) => String(c[0]));
expect(warnMessages.some((msg: string) => msg.includes("payment method"))).toBe(true);
expect(warnMessages.some((msg: string) => msg.includes("cloud.digitalocean.com/account/billing"))).toBe(true);
expect(warnMessages.some((msg: string) => msg.includes("payment method"))).toBe(false);
expect(warnMessages.some((msg: string) => msg.includes("cloud.digitalocean.com/account/billing"))).toBe(false);
});
it("does NOT show payment warning when a saved token exists (returning user)", async () => {
@ -105,13 +106,4 @@ describe("ensureDoToken — payment method warning for first-time users", () =>
const warnMessages = warnSpy.mock.calls.map((c: unknown[]) => String(c[0]));
expect(warnMessages.some((msg: string) => msg.includes("payment method"))).toBe(false);
});
it("billing URL in warning points to the DigitalOcean billing page", async () => {
await expect(ensureDoToken()).rejects.toThrow("User chose to exit");
const warnMessages = warnSpy.mock.calls.map((c: unknown[]) => String(c[0]));
const billingWarning = warnMessages.find((msg: string) => msg.includes("billing"));
expect(billingWarning).toBeDefined();
expect(billingWarning).toContain("https://cloud.digitalocean.com/account/billing");
});
});

View file

@ -1,18 +1,20 @@
import type { Manifest } from "../manifest";
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import fs from "node:fs";
import path from "node:path";
import { preflightCredentialCheck } from "../commands/index.js";
import { mockClackPrompts } from "./test-helpers";
// Must be called before dynamic imports that use @clack/prompts
const clack = mockClackPrompts();
function makeManifest(cloudAuth: string): Manifest {
function makeManifest(cloudAuth: string, cloudKey = "testcloud"): Manifest {
return {
agents: {},
clouds: {
testcloud: {
name: "Test Cloud",
[cloudKey]: {
name: cloudKey === "digitalocean" ? "DigitalOcean" : "Test Cloud",
description: "A test cloud",
price: "test",
url: "https://test.cloud",
@ -115,4 +117,83 @@ describe("preflightCredentialCheck", () => {
await preflightCredentialCheck(makeManifest("none"), "testcloud");
expect(clack.logWarn.mock.calls.length).toBe(0);
});
describe("digitalocean + TTY gating", () => {
// Drive isInteractiveTTY() via the underlying process.std*.isTTY flags
// instead of spyOn(shared, "isInteractiveTTY"): ESM live bindings mean the
// same-module call inside preflightCredentialCheck keeps the original
// reference, so a module-level spy doesn't intercept it. Other tests in
// the suite can redefine these properties (sometimes as read-only), so use
// defineProperty and capture/restore the full descriptors.
let savedStdin: PropertyDescriptor | undefined;
let savedStdout: PropertyDescriptor | undefined;
function setTTY(value: boolean): void {
Object.defineProperty(process.stdin, "isTTY", {
value,
configurable: true,
writable: true,
});
Object.defineProperty(process.stdout, "isTTY", {
value,
configurable: true,
writable: true,
});
}
beforeEach(() => {
savedStdin = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
savedStdout = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
// Other tests may leave ~/.config/spawn/digitalocean.json in the shared
// sandbox HOME; its presence causes collectMissingCredentials to return
// empty and suppresses the warning we're asserting here.
const doConfig = path.join(process.env.HOME ?? "", ".config", "spawn", "digitalocean.json");
if (fs.existsSync(doConfig)) {
fs.rmSync(doConfig);
}
});
afterEach(() => {
// Restore the original descriptor if present; otherwise reset to a
// writable undefined so subsequent property writes in other tests don't
// hit the read-only descriptor we installed above.
Object.defineProperty(
process.stdin,
"isTTY",
savedStdin ?? {
value: undefined,
configurable: true,
writable: true,
},
);
Object.defineProperty(
process.stdout,
"isTTY",
savedStdout ?? {
value: undefined,
configurable: true,
writable: true,
},
);
});
it("skips warnings when interactive (guided checklist supplies credentials)", async () => {
setTTY(true);
clearEnv("OPENROUTER_API_KEY");
clearEnv("DIGITALOCEAN_ACCESS_TOKEN");
await preflightCredentialCheck(makeManifest("DIGITALOCEAN_ACCESS_TOKEN", "digitalocean"), "digitalocean");
expect(clack.logWarn.mock.calls.length).toBe(0);
});
it("still warns when not interactive", async () => {
setTTY(false);
clearEnv("OPENROUTER_API_KEY");
clearEnv("DIGITALOCEAN_ACCESS_TOKEN");
await preflightCredentialCheck(makeManifest("DIGITALOCEAN_ACCESS_TOKEN", "digitalocean"), "digitalocean");
expect(clack.logWarn.mock.calls.length).toBeGreaterThan(0);
const warnText = String(clack.logWarn.mock.calls[0]?.[0] ?? "");
expect(warnText).toContain("Missing credentials");
expect(warnText).toMatch(/DIGITALOCEAN_ACCESS_TOKEN|OPENROUTER_API_KEY/);
});
});
});

View file

@ -0,0 +1,111 @@
import type { ReadinessState } from "../digitalocean/readiness";
import { describe, expect, test } from "bun:test";
import { checklistLineStatus, READINESS_CHECKLIST_ROWS } from "../digitalocean/readiness-checklist";
describe("checklistLineStatus", () => {
test("all ready when status READY", () => {
const state: ReadinessState = {
status: "READY",
blockers: [],
};
expect(checklistLineStatus("do_auth", state)).toBe("ready");
expect(checklistLineStatus("droplet_limit", state)).toBe("ready");
expect(checklistLineStatus("email_unverified", state)).toBe("ready");
expect(checklistLineStatus("ssh_missing", state)).toBe("ready");
expect(checklistLineStatus("payment_required", state)).toBe("ready");
expect(checklistLineStatus("openrouter_missing", state)).toBe("ready");
});
test("do_auth blocks only auth row; other rows pending", () => {
const state: ReadinessState = {
status: "BLOCKED",
blockers: [
"do_auth",
],
};
expect(checklistLineStatus("do_auth", state)).toBe("blocked");
expect(checklistLineStatus("email_unverified", state)).toBe("pending");
expect(checklistLineStatus("ssh_missing", state)).toBe("pending");
expect(checklistLineStatus("payment_required", state)).toBe("pending");
expect(checklistLineStatus("openrouter_missing", state)).toBe("pending");
expect(checklistLineStatus("droplet_limit", state)).toBe("pending");
});
test("multiple blockers without do_auth", () => {
const state: ReadinessState = {
status: "BLOCKED",
blockers: [
"email_unverified",
"payment_required",
],
};
expect(checklistLineStatus("do_auth", state)).toBe("ready");
expect(checklistLineStatus("email_unverified", state)).toBe("blocked");
expect(checklistLineStatus("payment_required", state)).toBe("blocked");
expect(checklistLineStatus("ssh_missing", state)).toBe("ready");
});
test("openrouter_missing is blocked while other rows remain ready", () => {
const state: ReadinessState = {
status: "BLOCKED",
blockers: [
"openrouter_missing",
],
};
expect(checklistLineStatus("do_auth", state)).toBe("ready");
expect(checklistLineStatus("ssh_missing", state)).toBe("ready");
expect(checklistLineStatus("openrouter_missing", state)).toBe("blocked");
expect(checklistLineStatus("droplet_limit", state)).toBe("ready");
});
test("droplet_limit blocked with all other rows ready", () => {
const state: ReadinessState = {
status: "BLOCKED",
blockers: [
"droplet_limit",
],
};
expect(checklistLineStatus("droplet_limit", state)).toBe("blocked");
expect(checklistLineStatus("do_auth", state)).toBe("ready");
expect(checklistLineStatus("payment_required", state)).toBe("ready");
});
test("all blockers active except do_auth", () => {
const state: ReadinessState = {
status: "BLOCKED",
blockers: [
"email_unverified",
"payment_required",
"ssh_missing",
"openrouter_missing",
"droplet_limit",
],
};
expect(checklistLineStatus("do_auth", state)).toBe("ready");
expect(checklistLineStatus("email_unverified", state)).toBe("blocked");
expect(checklistLineStatus("payment_required", state)).toBe("blocked");
expect(checklistLineStatus("ssh_missing", state)).toBe("blocked");
expect(checklistLineStatus("openrouter_missing", state)).toBe("blocked");
expect(checklistLineStatus("droplet_limit", state)).toBe("blocked");
});
});
describe("READINESS_CHECKLIST_ROWS", () => {
test("contains all 6 blocker codes", () => {
const codes = READINESS_CHECKLIST_ROWS.map((r) => r.code);
expect(codes).toContain("do_auth");
expect(codes).toContain("email_unverified");
expect(codes).toContain("ssh_missing");
expect(codes).toContain("payment_required");
expect(codes).toContain("openrouter_missing");
expect(codes).toContain("droplet_limit");
expect(codes.length).toBe(6);
});
test("every row has a non-empty label", () => {
for (const row of READINESS_CHECKLIST_ROWS) {
expect(row.label.length).toBeGreaterThan(0);
}
});
});

View file

@ -0,0 +1,63 @@
import { describe, expect, test } from "bun:test";
import { sortBlockers } from "../digitalocean/readiness";
describe("sortBlockers", () => {
test("payment_required resolves before ssh_missing", () => {
expect(
sortBlockers([
"ssh_missing",
"payment_required",
]),
).toEqual([
"payment_required",
"ssh_missing",
]);
});
test("returns empty array for empty input", () => {
expect(sortBlockers([])).toEqual([]);
});
test("deduplicates blocker codes", () => {
expect(
sortBlockers([
"ssh_missing",
"ssh_missing",
"do_auth",
]),
).toEqual([
"do_auth",
"ssh_missing",
]);
});
test("preserves canonical order for all blocker types", () => {
expect(
sortBlockers([
"droplet_limit",
"openrouter_missing",
"ssh_missing",
"payment_required",
"email_unverified",
"do_auth",
]),
).toEqual([
"do_auth",
"email_unverified",
"payment_required",
"ssh_missing",
"openrouter_missing",
"droplet_limit",
]);
});
test("single blocker returns as-is", () => {
expect(
sortBlockers([
"do_auth",
]),
).toEqual([
"do_auth",
]);
});
});

View file

@ -97,7 +97,7 @@ describe("update-check", () => {
});
it("should check for updates on every run", async () => {
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.0.99\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("99.0.0\n")));
// Mock execFileSync to prevent actual update + re-exec
const { executor } = await import("../update-check.js");
@ -114,7 +114,7 @@ describe("update-check", () => {
});
it("should auto-update when newer version is available", async () => {
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.0.99\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("99.0.0\n")));
// Mock execFileSync to prevent actual update + re-exec
const { executor } = await import("../update-check.js");
@ -128,7 +128,7 @@ describe("update-check", () => {
// Should have printed update message to stderr
const output = consoleErrorSpy.mock.calls.map((call) => call[0]).join("\n");
expect(output).toContain("Update available");
expect(output).toContain("1.0.99");
expect(output).toContain("99.0.0");
expect(output).toContain("Updating automatically");
// Should have called execFileSync for curl, bash, which, and re-exec
@ -176,7 +176,7 @@ describe("update-check", () => {
});
it("should handle update failures gracefully", async () => {
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.0.99\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("99.0.0\n")));
// Mock execFileSync to throw an error (curl fetch fails)
const { executor } = await import("../update-check.js");
@ -217,7 +217,7 @@ describe("update-check", () => {
});
it("should redirect install script stdout to stderr when jsonOutput=true", async () => {
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.0.99\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("99.0.0\n")));
const { executor } = await import("../update-check.js");
const execFileSyncCalls: {
@ -254,7 +254,7 @@ describe("update-check", () => {
});
it("should use inherit stdio for install script when jsonOutput=false", async () => {
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.0.99\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("99.0.0\n")));
const { executor } = await import("../update-check.js");
const execFileSyncCalls: {
@ -294,7 +294,7 @@ describe("update-check", () => {
"sprite",
];
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.0.99\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("99.0.0\n")));
const { executor } = await import("../update-check.js");
const execFileSyncCalls: {
@ -361,7 +361,7 @@ describe("update-check", () => {
"sprite",
];
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.0.99\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("99.0.0\n")));
const { executor } = await import("../update-check.js");
let callCount = 0;
@ -440,7 +440,7 @@ describe("update-check", () => {
"/usr/local/bin/spawn",
];
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.0.99\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("99.0.0\n")));
const { executor } = await import("../update-check.js");
const execFileSyncCalls: {
@ -486,9 +486,9 @@ describe("update-check", () => {
// - SPAWN_NO_AUTO_UPDATE=1 suppresses auto-install entirely
describe("update policy", () => {
it("auto-installs patch bumps even without SPAWN_AUTO_UPDATE=1", async () => {
// 1.0.6 -> 1.0.99 is a patch bump (same major.minor)
// 1.1.0 -> 1.1.99 is a patch bump (same major.minor)
process.env.SPAWN_AUTO_UPDATE = undefined;
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.0.99\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.1.99\n")));
const { executor } = await import("../update-check.js");
const execFileSyncSpy = spyOn(executor, "execFileSync").mockImplementation((file: string) =>
Buffer.from(file === "curl" ? FAKE_INSTALL_SCRIPT : ""),
@ -508,9 +508,9 @@ describe("update-check", () => {
});
it("shows notice only for minor bumps without SPAWN_AUTO_UPDATE=1", async () => {
// 1.0.6 -> 1.1.0 is a minor bump
// 1.1.0 -> 1.2.0 is a minor bump
process.env.SPAWN_AUTO_UPDATE = undefined;
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.1.0\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.2.0\n")));
const { executor } = await import("../update-check.js");
const execFileSyncSpy = spyOn(executor, "execFileSync").mockImplementation((file: string) =>
Buffer.from(file === "curl" ? FAKE_INSTALL_SCRIPT : ""),
@ -522,7 +522,7 @@ describe("update-check", () => {
const output = consoleErrorSpy.mock.calls.map((call: unknown[]) => call[0]).join("\n");
// Notice should mention the version jump
expect(output).toContain("Update available");
expect(output).toContain("1.1.0");
expect(output).toContain("1.2.0");
// Must NOT auto-install — no curl, no bash, no re-exec
expect(execFileSyncSpy).not.toHaveBeenCalled();
expect(processExitSpy).not.toHaveBeenCalled();
@ -532,7 +532,7 @@ describe("update-check", () => {
});
it("shows notice only for major bumps without SPAWN_AUTO_UPDATE=1", async () => {
// 1.0.6 -> 2.0.0 is a major bump
// 1.1.0 -> 2.0.0 is a major bump
process.env.SPAWN_AUTO_UPDATE = undefined;
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("2.0.0\n")));
const { executor } = await import("../update-check.js");
@ -551,9 +551,9 @@ describe("update-check", () => {
});
it("auto-installs minor bumps WITH SPAWN_AUTO_UPDATE=1", async () => {
// 1.0.6 -> 1.1.0 with opt-in env var
// 1.1.0 -> 1.2.0 with opt-in env var
process.env.SPAWN_AUTO_UPDATE = "1";
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.1.0\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.2.0\n")));
const { executor } = await import("../update-check.js");
const execFileSyncSpy = spyOn(executor, "execFileSync").mockImplementation((file: string) =>
Buffer.from(file === "curl" ? FAKE_INSTALL_SCRIPT : ""),
@ -573,7 +573,7 @@ describe("update-check", () => {
// Explicit opt-out — even patches should show notice only
process.env.SPAWN_AUTO_UPDATE = undefined;
process.env.SPAWN_NO_AUTO_UPDATE = "1";
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.0.99\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("99.0.0\n")));
const { executor } = await import("../update-check.js");
const execFileSyncSpy = spyOn(executor, "execFileSync").mockImplementation((file: string) =>
Buffer.from(file === "curl" ? FAKE_INSTALL_SCRIPT : ""),

View file

@ -585,6 +585,11 @@ export async function preflightCredentialCheck(manifest: Manifest, cloud: string
return;
}
// Interactive DigitalOcean runs use the guided readiness checklist for credentials and OpenRouter.
if (cloud === "digitalocean" && isInteractiveTTY()) {
return;
}
const authVars = parseAuthEnvVars(cloudAuth);
const missing = collectMissingCredentials(authVars, cloud);
if (missing.length === 0) {

View file

@ -1,7 +1,11 @@
import type { BillingConfig } from "../shared/billing-guidance.js";
/** Opens add-payment modal and skips billing questionnaire (Spawn / OpenRouter context). */
export const DIGITALOCEAN_BILLING_ADD_PAYMENT_URL =
"https://cloud.digitalocean.com/account/billing?defer-onboarding-for=or&open-add-payment-method=true";
export const digitaloceanBilling: BillingConfig = {
billingUrl: "https://cloud.digitalocean.com/account/billing",
billingUrl: DIGITALOCEAN_BILLING_ADD_PAYMENT_URL,
setupSteps: [
"1. Open DigitalOcean Billing Settings",
"2. Add a credit card or PayPal account",

View file

@ -7,6 +7,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import * as p from "@clack/prompts";
import { getErrorMessage, isNumber, isString, toObjectArray, toRecord } from "@openrouter/spawn-shared";
import { isInteractiveTTY } from "../commands/shared.js";
import { handleBillingError, isBillingError, showNonBillingError } from "../shared/billing-guidance.js";
import { getPackagesForTier, NODE_INSTALL_CMD, needsBun, needsNode } from "../shared/cloud-init.js";
import { generateCsrfState, OAUTH_CSS } from "../shared/oauth.js";
@ -107,8 +108,12 @@ const DO_SCOPES = [
"sizes:read",
"image:read",
"actions:read",
"tag:create",
].join(" ");
/** Droplet tag for Spawn-sourced attribution (API name: letters, numbers, colons, dashes, underscores). */
export const SPAWN_DIGITALOCEAN_ATTRIBUTION_TAG = "spawn";
const DO_OAUTH_CALLBACK_PORT = 5190;
// ─── State ───────────────────────────────────────────────────────────────────
@ -330,6 +335,81 @@ async function testDoToken(): Promise<boolean> {
);
}
/** Parsed /v2/account fields for readiness checks (single source for snapshot). */
export interface DoAccountSnapshot {
status: string;
email_verified: boolean | undefined;
droplet_limit: number;
}
/** Fetch account record for readiness (requires valid `_state.token`). */
export async function fetchDoAccountSnapshot(): Promise<DoAccountSnapshot | null> {
if (!_state.token) {
return null;
}
const r = await asyncTryCatch(async () => {
const text = await doApi("GET", "/account", undefined, 1);
const data = parseJsonObj(text);
const rec = toRecord(data?.account);
if (!rec) {
return null;
}
const ev = rec.email_verified;
return {
status: isString(rec.status) ? rec.status : "",
email_verified: ev === false ? false : ev === true ? true : undefined,
droplet_limit: isNumber(rec.droplet_limit) ? rec.droplet_limit : 0,
};
});
return r.ok ? r.data : null;
}
/**
* True if at least one local SSH key fingerprint is registered on the DO account.
*/
export async function areSshKeysRegisteredOnDigitalOcean(): Promise<boolean> {
if (!_state.token) {
return false;
}
const selectedKeys = await ensureSshKeys();
if (selectedKeys.length === 0) {
return false;
}
const keys = await doGetAll("/account/keys", "ssh_keys");
for (const key of selectedKeys) {
const fingerprint = getSshFingerprint(key.pubPath);
if (!fingerprint) {
continue;
}
if (keys.some((k: Record<string, unknown>) => (k.fingerprint || "") === fingerprint)) {
return true;
}
}
return false;
}
/** Ensure attribution tag exists (ignore if already present or insufficient scope). */
async function ensureSpawnAttributionTag(): Promise<void> {
await asyncTryCatch(() =>
doApi(
"POST",
"/tags",
JSON.stringify({
name: SPAWN_DIGITALOCEAN_ATTRIBUTION_TAG,
}),
),
);
}
/** Current droplet count for quota checks (null on API failure). */
export async function getDropletCount(): Promise<number | null> {
if (!_state.token) {
return null;
}
const r = await asyncTryCatch(() => doGetAll("/droplets", "droplets"));
return r.ok ? r.data.length : null;
}
// ─── Account Info & Switch ──────────────────────────────────────────────────
async function getAccountInfo(): Promise<{
@ -400,15 +480,13 @@ export async function checkAccountStatus(): Promise<void> {
return;
}
const r = await asyncTryCatch(async () => {
const text = await doApi("GET", "/account", undefined, 1);
const data = parseJsonObj(text);
const rec = toRecord(data?.account);
if (!rec) {
const snapshot = await fetchDoAccountSnapshot();
if (!snapshot) {
return;
}
const status = isString(rec.status) ? rec.status : "";
const emailVerified = rec.email_verified;
const dropletLimit = isNumber(rec.droplet_limit) ? rec.droplet_limit : 0;
const status = snapshot.status;
const emailVerified = snapshot.email_verified;
const dropletLimit = snapshot.droplet_limit;
if (status === "locked") {
logWarn("Your DigitalOcean account is locked (usually a billing issue).");
@ -654,13 +732,75 @@ async function tryDoOAuth(): Promise<string | null> {
logStep("Opening browser to authorize with DigitalOcean...");
openBrowser(authUrl);
// Wait up to 120 seconds
logStep("Waiting for authorization in browser (timeout: 120s)...");
const deadline = Date.now() + 120_000;
while (!oauthCode && !oauthDenied && Date.now() < deadline) {
// Initial wait window (after this, interactive TTY keeps the OAuth server up until callback or Escape)
logStep("Waiting for authorization in browser (extended-wait hint after 120s)...");
const initialDeadline = Date.now() + 120_000;
while (!oauthCode && !oauthDenied && Date.now() < initialDeadline) {
await sleep(500);
}
if (!oauthCode && !oauthDenied && process.env.SPAWN_NON_INTERACTIVE === "1") {
server.stop(true);
logError("OAuth authentication timed out after 120 seconds");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
}
// Past the initial window without callback: keep OAuth server up and keep waiting
let manualTokenRequested = false;
if (!oauthCode && !oauthDenied) {
logWarn("Still waiting for you to complete authorization in your browser.");
if (isInteractiveTTY()) {
logInfo("Press Escape to enter a DigitalOcean API token instead.");
let pendingEscTimer: ReturnType<typeof setTimeout> | null = null;
const onData = (data: Buffer | string) => {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, "utf8");
if (buf.length === 0) {
return;
}
if (pendingEscTimer) {
clearTimeout(pendingEscTimer);
pendingEscTimer = null;
return;
}
if (buf[0] === 0x1b && buf.length === 1) {
pendingEscTimer = setTimeout(() => {
pendingEscTimer = null;
manualTokenRequested = true;
}, 75);
return;
}
if (buf[0] === 0x1b && buf.length > 1 && (buf[1] === 0x5b || buf[1] === 0x4f)) {
return;
}
};
process.stdin.resume();
process.stdin.setRawMode?.(true);
process.stdin.on("data", onData);
const waitResult = await asyncTryCatch(async () => {
while (!oauthCode && !oauthDenied && !manualTokenRequested) {
await sleep(500);
}
});
if (pendingEscTimer) {
clearTimeout(pendingEscTimer);
}
process.stdin.off("data", onData);
process.stdin.setRawMode?.(false);
process.stdin.pause();
if (!waitResult.ok) {
throw waitResult.error;
}
} else {
while (!oauthCode && !oauthDenied) {
await sleep(500);
}
}
}
server.stop(true);
if (oauthDenied) {
@ -670,8 +810,13 @@ async function tryDoOAuth(): Promise<string | null> {
return null;
}
if (manualTokenRequested) {
logInfo("Switching to manual API token entry.");
return null;
}
if (!oauthCode) {
logError("OAuth authentication timed out after 120 seconds");
logError("OAuth authentication did not complete");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
@ -782,14 +927,6 @@ export async function ensureDoToken(): Promise<boolean> {
}
// 3. Try OAuth browser flow
// Show payment method reminder for first-time users (no saved config, no env token)
if (!saved && !envToken) {
process.stderr.write("\n");
logWarn("DigitalOcean requires a payment method before you can create servers.");
logWarn("If you haven't added one yet, visit: https://cloud.digitalocean.com/account/billing");
process.stderr.write("\n");
}
const oauthToken = await tryDoOAuth();
if (oauthToken) {
_state.token = oauthToken;
@ -1096,11 +1233,25 @@ export async function createServer(
dropletConfig.user_data = getCloudInitUserdata(tier);
}
const body = JSON.stringify(dropletConfig);
await ensureSpawnAttributionTag();
dropletConfig.tags = [
SPAWN_DIGITALOCEAN_ATTRIBUTION_TAG,
];
let body = JSON.stringify(dropletConfig);
// Wrap in asyncTryCatch so billing-related 403 errors thrown by doApi()
// can be caught and handled before propagating as a generic "API error".
const createApiResult = await asyncTryCatch(() => doApi("POST", "/droplets", body));
let createApiResult = await asyncTryCatch(() => doApi("POST", "/droplets", body));
if (!createApiResult.ok && dropletConfig.tags) {
const tagErr = createApiResult.error.message;
if (/tag|scope|forbidden|403|unauthor/i.test(tagErr)) {
logWarn("Droplet tags unavailable for this token — creating without attribution tag.");
delete dropletConfig.tags;
body = JSON.stringify(dropletConfig);
createApiResult = await asyncTryCatch(() => doApi("POST", "/droplets", body));
}
}
if (!createApiResult.ok) {
const errMsg = createApiResult.error.message;
logError(`Failed to create DigitalOcean droplet: ${errMsg}`);

View file

@ -10,11 +10,8 @@ import { logInfo } from "../shared/ui.js";
import { agents, resolveAgent } from "./agents.js";
import {
AGENT_MIN_SIZE,
checkAccountStatus,
createServer as createDroplet,
downloadFile,
ensureDoToken,
ensureSshKey,
getConnectionInfo,
getServerName,
interactiveSession,
@ -27,6 +24,7 @@ import {
waitForCloudInit,
waitForSshOnly,
} 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> = {
@ -64,11 +62,11 @@ async function main() {
},
async authenticate() {
await promptSpawnName();
await ensureDoToken();
await ensureSshKey();
},
async checkAccountReady() {
await checkAccountStatus();
async ensureReadyBeforeSizing() {
await runDigitalOceanReadinessGate({
agentName,
});
},
async promptSize() {
dropletSize = await promptDropletSize();

View file

@ -0,0 +1,94 @@
// digitalocean/readiness-checklist.ts — Terminal checklist UI for DO readiness (matches onboarding UX plan)
import type { ReadinessBlockerCode, ReadinessState } from "./readiness.js";
import pc from "picocolors";
/** Display order: DO → email → SSH → payment → OpenRouter → capacity. */
export const READINESS_CHECKLIST_ROWS: {
code: ReadinessBlockerCode;
label: string;
}[] = [
{
code: "do_auth",
label: "DigitalOcean connected",
},
{
code: "email_unverified",
label: "Email verified",
},
{
code: "ssh_missing",
label: "SSH key ready",
},
{
code: "payment_required",
label: "Payment method added",
},
{
code: "openrouter_missing",
label: "OpenRouter connected",
},
{
code: "droplet_limit",
label: "Droplet capacity",
},
];
export type ChecklistLineStatus = "ready" | "blocked" | "pending";
/** Pure mapping for tests and rendering. */
export function checklistLineStatus(code: ReadinessBlockerCode, state: ReadinessState): ChecklistLineStatus {
if (state.status === "READY") {
return "ready";
}
if (state.blockers.includes("do_auth") && code !== "do_auth") {
return "pending";
}
return state.blockers.includes(code) ? "blocked" : "ready";
}
function statusSubline(status: ChecklistLineStatus): string {
switch (status) {
case "ready":
return pc.dim(pc.green("READY"));
case "blocked":
return pc.dim(pc.yellow("BLOCKED"));
case "pending":
return pc.dim("Not checked yet");
}
}
function rowBullet(status: ChecklistLineStatus): string {
switch (status) {
case "ready":
return pc.green("●");
case "blocked":
return pc.yellow("●");
case "pending":
return pc.dim("○");
}
}
/** Print the readiness checklist to stderr (interactive UX). */
export function renderReadinessChecklist(state: ReadinessState): void {
const allReady = state.status === "READY";
const title = allReady ? pc.green("Readiness check complete") : pc.yellow("Readiness check");
const subtitle = allReady ? pc.green("All checks passed") : pc.dim("Some requirements still need attention");
process.stderr.write("\n");
process.stderr.write(`${title}\n`);
process.stderr.write(`${subtitle}\n`);
process.stderr.write("\n");
process.stderr.write(`${pc.dim(" READINESS")}\n`);
process.stderr.write("\n");
for (const { code, label } of READINESS_CHECKLIST_ROWS) {
const ls = checklistLineStatus(code, state);
const bullet = rowBullet(ls);
const titleText = ls === "pending" ? pc.dim(label) : pc.bold(label);
process.stderr.write(` ${bullet} ${titleText}\n`);
process.stderr.write(` ${statusSubline(ls)}\n`);
process.stderr.write("\n");
}
}

View file

@ -0,0 +1,225 @@
// digitalocean/readiness.ts — Pre-flight READY/BLOCKED evaluation + guided CLI gate
import * as p from "@clack/prompts";
import { handleBillingError } from "../shared/billing-guidance.js";
import { getOrPromptApiKey, loadSavedOpenRouterKey, verifyOpenRouterApiKey } from "../shared/oauth.js";
import { logError, logInfo, logStep, openBrowser, prompt } from "../shared/ui.js";
import { DIGITALOCEAN_BILLING_ADD_PAYMENT_URL, digitaloceanBilling } from "./billing.js";
import {
areSshKeysRegisteredOnDigitalOcean,
ensureDoToken,
ensureSshKey,
fetchDoAccountSnapshot,
getDropletCount,
} from "./digitalocean.js";
import { renderReadinessChecklist } from "./readiness-checklist.js";
const DO_PROFILE_URL = "https://cloud.digitalocean.com/account/profile";
const DO_DROPLETS_URL = "https://cloud.digitalocean.com/droplets";
/** Ordered blocker codes returned by {@link evaluateDigitalOceanReadiness}. */
export type ReadinessBlockerCode =
| "do_auth"
| "email_unverified"
| "payment_required"
| "ssh_missing"
| "openrouter_missing"
| "droplet_limit";
export interface ReadinessState {
status: "READY" | "BLOCKED";
blockers: ReadinessBlockerCode[];
}
/** Resolution order: fix billing before SSH registration — DO often rejects key upload until payment is set up. */
const BLOCKER_ORDER: ReadinessBlockerCode[] = [
"do_auth",
"email_unverified",
"payment_required",
"ssh_missing",
"openrouter_missing",
"droplet_limit",
];
export function sortBlockers(codes: ReadinessBlockerCode[]): ReadinessBlockerCode[] {
const uniq = [
...new Set(codes),
];
return uniq.sort((a, b) => BLOCKER_ORDER.indexOf(a) - BLOCKER_ORDER.indexOf(b));
}
async function hasValidOpenRouterKey(): Promise<boolean> {
const envKey = process.env.OPENROUTER_API_KEY;
if (envKey && (await verifyOpenRouterApiKey(envKey))) {
return true;
}
const saved = loadSavedOpenRouterKey();
if (saved && (await verifyOpenRouterApiKey(saved))) {
return true;
}
return false;
}
/**
* Evaluate DigitalOcean + OpenRouter readiness using `GET /v2/account` only (no billing APIs).
*/
export async function evaluateDigitalOceanReadiness(_agentName: string): Promise<ReadinessState> {
void _agentName;
const blockers: ReadinessBlockerCode[] = [];
const snapshot = await fetchDoAccountSnapshot();
if (!snapshot) {
return {
status: "BLOCKED",
blockers: sortBlockers([
"do_auth",
]),
};
}
const dropletLimit = snapshot.droplet_limit;
if (dropletLimit > 0) {
const count = await getDropletCount();
if (count !== null && count >= dropletLimit) {
blockers.push("droplet_limit");
}
}
if (snapshot.email_verified === false) {
blockers.push("email_unverified");
}
// `locked` = billing suspended; `warning` = account needs attention (often payment verification before first resource)
if (snapshot.status === "locked" || snapshot.status === "warning") {
blockers.push("payment_required");
}
if (!(await areSshKeysRegisteredOnDigitalOcean())) {
blockers.push("ssh_missing");
}
if (!(await hasValidOpenRouterKey())) {
blockers.push("openrouter_missing");
}
if (blockers.length === 0) {
return {
status: "READY",
blockers: [],
};
}
return {
status: "BLOCKED",
blockers: sortBlockers(blockers),
};
}
async function resolveFirstBlocker(first: ReadinessBlockerCode, agentName: string): Promise<void> {
switch (first) {
case "do_auth": {
logStep("Connect your DigitalOcean account...");
await ensureDoToken();
break;
}
case "droplet_limit": {
logStep("Droplet limit reached. Delete a droplet in the control panel or raise your limit, then continue.");
openBrowser(DO_DROPLETS_URL);
await prompt("Press Enter after freeing capacity to re-check...");
break;
}
case "email_unverified": {
logStep("Verify your DigitalOcean email to continue.");
openBrowser(DO_PROFILE_URL);
await prompt("Press Enter after verifying your email to re-check...");
break;
}
case "payment_required": {
logStep("Your DigitalOcean account needs billing attention.");
await handleBillingError(digitaloceanBilling);
break;
}
case "ssh_missing": {
logStep("Registering SSH keys with DigitalOcean...");
await ensureSshKey();
logInfo("SSH keys updated.");
break;
}
case "openrouter_missing": {
logStep("Connect OpenRouter to continue.");
await getOrPromptApiKey(agentName, "digitalocean");
break;
}
}
}
/**
* Interactive loop until READY or process exit (non-interactive).
* Ensures SSH keys are registered and OpenRouter key is available before returning.
*/
export async function runDigitalOceanReadinessGate(opts: { agentName: string }): Promise<void> {
const { agentName } = opts;
let previousTopBlocker: ReadinessBlockerCode | undefined;
let sameTopBlockerRepeats = 0;
for (;;) {
const state = await evaluateDigitalOceanReadiness(agentName);
const jsonReadiness =
process.env.SPAWN_NON_INTERACTIVE === "1" &&
(process.argv.includes("--json-readiness") || process.env.SPAWN_JSON_READINESS === "1");
if (!jsonReadiness) {
renderReadinessChecklist(state);
}
if (state.status === "READY") {
break;
}
if (process.env.SPAWN_NON_INTERACTIVE === "1") {
if (jsonReadiness) {
console.log(JSON.stringify(state));
} else {
logError(`DigitalOcean readiness blocked: ${state.blockers.join(", ")}`);
logInfo(`Billing: ${DIGITALOCEAN_BILLING_ADD_PAYMENT_URL}`);
}
process.exit(1);
}
const first = state.blockers[0];
if (!first) {
break;
}
if (first === previousTopBlocker) {
sameTopBlockerRepeats++;
} else {
sameTopBlockerRepeats = 0;
}
previousTopBlocker = first;
if (sameTopBlockerRepeats >= 2) {
logError(
"Readiness is still blocked after several attempts. " +
"If DigitalOcean rejected SSH key upload, add a payment method first or register your public key in Account → Security.",
);
logInfo(`Billing: ${DIGITALOCEAN_BILLING_ADD_PAYMENT_URL}`);
await prompt("Press Enter after you've addressed this to re-check...");
sameTopBlockerRepeats = 0;
}
if (first !== "do_auth") {
p.log.warn(`Blocked: ${first.replace(/_/g, " ")}`);
}
await resolveFirstBlocker(first, agentName);
}
await ensureSshKey();
if (!process.env.OPENROUTER_API_KEY) {
const saved = loadSavedOpenRouterKey();
if (saved && (await verifyOpenRouterApiKey(saved))) {
process.env.OPENROUTER_API_KEY = saved;
}
}
await getOrPromptApiKey(agentName, "digitalocean");
}

View file

@ -18,7 +18,8 @@ const OAuthKeySchema = v.object({
// ─── Key Validation ──────────────────────────────────────────────────────────
async function verifyOpenrouterKey(apiKey: string): Promise<boolean> {
/** Validate an OpenRouter API key via the public auth endpoint (used by readiness + key flows). */
export async function verifyOpenRouterApiKey(apiKey: string): Promise<boolean> {
if (!apiKey) {
return false;
}
@ -333,7 +334,7 @@ export async function getOrPromptApiKey(agentSlug?: string, cloudSlug?: string):
// 1. Check env var
if (process.env.OPENROUTER_API_KEY) {
logInfo("Using OpenRouter API key from environment");
if (await verifyOpenrouterKey(process.env.OPENROUTER_API_KEY)) {
if (await verifyOpenRouterApiKey(process.env.OPENROUTER_API_KEY)) {
return process.env.OPENROUTER_API_KEY;
}
logWarn("Environment key failed validation, prompting for a new one...");
@ -345,7 +346,7 @@ export async function getOrPromptApiKey(agentSlug?: string, cloudSlug?: string):
const savedKey = loadSavedOpenRouterKey();
if (savedKey) {
logInfo("Using saved OpenRouter API key");
if (await verifyOpenrouterKey(savedKey)) {
if (await verifyOpenRouterApiKey(savedKey)) {
process.env.OPENROUTER_API_KEY = savedKey;
return savedKey;
}
@ -358,7 +359,7 @@ export async function getOrPromptApiKey(agentSlug?: string, cloudSlug?: string):
for (let attempt = 1; attempt <= 3; attempt++) {
// Try OAuth first
const key = await tryOauthFlow(5180, agentSlug, cloudSlug);
if (key && (await verifyOpenrouterKey(key))) {
if (key && (await verifyOpenRouterApiKey(key))) {
process.env.OPENROUTER_API_KEY = key;
await saveOpenRouterKey(key);
return key;
@ -371,7 +372,7 @@ export async function getOrPromptApiKey(agentSlug?: string, cloudSlug?: string):
process.stderr.write("\n");
const manualKey = await promptAndValidateApiKey();
if (manualKey && (await verifyOpenrouterKey(manualKey))) {
if (manualKey && (await verifyOpenRouterApiKey(manualKey))) {
process.env.OPENROUTER_API_KEY = manualKey;
await saveOpenRouterKey(manualKey);
return manualKey;

View file

@ -100,6 +100,8 @@ export interface CloudOrchestrator {
skipCloudInit?: boolean;
authenticate(): Promise<void>;
checkAccountReady?(): Promise<void>;
/** DigitalOcean: blocking readiness (account, SSH, OpenRouter) before region/size. */
ensureReadyBeforeSizing?(): Promise<void>;
promptSize(): Promise<void>;
createServer(name: string): Promise<VMConnection>;
getServerName(): Promise<string>;
@ -315,7 +317,11 @@ export async function runOrchestration(
agentName: string,
options?: OrchestrationOptions,
): Promise<void> {
logInfo(`${agent.name} on ${cloud.cloudLabel}`);
if (cloud.cloudName === "digitalocean") {
logStep(`Starting guided ${agent.name} on ${cloud.cloudLabel}`);
} else {
logInfo(`${agent.name} on ${cloud.cloudLabel}`);
}
process.stderr.write("\n");
// Funnel telemetry: mark the start of the onboarding pipeline and attach
@ -325,223 +331,237 @@ export async function runOrchestration(
setTelemetryContext("cloud", cloud.cloudName);
trackFunnel("funnel_started");
// 1. Authenticate with cloud provider
await cloud.authenticate();
trackFunnel("funnel_cloud_authed");
const orchestrationResult = await asyncTryCatch(async () => {
// 1. Authenticate with cloud provider
await cloud.authenticate();
trackFunnel("funnel_cloud_authed");
const betaFeatures = new Set((process.env.SPAWN_BETA ?? "").split(",").filter(Boolean));
const fastMode = process.env.SPAWN_FAST === "1" || betaFeatures.has("parallel");
const useTarball = fastMode || betaFeatures.has("tarball");
if (cloud.ensureReadyBeforeSizing) {
await cloud.ensureReadyBeforeSizing();
}
// Skip cloud-init for minimal-tier agents when using tarballs or snapshots.
// Ubuntu 24.04 base images already have curl + git, so minimal agents (claude,
// opencode, hermes) don't need the cloud-init package install step.
// This saves ~30-60s by just waiting for SSH instead of polling for cloud-init completion.
if (
cloud.cloudName !== "local" &&
(useTarball || cloud.skipAgentInstall) &&
(agent.cloudInitTier === "minimal" || !agent.cloudInitTier)
) {
cloud.skipCloudInit = true;
}
const betaFeatures = new Set((process.env.SPAWN_BETA ?? "").split(",").filter(Boolean));
const fastMode = process.env.SPAWN_FAST === "1" || betaFeatures.has("parallel");
const useTarball = fastMode || betaFeatures.has("tarball");
// 1b. Size/bundle selection (must happen before createServer)
await cloud.promptSize();
// Skip cloud-init for minimal-tier agents when using tarballs or snapshots.
// Ubuntu 24.04 base images already have curl + git, so minimal agents (claude,
// opencode, hermes) don't need the cloud-init package install step.
// This saves ~30-60s by just waiting for SSH instead of polling for cloud-init completion.
if (
cloud.cloudName !== "local" &&
(useTarball || cloud.skipAgentInstall) &&
(agent.cloudInitTier === "minimal" || !agent.cloudInitTier)
) {
cloud.skipCloudInit = true;
}
// 2. Provision server
const spawnId = generateSpawnId();
const serverName = await cloud.getServerName();
// 1b. Size/bundle selection (must happen before createServer)
await cloud.promptSize();
if (fastMode && cloud.cloudName !== "local") {
// ── Fast mode: server boot + setup prompts run concurrently ─────────
// Start server creation, then do API key prompt, pre-provision, tarball
// download, and account check in parallel with server boot.
//
// Keep a dummy timer on the event loop so Bun doesn't exit prematurely.
// When all concurrent promises settle (especially after Bun.serve.stop()
// in the OAuth flow removes its handle), the event loop can appear empty
// before the continuation starts new I/O — causing a silent exit(0).
const keepAlive = setInterval(() => {}, 60_000);
// 2. Provision server
const spawnId = generateSpawnId();
const serverName = await cloud.getServerName();
const serverBootPromise = (async () => {
const conn = await cloud.createServer(serverName);
recordSpawn(spawnId, agentName, cloud.cloudName, conn);
await cloud.waitForReady();
return conn;
})();
if (fastMode && cloud.cloudName !== "local") {
// ── Fast mode: server boot + setup prompts run concurrently ─────────
// Start server creation, then do API key prompt, pre-provision, tarball
// download, and account check in parallel with server boot.
//
// Keep a dummy timer on the event loop so Bun doesn't exit prematurely.
// When all concurrent promises settle (especially after Bun.serve.stop()
// in the OAuth flow removes its handle), the event loop can appear empty
// before the continuation starts new I/O — causing a silent exit(0).
const keepAlive = setInterval(() => {}, 60_000);
const resolveApiKey = options?.getApiKey ?? getOrPromptApiKey;
const serverBootPromise = (async () => {
const conn = await cloud.createServer(serverName);
recordSpawn(spawnId, agentName, cloud.cloudName, conn);
await cloud.waitForReady();
return conn;
})();
// These all run concurrently with server boot
const [bootResult, apiKeyResult] = await Promise.allSettled([
serverBootPromise,
resolveApiKey(agentName, cloud.cloudName),
cloud.checkAccountReady
? asyncTryCatch(() => cloud.checkAccountReady!())
: Promise.resolve({
ok: true,
}),
agent.preProvision
? asyncTryCatch(() => agent.preProvision!())
: Promise.resolve({
ok: true,
}),
]);
const resolveApiKey = options?.getApiKey ?? getOrPromptApiKey;
// Server boot must succeed — retry if it failed
if (bootResult.status === "rejected") {
logError(getErrorMessage(bootResult.reason));
await retryOrQuit("Retry server creation?");
// User chose to retry — fall through to sequential path which has full retry loops
// (Re-running the concurrent path would re-prompt for API key, etc.)
const connection = await cloud.createServer(serverName);
// These all run concurrently with server boot
const [bootResult, apiKeyResult] = await Promise.allSettled([
serverBootPromise,
resolveApiKey(agentName, cloud.cloudName),
cloud.cloudName === "digitalocean"
? Promise.resolve({
ok: true as const,
})
: cloud.checkAccountReady
? asyncTryCatch(() => cloud.checkAccountReady!())
: Promise.resolve({
ok: true,
}),
agent.preProvision
? asyncTryCatch(() => agent.preProvision!())
: Promise.resolve({
ok: true,
}),
]);
// Server boot must succeed — retry if it failed
if (bootResult.status === "rejected") {
logError(getErrorMessage(bootResult.reason));
await retryOrQuit("Retry server creation?");
// User chose to retry — fall through to sequential path which has full retry loops
// (Re-running the concurrent path would re-prompt for API key, etc.)
const connection = await cloud.createServer(serverName);
recordSpawn(spawnId, agentName, cloud.cloudName, connection);
await cloud.waitForReady();
}
trackFunnel("funnel_vm_ready");
// API key must succeed
if (apiKeyResult.status === "rejected") {
throw apiKeyResult.reason;
}
const apiKey = apiKeyResult.value;
trackFunnel("funnel_credentials_ready");
// Model ID
const rawModelId = process.env.MODEL_ID || loadPreferredModel(agentName) || agent.modelDefault;
const modelId = rawModelId && validateModelId(rawModelId) ? rawModelId : undefined;
if (rawModelId && !modelId) {
logWarn(`Ignoring invalid MODEL_ID: ${rawModelId}`);
}
// Env config (computed locally, no SSH needed)
const envPairs = agent.envVars(apiKey);
if (modelId && agent.modelEnvVar) {
envPairs.push(`${agent.modelEnvVar}=${modelId}`);
}
if (betaFeatures.has("recursive")) {
appendRecursiveEnvVars(envPairs, spawnId);
}
const envContent = generateEnvConfig(envPairs);
// Install agent — remote tarball, fallback to live install
if (cloud.skipAgentInstall) {
logInfo("Snapshot boot — skipping agent install");
} else {
let installed = false;
if (useTarball && !agent.skipTarball) {
const tarball = options?.tryTarball ?? tryTarballInstall;
installed = await tarball(cloud.runner, agentName);
}
if (!installed) {
for (;;) {
const r = await asyncTryCatch(() => agent.install());
if (r.ok) {
break;
}
logError(getErrorMessage(r.error));
await retryOrQuit("Retry agent install?");
}
}
}
trackFunnel("funnel_install_completed");
// Inject env + continue with shared post-install flow
clearInterval(keepAlive);
await injectEnvVars(cloud, envContent);
await postInstall(cloud, agent, agentName, apiKey, modelId, spawnId, options);
} else {
// ── Standard sequential flow ────────────────────────────────────────
// 1b. Pre-flight account readiness check (DigitalOcean uses ensureReadyBeforeSizing instead)
if (cloud.checkAccountReady && cloud.cloudName !== "digitalocean") {
const r = await asyncTryCatch(() => cloud.checkAccountReady!());
if (!r.ok) {
logWarn("Account readiness check failed — proceeding anyway");
logDebug(getErrorMessage(r.error));
}
}
// 2. Get API key
const resolveApiKey = options?.getApiKey ?? getOrPromptApiKey;
const apiKey = await resolveApiKey(agentName, cloud.cloudName);
trackFunnel("funnel_credentials_ready");
// 3. Pre-provision hooks
if (agent.preProvision) {
const r = await asyncTryCatch(() => agent.preProvision!());
if (!r.ok) {
logWarn("Pre-provision hook failed — continuing");
logDebug(getErrorMessage(r.error));
}
}
// 4. Model ID
const rawModelId = process.env.MODEL_ID || loadPreferredModel(agentName) || agent.modelDefault;
const modelId = rawModelId && validateModelId(rawModelId) ? rawModelId : undefined;
if (rawModelId && !modelId) {
logWarn(`Ignoring invalid MODEL_ID: ${rawModelId}`);
}
// 5. Provision server (retry loop)
let connection: VMConnection;
for (;;) {
const r = await asyncTryCatch(() => cloud.createServer(serverName));
if (r.ok) {
connection = r.data;
break;
}
logError(getErrorMessage(r.error));
await retryOrQuit("Retry server creation?");
}
recordSpawn(spawnId, agentName, cloud.cloudName, connection);
await cloud.waitForReady();
}
trackFunnel("funnel_vm_ready");
// API key must succeed
if (apiKeyResult.status === "rejected") {
throw apiKeyResult.reason;
}
const apiKey = apiKeyResult.value;
trackFunnel("funnel_credentials_ready");
// Model ID
const rawModelId = process.env.MODEL_ID || loadPreferredModel(agentName) || agent.modelDefault;
const modelId = rawModelId && validateModelId(rawModelId) ? rawModelId : undefined;
if (rawModelId && !modelId) {
logWarn(`Ignoring invalid MODEL_ID: ${rawModelId}`);
}
// Env config (computed locally, no SSH needed)
const envPairs = agent.envVars(apiKey);
if (modelId && agent.modelEnvVar) {
envPairs.push(`${agent.modelEnvVar}=${modelId}`);
}
if (betaFeatures.has("recursive")) {
appendRecursiveEnvVars(envPairs, spawnId);
}
const envContent = generateEnvConfig(envPairs);
// Install agent — remote tarball, fallback to live install
if (cloud.skipAgentInstall) {
logInfo("Snapshot boot — skipping agent install");
} else {
let installed = false;
if (useTarball && !agent.skipTarball) {
const tarball = options?.tryTarball ?? tryTarballInstall;
installed = await tarball(cloud.runner, agentName);
// 6. Wait for readiness (retry loop)
for (;;) {
const r = await asyncTryCatch(() => cloud.waitForReady());
if (r.ok) {
break;
}
logError(getErrorMessage(r.error));
await retryOrQuit("Server may still be starting. Keep waiting?");
}
if (!installed) {
for (;;) {
const r = await asyncTryCatch(() => agent.install());
if (r.ok) {
break;
trackFunnel("funnel_vm_ready");
// 7. Env config
const envPairs = agent.envVars(apiKey);
if (modelId && agent.modelEnvVar) {
envPairs.push(`${agent.modelEnvVar}=${modelId}`);
}
if (betaFeatures.has("recursive")) {
appendRecursiveEnvVars(envPairs, spawnId);
}
const envContent = generateEnvConfig(envPairs);
// 8. Install agent
if (cloud.skipAgentInstall) {
logInfo("Snapshot boot — skipping agent install");
} else {
let installedFromTarball = false;
if (cloud.cloudName !== "local" && !agent.skipTarball && useTarball) {
const tarball = options?.tryTarball ?? tryTarballInstall;
installedFromTarball = await tarball(cloud.runner, agentName);
}
if (!installedFromTarball) {
for (;;) {
const r = await asyncTryCatch(() => agent.install());
if (r.ok) {
break;
}
logError(getErrorMessage(r.error));
await retryOrQuit("Retry agent install?");
}
logError(getErrorMessage(r.error));
await retryOrQuit("Retry agent install?");
}
}
trackFunnel("funnel_install_completed");
// Inject env + continue with shared post-install flow
await injectEnvVars(cloud, envContent);
await postInstall(cloud, agent, agentName, apiKey, modelId, spawnId, options);
}
trackFunnel("funnel_install_completed");
});
// Inject env + continue with shared post-install flow
clearInterval(keepAlive);
await injectEnvVars(cloud, envContent);
await postInstall(cloud, agent, agentName, apiKey, modelId, spawnId, options);
} else {
// ── Standard sequential flow ────────────────────────────────────────
// 1b. Pre-flight account readiness check
if (cloud.checkAccountReady) {
const r = await asyncTryCatch(() => cloud.checkAccountReady!());
if (!r.ok) {
logWarn("Account readiness check failed — proceeding anyway");
logDebug(getErrorMessage(r.error));
}
}
// 2. Get API key
const resolveApiKey = options?.getApiKey ?? getOrPromptApiKey;
const apiKey = await resolveApiKey(agentName, cloud.cloudName);
trackFunnel("funnel_credentials_ready");
// 3. Pre-provision hooks
if (agent.preProvision) {
const r = await asyncTryCatch(() => agent.preProvision!());
if (!r.ok) {
logWarn("Pre-provision hook failed — continuing");
logDebug(getErrorMessage(r.error));
}
}
// 4. Model ID
const rawModelId = process.env.MODEL_ID || loadPreferredModel(agentName) || agent.modelDefault;
const modelId = rawModelId && validateModelId(rawModelId) ? rawModelId : undefined;
if (rawModelId && !modelId) {
logWarn(`Ignoring invalid MODEL_ID: ${rawModelId}`);
}
// 5. Provision server (retry loop)
let connection: VMConnection;
for (;;) {
const r = await asyncTryCatch(() => cloud.createServer(serverName));
if (r.ok) {
connection = r.data;
break;
}
logError(getErrorMessage(r.error));
await retryOrQuit("Retry server creation?");
}
recordSpawn(spawnId, agentName, cloud.cloudName, connection);
// 6. Wait for readiness (retry loop)
for (;;) {
const r = await asyncTryCatch(() => cloud.waitForReady());
if (r.ok) {
break;
}
logError(getErrorMessage(r.error));
await retryOrQuit("Server may still be starting. Keep waiting?");
}
trackFunnel("funnel_vm_ready");
// 7. Env config
const envPairs = agent.envVars(apiKey);
if (modelId && agent.modelEnvVar) {
envPairs.push(`${agent.modelEnvVar}=${modelId}`);
}
if (betaFeatures.has("recursive")) {
appendRecursiveEnvVars(envPairs, spawnId);
}
const envContent = generateEnvConfig(envPairs);
// 8. Install agent
if (cloud.skipAgentInstall) {
logInfo("Snapshot boot — skipping agent install");
} else {
let installedFromTarball = false;
if (cloud.cloudName !== "local" && !agent.skipTarball && useTarball) {
const tarball = options?.tryTarball ?? tryTarballInstall;
installedFromTarball = await tarball(cloud.runner, agentName);
}
if (!installedFromTarball) {
for (;;) {
const r = await asyncTryCatch(() => agent.install());
if (r.ok) {
break;
}
logError(getErrorMessage(r.error));
await retryOrQuit("Retry agent install?");
}
}
}
trackFunnel("funnel_install_completed");
// Inject env + continue with shared post-install flow
await injectEnvVars(cloud, envContent);
await postInstall(cloud, agent, agentName, apiKey, modelId, spawnId, options);
if (!orchestrationResult.ok) {
throw orchestrationResult.error;
}
}

View file

@ -72,6 +72,14 @@ bash <(curl -fsSL https://openrouter.ai/labs/spawn/digitalocean/t3code.sh)
| `DO_DROPLET_NAME` | Name for the created droplet | auto-generated |
| `DO_REGION` | Datacenter region (see regions below) | `nyc3` |
| `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 | — |
### Pre-flight readiness
Before region/size selection, the CLI checks DigitalOcean account state (`GET /v2/account`), SSH keys registered on your account, and OpenRouter credentials. If something blocks deployment (unverified email, locked or warning billing status, droplet quota, missing SSH registration, or invalid OpenRouter key), you get guided steps and a readiness checklist. Billing issues open the add-payment flow: `https://cloud.digitalocean.com/account/billing?defer-onboarding-for=or&open-add-payment-method=true`.
OAuth tokens requested by the CLI include `tag:create` so droplets can be tagged `spawn` for attribution. If your token cannot create tags, the CLI retries creation without the tag.
### Available Regions