From 4d289d5d3ac831e6e25c7452c122ff6a662f0d90 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Wed, 15 Apr 2026 14:36:55 -0600 Subject: [PATCH] feat(digitalocean): guided readiness before deploy Pre-flight gate, checklist UI, billing deep-link, DO OAuth escape hatch, orchestration ordering (readiness before sizing), tests and DO README updates. --- packages/cli/src/__tests__/README.md | 4 +- .../src/__tests__/billing-guidance.test.ts | 10 +- .../src/__tests__/do-payment-warning.test.ts | 22 +- .../__tests__/preflight-credentials.test.ts | 40 +- .../src/__tests__/readiness-checklist.test.ts | 41 ++ packages/cli/src/__tests__/readiness.test.ts | 16 + packages/cli/src/commands/shared.ts | 5 + packages/cli/src/digitalocean/billing.ts | 6 +- packages/cli/src/digitalocean/digitalocean.ts | 195 ++++++++- packages/cli/src/digitalocean/main.ts | 12 +- .../src/digitalocean/readiness-checklist.ts | 94 ++++ packages/cli/src/digitalocean/readiness.ts | 225 ++++++++++ packages/cli/src/shared/oauth.ts | 11 +- packages/cli/src/shared/orchestrate.ts | 404 +++++++++--------- sh/digitalocean/README.md | 21 + sh/digitalocean/reset-local-state.sh | 37 ++ 16 files changed, 895 insertions(+), 248 deletions(-) create mode 100644 packages/cli/src/__tests__/readiness-checklist.test.ts create mode 100644 packages/cli/src/__tests__/readiness.test.ts create mode 100644 packages/cli/src/digitalocean/readiness-checklist.ts create mode 100644 packages/cli/src/digitalocean/readiness.ts create mode 100755 sh/digitalocean/reset-local-state.sh diff --git a/packages/cli/src/__tests__/README.md b/packages/cli/src/__tests__/README.md index e70ec9efd..4ce5a5d7d 100644 --- a/packages/cli/src/__tests__/README.md +++ b/packages/cli/src/__tests__/README.md @@ -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 diff --git a/packages/cli/src/__tests__/billing-guidance.test.ts b/packages/cli/src/__tests__/billing-guidance.test.ts index ed97e4104..683da8545 100644 --- a/packages/cli/src/__tests__/billing-guidance.test.ts +++ b/packages/cli/src/__tests__/billing-guidance.test.ts @@ -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(); diff --git a/packages/cli/src/__tests__/do-payment-warning.test.ts b/packages/cli/src/__tests__/do-payment-warning.test.ts index 456c1c6bd..16a7f2699 100644 --- a/packages/cli/src/__tests__/do-payment-warning.test.ts +++ b/packages/cli/src/__tests__/do-payment-warning.test.ts @@ -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 = {}; const originalFetch = globalThis.fetch; let stderrSpy: ReturnType; @@ -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"); - }); }); diff --git a/packages/cli/src/__tests__/preflight-credentials.test.ts b/packages/cli/src/__tests__/preflight-credentials.test.ts index 551735b35..8b7ee85e3 100644 --- a/packages/cli/src/__tests__/preflight-credentials.test.ts +++ b/packages/cli/src/__tests__/preflight-credentials.test.ts @@ -1,18 +1,19 @@ import type { Manifest } from "../manifest"; -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; import { preflightCredentialCheck } from "../commands/index.js"; +import * as shared from "../commands/shared.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 +116,35 @@ describe("preflightCredentialCheck", () => { await preflightCredentialCheck(makeManifest("none"), "testcloud"); expect(clack.logWarn.mock.calls.length).toBe(0); }); + + describe("digitalocean + TTY gating", () => { + let isTTYSpy: ReturnType; + + beforeEach(() => { + isTTYSpy = spyOn(shared, "isInteractiveTTY"); + }); + + afterEach(() => { + isTTYSpy.mockRestore(); + }); + + it("skips warnings when interactive (guided checklist supplies credentials)", async () => { + isTTYSpy.mockReturnValue(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 () => { + isTTYSpy.mockReturnValue(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/); + }); + }); }); diff --git a/packages/cli/src/__tests__/readiness-checklist.test.ts b/packages/cli/src/__tests__/readiness-checklist.test.ts new file mode 100644 index 000000000..67ab9a340 --- /dev/null +++ b/packages/cli/src/__tests__/readiness-checklist.test.ts @@ -0,0 +1,41 @@ +import type { ReadinessState } from "../digitalocean/readiness"; + +import { describe, expect, test } from "bun:test"; +import { checklistLineStatus } 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"); + }); + + 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"); + }); + + 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"); + }); +}); diff --git a/packages/cli/src/__tests__/readiness.test.ts b/packages/cli/src/__tests__/readiness.test.ts new file mode 100644 index 000000000..f41761be9 --- /dev/null +++ b/packages/cli/src/__tests__/readiness.test.ts @@ -0,0 +1,16 @@ +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", + ]); + }); +}); diff --git a/packages/cli/src/commands/shared.ts b/packages/cli/src/commands/shared.ts index ea15881f9..edc708f75 100644 --- a/packages/cli/src/commands/shared.ts +++ b/packages/cli/src/commands/shared.ts @@ -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) { diff --git a/packages/cli/src/digitalocean/billing.ts b/packages/cli/src/digitalocean/billing.ts index 160bd3ea2..010bf0296 100644 --- a/packages/cli/src/digitalocean/billing.ts +++ b/packages/cli/src/digitalocean/billing.ts @@ -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", diff --git a/packages/cli/src/digitalocean/digitalocean.ts b/packages/cli/src/digitalocean/digitalocean.ts index 852b3031b..b72a5d0b7 100644 --- a/packages/cli/src/digitalocean/digitalocean.ts +++ b/packages/cli/src/digitalocean/digitalocean.ts @@ -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 { ); } +/** 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 { + 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 { + 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) => (k.fingerprint || "") === fingerprint)) { + return true; + } + } + return false; +} + +/** Ensure attribution tag exists (ignore if already present or insufficient scope). */ +async function ensureSpawnAttributionTag(): Promise { + 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 { + 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 { 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 { 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 | 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 { 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 { } // 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}`); diff --git a/packages/cli/src/digitalocean/main.ts b/packages/cli/src/digitalocean/main.ts index c8baeb710..d1df30141 100644 --- a/packages/cli/src/digitalocean/main.ts +++ b/packages/cli/src/digitalocean/main.ts @@ -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 = { @@ -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(); diff --git a/packages/cli/src/digitalocean/readiness-checklist.ts b/packages/cli/src/digitalocean/readiness-checklist.ts new file mode 100644 index 000000000..eb7f51d37 --- /dev/null +++ b/packages/cli/src/digitalocean/readiness-checklist.ts @@ -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"); + } +} diff --git a/packages/cli/src/digitalocean/readiness.ts b/packages/cli/src/digitalocean/readiness.ts new file mode 100644 index 000000000..0348c7f61 --- /dev/null +++ b/packages/cli/src/digitalocean/readiness.ts @@ -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 { + 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 { + 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 { + 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 { + 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"); +} diff --git a/packages/cli/src/shared/oauth.ts b/packages/cli/src/shared/oauth.ts index 11772f818..15e8ae430 100644 --- a/packages/cli/src/shared/oauth.ts +++ b/packages/cli/src/shared/oauth.ts @@ -18,7 +18,8 @@ const OAuthKeySchema = v.object({ // ─── Key Validation ────────────────────────────────────────────────────────── -async function verifyOpenrouterKey(apiKey: string): Promise { +/** Validate an OpenRouter API key via the public auth endpoint (used by readiness + key flows). */ +export async function verifyOpenRouterApiKey(apiKey: string): Promise { 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; diff --git a/packages/cli/src/shared/orchestrate.ts b/packages/cli/src/shared/orchestrate.ts index 3fc0e8866..bb6750fbf 100644 --- a/packages/cli/src/shared/orchestrate.ts +++ b/packages/cli/src/shared/orchestrate.ts @@ -100,6 +100,8 @@ export interface CloudOrchestrator { skipCloudInit?: boolean; authenticate(): Promise; checkAccountReady?(): Promise; + /** DigitalOcean: blocking readiness (account, SSH, OpenRouter) before region/size. */ + ensureReadyBeforeSizing?(): Promise; promptSize(): Promise; createServer(name: string): Promise; getServerName(): Promise; @@ -315,7 +317,11 @@ export async function runOrchestration( agentName: string, options?: OrchestrationOptions, ): Promise { - 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 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"); - - // 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 orchestrationResult = await asyncTryCatch(async () => { + // 1. Authenticate with cloud provider + await cloud.authenticate(); + trackFunnel("funnel_cloud_authed"); - // 1b. Size/bundle selection (must happen before createServer) - await cloud.promptSize(); - - // 2. Provision server - const spawnId = generateSpawnId(); - const serverName = await cloud.getServerName(); - - 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 serverBootPromise = (async () => { - const conn = await cloud.createServer(serverName); - recordSpawn(spawnId, agentName, cloud.cloudName, conn); - await cloud.waitForReady(); - return conn; - })(); - - const resolveApiKey = options?.getApiKey ?? getOrPromptApiKey; - - // 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, - }), - ]); - - // 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(); + if (cloud.ensureReadyBeforeSizing) { + await cloud.ensureReadyBeforeSizing(); } - 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}`); + 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"); + + // 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; } - // 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); + // 1b. Size/bundle selection (must happen before createServer) + await cloud.promptSize(); + + // 2. Provision server + const spawnId = generateSpawnId(); + const serverName = await cloud.getServerName(); + + 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 serverBootPromise = (async () => { + const conn = await cloud.createServer(serverName); + recordSpawn(spawnId, agentName, cloud.cloudName, conn); + await cloud.waitForReady(); + return conn; + })(); + + const resolveApiKey = options?.getApiKey ?? getOrPromptApiKey; + + // 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"); - // 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); + // API key must succeed + if (apiKeyResult.status === "rejected") { + throw apiKeyResult.reason; } - if (!installed) { - for (;;) { - const r = await asyncTryCatch(() => agent.install()); - if (r.ok) { - break; + 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?"); } - logError(getErrorMessage(r.error)); - await retryOrQuit("Retry agent install?"); } } - } - trackFunnel("funnel_install_completed"); + 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)); + // 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)); + // 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; + // 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}`); } - 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; + // 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?"); } - logError(getErrorMessage(r.error)); - await retryOrQuit("Server may still be starting. Keep waiting?"); - } - trackFunnel("funnel_vm_ready"); + recordSpawn(spawnId, agentName, cloud.cloudName, connection); - // 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); + // 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"); - // 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); + // 7. Env config + const envPairs = agent.envVars(apiKey); + if (modelId && agent.modelEnvVar) { + envPairs.push(`${agent.modelEnvVar}=${modelId}`); } - if (!installedFromTarball) { - for (;;) { - const r = await asyncTryCatch(() => agent.install()); - if (r.ok) { - break; + 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 - await injectEnvVars(cloud, envContent); - await postInstall(cloud, agent, agentName, apiKey, modelId, spawnId, options); + if (!orchestrationResult.ok) { + throw orchestrationResult.error; } } diff --git a/sh/digitalocean/README.md b/sh/digitalocean/README.md index 6a25aa35c..e9c88e5f8 100644 --- a/sh/digitalocean/README.md +++ b/sh/digitalocean/README.md @@ -66,6 +66,27 @@ bash <(curl -fsSL https://openrouter.ai/labs/spawn/digitalocean/pi.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 | — | + +### Reset local state + +To drop saved DigitalOcean and OpenRouter credentials and optional shell variables while developing or testing, use [`reset-local-state.sh`](reset-local-state.sh). It deletes `~/.config/spawn/digitalocean.json` and `~/.config/spawn/openrouter.json`. + +```bash +# Remove the saved token files (enough for a clean OAuth/API flow next run) +bash sh/digitalocean/reset-local-state.sh + +# Also clear DO/OpenRouter-related env vars in this shell (bash: source; otherwise paste the +# `unset` line printed when you run the script without sourcing). +source sh/digitalocean/reset-local-state.sh +``` + +### 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 diff --git a/sh/digitalocean/reset-local-state.sh b/sh/digitalocean/reset-local-state.sh new file mode 100755 index 000000000..1ab025b88 --- /dev/null +++ b/sh/digitalocean/reset-local-state.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Reset local DigitalOcean state for Spawn: removes saved DO + OpenRouter config and optionally +# clears DO/OpenRouter-related environment variables in the *current* shell. +# +# Remove persisted credentials (subprocess is fine): +# bash /path/to/reset-local-state.sh +# +# Also unset env vars in this shell (must source): +# source /path/to/reset-local-state.sh + +set -e + +CONFIG="${HOME}/.config/spawn/digitalocean.json" +if [[ -e "$CONFIG" ]]; then + rm -f "$CONFIG" + echo "Removed: $CONFIG" +else + echo "No file at $CONFIG (already clean)." +fi + +OPENROUTER_CONFIG="${HOME}/.config/spawn/openrouter.json" +if [[ -e "$OPENROUTER_CONFIG" ]]; then + rm -f "$OPENROUTER_CONFIG" + echo "Removed: $OPENROUTER_CONFIG" +else + echo "No file at $OPENROUTER_CONFIG (already clean)." +fi + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + echo "Note: Environment variables were not cleared (this ran in a subshell)." + echo " Paste in this shell, or open a new terminal:" + echo ' unset DIGITALOCEAN_ACCESS_TOKEN DIGITALOCEAN_API_TOKEN DO_API_TOKEN DO_DROPLET_NAME DO_REGION DO_DROPLET_SIZE OPENROUTER_API_KEY' + echo " Or from bash: source ${BASH_SOURCE[0]}" +else + unset DIGITALOCEAN_ACCESS_TOKEN DIGITALOCEAN_API_TOKEN DO_API_TOKEN DO_DROPLET_NAME DO_REGION DO_DROPLET_SIZE OPENROUTER_API_KEY 2>/dev/null || true + echo "Cleared DigitalOcean/OpenRouter-related environment variables in this shell." +fi