diff --git a/.claude/skills/setup-agent-team/qa-fixtures-prompt.md b/.claude/skills/setup-agent-team/qa-fixtures-prompt.md index 52cfcec06..fc036454e 100644 --- a/.claude/skills/setup-agent-team/qa-fixtures-prompt.md +++ b/.claude/skills/setup-agent-team/qa-fixtures-prompt.md @@ -31,7 +31,7 @@ Cloud credentials are stored in `~/.config/spawn/{cloud}.json` (loaded by `sh/sh For each cloud with a fixture directory, check if its required env vars are set: - **hetzner**: `HCLOUD_TOKEN` -- **digitalocean**: `DO_API_TOKEN` +- **digitalocean**: `DIGITALOCEAN_ACCESS_TOKEN` - **aws**: `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` Skip clouds where credentials are missing (log which ones). @@ -53,11 +53,11 @@ curl -s -H "Authorization: Bearer ${HCLOUD_TOKEN}" "https://api.hetzner.cloud/v1 curl -s -H "Authorization: Bearer ${HCLOUD_TOKEN}" "https://api.hetzner.cloud/v1/locations" ``` -### DigitalOcean (needs DO_API_TOKEN) +### DigitalOcean (needs DIGITALOCEAN_ACCESS_TOKEN) ```bash -curl -s -H "Authorization: Bearer ${DO_API_TOKEN}" "https://api.digitalocean.com/v2/account/keys" -curl -s -H "Authorization: Bearer ${DO_API_TOKEN}" "https://api.digitalocean.com/v2/sizes" -curl -s -H "Authorization: Bearer ${DO_API_TOKEN}" "https://api.digitalocean.com/v2/regions" +curl -s -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" "https://api.digitalocean.com/v2/account/keys" +curl -s -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" "https://api.digitalocean.com/v2/sizes" +curl -s -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" "https://api.digitalocean.com/v2/regions" ``` For any other cloud directories found, read their TypeScript module in `packages/cli/src/{cloud}/` to discover the API base URL and auth pattern, then call equivalent GET-only endpoints. diff --git a/.github/workflows/packer-snapshots.yml b/.github/workflows/packer-snapshots.yml index 080442b64..0dfbf5c95 100644 --- a/.github/workflows/packer-snapshots.yml +++ b/.github/workflows/packer-snapshots.yml @@ -71,18 +71,18 @@ jobs: - name: Generate variables file run: | jq -n \ - --arg token "$DO_API_TOKEN" \ + --arg token "$DIGITALOCEAN_ACCESS_TOKEN" \ --arg agent "$AGENT_NAME" \ --arg tier "$TIER" \ --argjson install "$INSTALL_COMMANDS" \ '{ - do_api_token: $token, + digitalocean_access_token: $token, agent_name: $agent, cloud_init_tier: $tier, install_commands: $install }' > packer/auto.pkrvars.json env: - DO_API_TOKEN: ${{ secrets.DO_API_TOKEN }} + DIGITALOCEAN_ACCESS_TOKEN: ${{ secrets.DO_API_TOKEN }} AGENT_NAME: ${{ matrix.agent }} TIER: ${{ steps.config.outputs.tier }} INSTALL_COMMANDS: ${{ steps.config.outputs.install }} @@ -96,7 +96,7 @@ jobs: if: cancelled() run: | # Filter by spawn-packer tag to avoid destroying builder droplets from other workflows - DROPLET_IDS=$(curl -s -H "Authorization: Bearer ${DO_API_TOKEN}" \ + DROPLET_IDS=$(curl -s -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" \ "https://api.digitalocean.com/v2/droplets?per_page=200&tag_name=spawn-packer" \ | jq -r '.droplets[].id') @@ -107,28 +107,28 @@ jobs: for ID in $DROPLET_IDS; do echo "Destroying orphaned builder droplet: ${ID}" - curl -s -X DELETE -H "Authorization: Bearer ${DO_API_TOKEN}" \ + curl -s -X DELETE -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" \ "https://api.digitalocean.com/v2/droplets/${ID}" || true done env: - DO_API_TOKEN: ${{ secrets.DO_API_TOKEN }} + DIGITALOCEAN_ACCESS_TOKEN: ${{ secrets.DO_API_TOKEN }} - name: Cleanup old snapshots if: success() run: | PREFIX="spawn-${AGENT_NAME}-" - SNAPSHOTS=$(curl -s -H "Authorization: Bearer ${DO_API_TOKEN}" \ + SNAPSHOTS=$(curl -s -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" \ "https://api.digitalocean.com/v2/images?private=true&per_page=100" \ | jq -r --arg prefix "$PREFIX" \ '[.images[] | select(.name | startswith($prefix))] | sort_by(.created_at) | reverse | .[1:] | .[].id') for ID in $SNAPSHOTS; do echo "Deleting old snapshot: ${ID}" - curl -s -X DELETE -H "Authorization: Bearer ${DO_API_TOKEN}" \ + curl -s -X DELETE -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" \ "https://api.digitalocean.com/v2/images/${ID}" || true done env: - DO_API_TOKEN: ${{ secrets.DO_API_TOKEN }} + DIGITALOCEAN_ACCESS_TOKEN: ${{ secrets.DO_API_TOKEN }} AGENT_NAME: ${{ matrix.agent }} - name: Submit to DO Marketplace @@ -162,7 +162,7 @@ jobs: HTTP_CODE=$(curl -s -o /tmp/mp-response.json -w "%{http_code}" \ -X PATCH \ -H "Content-Type: application/json" \ - -H "Authorization: Bearer ${DO_API_TOKEN}" \ + -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" \ -d "$(jq -n \ --arg reason "Nightly rebuild — $(date -u '+%Y-%m-%d')" \ --argjson imageId "$IMG_ID" \ @@ -177,6 +177,6 @@ jobs: exit 1 ;; esac env: - DO_API_TOKEN: ${{ secrets.DO_API_TOKEN }} + DIGITALOCEAN_ACCESS_TOKEN: ${{ secrets.DO_API_TOKEN }} AGENT_NAME: ${{ matrix.agent }} MARKETPLACE_APP_IDS: ${{ secrets.MARKETPLACE_APP_IDS }} diff --git a/README.md b/README.md index 743a67ad2..50a3456f8 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ export OPENROUTER_API_KEY=sk-or-v1-xxxxx # Cloud-specific credentials (varies by provider) # Note: Sprite uses `sprite login` for authentication export HCLOUD_TOKEN=... # For Hetzner -export DO_API_TOKEN=... # For DigitalOcean +export DIGITALOCEAN_ACCESS_TOKEN=... # For DigitalOcean # Run non-interactively spawn claude hetzner @@ -258,7 +258,7 @@ If spawn fails to install, try these steps: 2. **Set credentials via environment variables** before launching: ```powershell $env:OPENROUTER_API_KEY = "sk-or-v1-xxxxx" - $env:DO_API_TOKEN = "dop_v1_xxxxx" # For DigitalOcean + $env:DIGITALOCEAN_ACCESS_TOKEN = "dop_v1_xxxxx" # For DigitalOcean $env:HCLOUD_TOKEN = "xxxxx" # For Hetzner spawn openclaw digitalocean ``` diff --git a/manifest.json b/manifest.json index ac5887382..e1ce49f13 100644 --- a/manifest.json +++ b/manifest.json @@ -411,7 +411,7 @@ "description": "Cloud servers (account + payment method required)", "url": "https://www.digitalocean.com/", "type": "api", - "auth": "DO_API_TOKEN", + "auth": "DIGITALOCEAN_ACCESS_TOKEN", "provision_method": "POST /v2/droplets with user_data", "exec_method": "ssh root@IP", "interactive_method": "ssh -t root@IP", diff --git a/packages/cli/src/__tests__/commands-exported-utils.test.ts b/packages/cli/src/__tests__/commands-exported-utils.test.ts index 0d50cb273..9a500bd01 100644 --- a/packages/cli/src/__tests__/commands-exported-utils.test.ts +++ b/packages/cli/src/__tests__/commands-exported-utils.test.ts @@ -47,8 +47,8 @@ describe("parseAuthEnvVars", () => { }); it("should extract env var starting with letter followed by digits", () => { - expect(parseAuthEnvVars("DO_API_TOKEN")).toEqual([ - "DO_API_TOKEN", + expect(parseAuthEnvVars("DIGITALOCEAN_ACCESS_TOKEN")).toEqual([ + "DIGITALOCEAN_ACCESS_TOKEN", ]); }); }); diff --git a/packages/cli/src/__tests__/do-cov.test.ts b/packages/cli/src/__tests__/do-cov.test.ts index a81a5cbb6..19a44b75d 100644 --- a/packages/cli/src/__tests__/do-cov.test.ts +++ b/packages/cli/src/__tests__/do-cov.test.ts @@ -259,7 +259,7 @@ describe("digitalocean/getServerIp", () => { ); const { getServerIp } = await import("../digitalocean/digitalocean"); // Need to set the token state - process.env.DO_API_TOKEN = "test-token"; + process.env.DIGITALOCEAN_ACCESS_TOKEN = "test-token"; // getServerIp calls doApi which uses internal state token - need to set via ensureDoToken // But doApi will use _state.token. Since we can't easily set _state, we test the 404 path // by mocking fetch to always return 404 diff --git a/packages/cli/src/__tests__/do-payment-warning.test.ts b/packages/cli/src/__tests__/do-payment-warning.test.ts index e02fc5020..456c1c6bd 100644 --- a/packages/cli/src/__tests__/do-payment-warning.test.ts +++ b/packages/cli/src/__tests__/do-payment-warning.test.ts @@ -25,9 +25,15 @@ describe("ensureDoToken — payment method warning for first-time users", () => let warnSpy: ReturnType; beforeEach(() => { - // Save and clear DO_API_TOKEN - savedEnv["DO_API_TOKEN"] = process.env.DO_API_TOKEN; - delete process.env.DO_API_TOKEN; + // Save and clear all accepted DigitalOcean token env vars + for (const v of [ + "DIGITALOCEAN_ACCESS_TOKEN", + "DIGITALOCEAN_API_TOKEN", + "DO_API_TOKEN", + ]) { + savedEnv[v] = process.env[v]; + delete process.env[v]; + } // Fail OAuth connectivity check → tryDoOAuth returns null immediately globalThis.fetch = mock(() => Promise.reject(new Error("Network unreachable"))); @@ -73,7 +79,25 @@ describe("ensureDoToken — payment method warning for first-time users", () => expect(warnMessages.some((msg: string) => msg.includes("payment method"))).toBe(false); }); - it("does NOT show payment warning when DO_API_TOKEN env var is set", async () => { + it("does NOT show payment warning when DIGITALOCEAN_ACCESS_TOKEN env var is set", async () => { + process.env.DIGITALOCEAN_ACCESS_TOKEN = "dop_v1_invalid_env_token"; + + await expect(ensureDoToken()).rejects.toThrow(); + + const warnMessages = warnSpy.mock.calls.map((c: unknown[]) => String(c[0])); + expect(warnMessages.some((msg: string) => msg.includes("payment method"))).toBe(false); + }); + + it("does NOT show payment warning when DIGITALOCEAN_API_TOKEN env var is set", async () => { + process.env.DIGITALOCEAN_API_TOKEN = "dop_v1_invalid_env_token"; + + await expect(ensureDoToken()).rejects.toThrow(); + + const warnMessages = warnSpy.mock.calls.map((c: unknown[]) => String(c[0])); + expect(warnMessages.some((msg: string) => msg.includes("payment method"))).toBe(false); + }); + + it("does NOT show payment warning when legacy DO_API_TOKEN env var is set", async () => { process.env.DO_API_TOKEN = "dop_v1_invalid_env_token"; await expect(ensureDoToken()).rejects.toThrow(); diff --git a/packages/cli/src/__tests__/run-path-credential-display.test.ts b/packages/cli/src/__tests__/run-path-credential-display.test.ts index e361c298e..5f1eceec9 100644 --- a/packages/cli/src/__tests__/run-path-credential-display.test.ts +++ b/packages/cli/src/__tests__/run-path-credential-display.test.ts @@ -68,7 +68,7 @@ function makeManifest(overrides?: Partial): Manifest { price: "test", url: "https://digitalocean.com", type: "api", - auth: "DO_API_TOKEN", + auth: "DIGITALOCEAN_ACCESS_TOKEN", provision_method: "api", exec_method: "ssh root@IP", interactive_method: "ssh -t root@IP", @@ -138,6 +138,8 @@ describe("prioritizeCloudsByCredentials", () => { // Save and clear credential env vars for (const v of [ "HCLOUD_TOKEN", + "DIGITALOCEAN_ACCESS_TOKEN", + "DIGITALOCEAN_API_TOKEN", "DO_API_TOKEN", "UPCLOUD_USERNAME", "UPCLOUD_PASSWORD", @@ -191,7 +193,7 @@ describe("prioritizeCloudsByCredentials", () => { it("should move multiple credential clouds to front", () => { process.env.HCLOUD_TOKEN = "test-token"; - process.env.DO_API_TOKEN = "test-do-token"; + process.env.DIGITALOCEAN_ACCESS_TOKEN = "test-do-token"; const manifest = makeManifest(); const clouds = [ "upcloud", @@ -290,7 +292,7 @@ describe("prioritizeCloudsByCredentials", () => { it("should preserve relative order within each group", () => { process.env.HCLOUD_TOKEN = "token"; - process.env.DO_API_TOKEN = "token"; + process.env.DIGITALOCEAN_ACCESS_TOKEN = "token"; const manifest = makeManifest(); // Input order: digitalocean before hetzner (both have creds) const clouds = [ @@ -331,7 +333,7 @@ describe("prioritizeCloudsByCredentials", () => { it("should count all credential clouds correctly with all set", () => { process.env.HCLOUD_TOKEN = "t1"; - process.env.DO_API_TOKEN = "t2"; + process.env.DIGITALOCEAN_ACCESS_TOKEN = "t2"; process.env.UPCLOUD_USERNAME = "u"; process.env.UPCLOUD_PASSWORD = "p"; const manifest = makeManifest(); @@ -350,4 +352,30 @@ describe("prioritizeCloudsByCredentials", () => { expect(result.sortedClouds.slice(3)).toContain("sprite"); expect(result.sortedClouds.slice(3)).toContain("localcloud"); }); + + it("should recognize legacy DO_API_TOKEN as alias for DIGITALOCEAN_ACCESS_TOKEN", () => { + process.env.DO_API_TOKEN = "legacy-token"; + const manifest = makeManifest(); + const clouds = [ + "digitalocean", + "hetzner", + ]; + const result = prioritizeCloudsByCredentials(clouds, manifest); + + expect(result.credCount).toBe(1); + expect(result.sortedClouds[0]).toBe("digitalocean"); + }); + + it("should recognize DIGITALOCEAN_API_TOKEN as alias for DIGITALOCEAN_ACCESS_TOKEN", () => { + process.env.DIGITALOCEAN_API_TOKEN = "alt-token"; + const manifest = makeManifest(); + const clouds = [ + "digitalocean", + "hetzner", + ]; + const result = prioritizeCloudsByCredentials(clouds, manifest); + + expect(result.credCount).toBe(1); + expect(result.sortedClouds[0]).toBe("digitalocean"); + }); }); diff --git a/packages/cli/src/__tests__/script-failure-guidance.test.ts b/packages/cli/src/__tests__/script-failure-guidance.test.ts index 0a324d975..cc5ea3b68 100644 --- a/packages/cli/src/__tests__/script-failure-guidance.test.ts +++ b/packages/cli/src/__tests__/script-failure-guidance.test.ts @@ -209,12 +209,12 @@ describe("getScriptFailureGuidance", () => { it("should show specific env var name and setup hint for default case when authHint is provided", () => { const savedOR = process.env.OPENROUTER_API_KEY; - const savedDO = process.env.DO_API_TOKEN; + const savedDO = process.env.DIGITALOCEAN_ACCESS_TOKEN; delete process.env.OPENROUTER_API_KEY; - delete process.env.DO_API_TOKEN; - const lines = stripped_getScriptFailureGuidance(42, "digitalocean", "DO_API_TOKEN"); + delete process.env.DIGITALOCEAN_ACCESS_TOKEN; + const lines = stripped_getScriptFailureGuidance(42, "digitalocean", "DIGITALOCEAN_ACCESS_TOKEN"); const joined = lines.join("\n"); - expect(joined).toContain("DO_API_TOKEN"); + expect(joined).toContain("DIGITALOCEAN_ACCESS_TOKEN"); expect(joined).toContain("OPENROUTER_API_KEY"); expect(joined).toContain("spawn digitalocean"); expect(joined).toContain("setup"); @@ -222,7 +222,7 @@ describe("getScriptFailureGuidance", () => { process.env.OPENROUTER_API_KEY = savedOR; } if (savedDO !== undefined) { - process.env.DO_API_TOKEN = savedDO; + process.env.DIGITALOCEAN_ACCESS_TOKEN = savedDO; } }); @@ -230,7 +230,7 @@ describe("getScriptFailureGuidance", () => { const lines = stripped_getScriptFailureGuidance(42, "digitalocean"); const joined = lines.join("\n"); expect(joined).toContain("spawn digitalocean"); - expect(joined).not.toContain("DO_API_TOKEN"); + expect(joined).not.toContain("DIGITALOCEAN_ACCESS_TOKEN"); }); it("should handle multi-credential auth hint", () => { diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index bf8a35ca9..e97af9219 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -58,6 +58,7 @@ export { getImplementedClouds, hasCloudCli, hasCloudCredentials, + isAuthEnvVarSet, isInteractiveTTY, levenshtein, loadManifestWithSpinner, diff --git a/packages/cli/src/commands/shared.ts b/packages/cli/src/commands/shared.ts index 94219a1d5..ea15881f9 100644 --- a/packages/cli/src/commands/shared.ts +++ b/packages/cli/src/commands/shared.ts @@ -489,9 +489,26 @@ export function parseAuthEnvVars(auth: string): string[] { .filter((s) => /^[A-Z][A-Z0-9_]{3,}$/.test(s)); } +/** Legacy env var names accepted as aliases for the canonical names in the manifest */ +const AUTH_VAR_ALIASES: Record = { + DIGITALOCEAN_ACCESS_TOKEN: [ + "DIGITALOCEAN_API_TOKEN", + "DO_API_TOKEN", + ], +}; + +/** Check if an auth env var (or one of its legacy aliases) is set */ +export function isAuthEnvVarSet(varName: string): boolean { + if (process.env[varName]) { + return true; + } + const aliases = AUTH_VAR_ALIASES[varName]; + return !!aliases?.some((a) => !!process.env[a]); +} + /** Format an auth env var line showing whether it's already set or needs to be exported */ function formatAuthVarLine(varName: string, urlHint?: string): string { - if (process.env[varName]) { + if (isAuthEnvVarSet(varName)) { return ` ${pc.green(varName)} ${pc.dim("-- set")}`; } const hint = urlHint ? ` ${pc.dim(`# ${urlHint}`)}` : ""; @@ -504,12 +521,12 @@ export function hasCloudCredentials(auth: string): boolean { if (vars.length === 0) { return false; } - return vars.every((v) => !!process.env[v]); + return vars.every((v) => isAuthEnvVarSet(v)); } /** Format a single credential env var as a status line (green if set, red if missing) */ export function formatCredStatusLine(varName: string, urlHint?: string): string { - if (process.env[varName]) { + if (isAuthEnvVarSet(varName)) { return ` ${pc.green(varName)} ${pc.dim("-- set")}`; } const suffix = urlHint ? ` ${pc.dim(urlHint)}` : ""; @@ -542,7 +559,7 @@ export function collectMissingCredentials(authVars: string[], cloud?: string): s missing.push("OPENROUTER_API_KEY"); } for (const v of authVars) { - if (!process.env[v]) { + if (!isAuthEnvVarSet(v)) { missing.push(v); } } diff --git a/packages/cli/src/digitalocean/digitalocean.ts b/packages/cli/src/digitalocean/digitalocean.ts index 638114b99..7f2b8467e 100644 --- a/packages/cli/src/digitalocean/digitalocean.ts +++ b/packages/cli/src/digitalocean/digitalocean.ts @@ -666,14 +666,14 @@ async function tryDoOAuth(): Promise { if (oauthDenied) { logError("OAuth authorization was denied by the user"); logError("Alternative: Use a manual API token instead"); - logError(" export DO_API_TOKEN=dop_v1_..."); + logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_..."); return null; } if (!oauthCode) { logError("OAuth authentication timed out after 120 seconds"); logError("Alternative: Use a manual API token instead"); - logError(" export DO_API_TOKEN=dop_v1_..."); + logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_..."); return null; } @@ -729,15 +729,22 @@ async function tryDoOAuth(): Promise { /** Returns true if browser OAuth was triggered (so caller can delay before next OAuth). */ export async function ensureDoToken(): Promise { - // 1. Env var - if (process.env.DO_API_TOKEN) { - _state.token = process.env.DO_API_TOKEN.trim(); + // 1. Env var (DIGITALOCEAN_ACCESS_TOKEN > DIGITALOCEAN_API_TOKEN > DO_API_TOKEN) + const envToken = + process.env.DIGITALOCEAN_ACCESS_TOKEN ?? process.env.DIGITALOCEAN_API_TOKEN ?? process.env.DO_API_TOKEN; + if (envToken) { + const envVarName = process.env.DIGITALOCEAN_ACCESS_TOKEN + ? "DIGITALOCEAN_ACCESS_TOKEN" + : process.env.DIGITALOCEAN_API_TOKEN + ? "DIGITALOCEAN_API_TOKEN" + : "DO_API_TOKEN"; + _state.token = envToken.trim(); if (await testDoToken()) { logInfo("Using DigitalOcean API token from environment"); await saveTokenToConfig(_state.token); return false; } - logWarn("DO_API_TOKEN from environment is invalid"); + logWarn(`${envVarName} from environment is invalid`); _state.token = ""; } @@ -776,7 +783,7 @@ 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 && !process.env.DO_API_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"); diff --git a/packer/digitalocean.pkr.hcl b/packer/digitalocean.pkr.hcl index 2329be016..84ac88831 100644 --- a/packer/digitalocean.pkr.hcl +++ b/packer/digitalocean.pkr.hcl @@ -7,7 +7,7 @@ packer { } } -variable "do_api_token" { +variable "digitalocean_access_token" { type = string sensitive = true } @@ -32,7 +32,7 @@ locals { } source "digitalocean" "spawn" { - api_token = var.do_api_token + api_token = var.digitalocean_access_token image = "ubuntu-24-04-x64" region = "sfo3" # 2 GB RAM needed — Claude's native installer and zeroclaw's Rust build diff --git a/sh/digitalocean/README.md b/sh/digitalocean/README.md index d8672dc07..0d0d9225e 100644 --- a/sh/digitalocean/README.md +++ b/sh/digitalocean/README.md @@ -62,7 +62,7 @@ bash <(curl -fsSL https://openrouter.ai/labs/spawn/digitalocean/cursor.sh) | Variable | Description | Default | |---|---|---| -| `DO_API_TOKEN` | DigitalOcean API token | — (OAuth if unset) | +| `DIGITALOCEAN_ACCESS_TOKEN` | DigitalOcean API token (also accepts `DIGITALOCEAN_API_TOKEN` or `DO_API_TOKEN`) | — (OAuth if unset) | | `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` | @@ -97,7 +97,7 @@ bash <(curl -fsSL https://openrouter.ai/labs/spawn/digitalocean/cursor.sh) ```bash DO_DROPLET_NAME=dev-mk1 \ -DO_API_TOKEN=your-token \ +DIGITALOCEAN_ACCESS_TOKEN=your-token \ OPENROUTER_API_KEY=sk-or-v1-xxxxx \ bash <(curl -fsSL https://openrouter.ai/labs/spawn/digitalocean/claude.sh) ``` @@ -107,7 +107,7 @@ Override region and droplet size: ```bash DO_REGION=fra1 \ DO_DROPLET_SIZE=s-1vcpu-2gb \ -DO_API_TOKEN=your-token \ +DIGITALOCEAN_ACCESS_TOKEN=your-token \ OPENROUTER_API_KEY=sk-or-v1-xxxxx \ bash <(curl -fsSL https://openrouter.ai/labs/spawn/digitalocean/claude.sh) ``` diff --git a/sh/e2e/interactive-harness.ts b/sh/e2e/interactive-harness.ts index bc7e6aacd..cecb26096 100644 --- a/sh/e2e/interactive-harness.ts +++ b/sh/e2e/interactive-harness.ts @@ -9,7 +9,7 @@ // Required env: // ANTHROPIC_API_KEY — For the AI driver (Claude Haiku) // OPENROUTER_API_KEY — Injected into spawn for the agent -// Cloud credentials — HCLOUD_TOKEN, DO_API_TOKEN, AWS_ACCESS_KEY_ID, etc. +// Cloud credentials — HCLOUD_TOKEN, DIGITALOCEAN_ACCESS_TOKEN, AWS_ACCESS_KEY_ID, etc. // // Outputs JSON to stdout: { success: boolean, duration: number, transcript: string, uxIssues?: UxIssue[] } @@ -47,7 +47,7 @@ function buildCredentialHints(): string { const hetzner = process.env.HCLOUD_TOKEN ?? ""; if (hetzner) creds.push(`Hetzner token: ${hetzner}`); - const doToken = process.env.DO_API_TOKEN ?? ""; + const doToken = process.env.DIGITALOCEAN_ACCESS_TOKEN ?? process.env.DIGITALOCEAN_API_TOKEN ?? process.env.DO_API_TOKEN ?? ""; if (doToken) creds.push(`DigitalOcean token: ${doToken}`); const awsKey = process.env.AWS_ACCESS_KEY_ID ?? ""; @@ -79,6 +79,8 @@ function redactSecrets(text: string): string { const secrets = [ process.env.OPENROUTER_API_KEY, process.env.HCLOUD_TOKEN, + process.env.DIGITALOCEAN_ACCESS_TOKEN, + process.env.DIGITALOCEAN_API_TOKEN, process.env.DO_API_TOKEN, process.env.AWS_ACCESS_KEY_ID, process.env.AWS_SECRET_ACCESS_KEY, diff --git a/sh/e2e/lib/clouds/digitalocean.sh b/sh/e2e/lib/clouds/digitalocean.sh index 0f0589dc3..fcd038412 100644 --- a/sh/e2e/lib/clouds/digitalocean.sh +++ b/sh/e2e/lib/clouds/digitalocean.sh @@ -4,11 +4,19 @@ # Implements the standard cloud driver interface (_digitalocean_*) for # provisioning and managing DigitalOcean droplets in the E2E test suite. # -# Requires: DO_API_TOKEN, jq, ssh +# Accepts: DIGITALOCEAN_ACCESS_TOKEN, DIGITALOCEAN_API_TOKEN, or DO_API_TOKEN # API: https://api.digitalocean.com/v2 # SSH user: root set -eo pipefail +# ── Resolve DigitalOcean token (canonical > alternate > legacy) ─────────── +if [ -n "${DIGITALOCEAN_ACCESS_TOKEN:-}" ]; then + DO_API_TOKEN="${DIGITALOCEAN_ACCESS_TOKEN}" +elif [ -n "${DIGITALOCEAN_API_TOKEN:-}" ]; then + DO_API_TOKEN="${DIGITALOCEAN_API_TOKEN}" +fi +export DO_API_TOKEN + # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- @@ -19,7 +27,7 @@ _DO_DEFAULT_REGION="nyc3" # --------------------------------------------------------------------------- # _do_curl_auth [curl-args...] # -# Wrapper around curl that passes the DO_API_TOKEN via a temp config file +# Wrapper around curl that passes the token via a temp config file # instead of a command-line -H flag. This keeps the token out of `ps` output. # All arguments are forwarded to curl. # --------------------------------------------------------------------------- @@ -37,19 +45,19 @@ _do_curl_auth() { # --------------------------------------------------------------------------- # _digitalocean_validate_env # -# Validates that DO_API_TOKEN is set and the DigitalOcean API is reachable -# with valid credentials. +# Validates that a DigitalOcean token is set and the API is reachable. +# Accepts DIGITALOCEAN_ACCESS_TOKEN, DIGITALOCEAN_API_TOKEN, or DO_API_TOKEN. # Returns 0 on success, 1 on failure. # --------------------------------------------------------------------------- _digitalocean_validate_env() { if [ -z "${DO_API_TOKEN:-}" ]; then - log_err "DO_API_TOKEN is not set" + log_err "DigitalOcean token is not set (set DIGITALOCEAN_ACCESS_TOKEN, DIGITALOCEAN_API_TOKEN, or DO_API_TOKEN)" return 1 fi if ! _do_curl_auth -sf \ "${_DO_API}/account" >/dev/null 2>&1; then - log_err "DigitalOcean API authentication failed — check DO_API_TOKEN" + log_err "DigitalOcean API authentication failed — check your token" return 1 fi