Skip to content

Commit ab69a4d

Browse files
louisgvclaude
andcommitted
fix(digitalocean): use canonical DIGITALOCEAN_ACCESS_TOKEN env var
Replaces all references to DO_API_TOKEN with DIGITALOCEAN_ACCESS_TOKEN, matching DigitalOcean's official CLI and API documentation. This includes TypeScript source, tests, shell scripts, Packer config, CI workflows, and documentation. Supersedes #3068 (rebased onto current main). Agent: pr-maintainer Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent ccd8600 commit ab69a4d

16 files changed

Lines changed: 147 additions & 60 deletions

File tree

.claude/skills/setup-agent-team/qa-fixtures-prompt.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Cloud credentials are stored in `~/.config/spawn/{cloud}.json` (loaded by `sh/sh
3131

3232
For each cloud with a fixture directory, check if its required env vars are set:
3333
- **hetzner**: `HCLOUD_TOKEN`
34-
- **digitalocean**: `DO_API_TOKEN`
34+
- **digitalocean**: `DIGITALOCEAN_ACCESS_TOKEN`
3535
- **aws**: `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`
3636

3737
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
5353
curl -s -H "Authorization: Bearer ${HCLOUD_TOKEN}" "https://api.hetzner.cloud/v1/locations"
5454
```
5555

56-
### DigitalOcean (needs DO_API_TOKEN)
56+
### DigitalOcean (needs DIGITALOCEAN_ACCESS_TOKEN)
5757
```bash
58-
curl -s -H "Authorization: Bearer ${DO_API_TOKEN}" "https://api.digitalocean.com/v2/account/keys"
59-
curl -s -H "Authorization: Bearer ${DO_API_TOKEN}" "https://api.digitalocean.com/v2/sizes"
60-
curl -s -H "Authorization: Bearer ${DO_API_TOKEN}" "https://api.digitalocean.com/v2/regions"
58+
curl -s -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" "https://api.digitalocean.com/v2/account/keys"
59+
curl -s -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" "https://api.digitalocean.com/v2/sizes"
60+
curl -s -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" "https://api.digitalocean.com/v2/regions"
6161
```
6262

6363
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.

.github/workflows/packer-snapshots.yml

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -71,18 +71,18 @@ jobs:
7171
- name: Generate variables file
7272
run: |
7373
jq -n \
74-
--arg token "$DO_API_TOKEN" \
74+
--arg token "$DIGITALOCEAN_ACCESS_TOKEN" \
7575
--arg agent "$AGENT_NAME" \
7676
--arg tier "$TIER" \
7777
--argjson install "$INSTALL_COMMANDS" \
7878
'{
79-
do_api_token: $token,
79+
digitalocean_access_token: $token,
8080
agent_name: $agent,
8181
cloud_init_tier: $tier,
8282
install_commands: $install
8383
}' > packer/auto.pkrvars.json
8484
env:
85-
DO_API_TOKEN: ${{ secrets.DO_API_TOKEN }}
85+
DIGITALOCEAN_ACCESS_TOKEN: ${{ secrets.DO_API_TOKEN }}
8686
AGENT_NAME: ${{ matrix.agent }}
8787
TIER: ${{ steps.config.outputs.tier }}
8888
INSTALL_COMMANDS: ${{ steps.config.outputs.install }}
@@ -96,7 +96,7 @@ jobs:
9696
if: cancelled()
9797
run: |
9898
# Filter by spawn-packer tag to avoid destroying builder droplets from other workflows
99-
DROPLET_IDS=$(curl -s -H "Authorization: Bearer ${DO_API_TOKEN}" \
99+
DROPLET_IDS=$(curl -s -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" \
100100
"https://api.digitalocean.com/v2/droplets?per_page=200&tag_name=spawn-packer" \
101101
| jq -r '.droplets[].id')
102102
@@ -107,28 +107,28 @@ jobs:
107107
108108
for ID in $DROPLET_IDS; do
109109
echo "Destroying orphaned builder droplet: ${ID}"
110-
curl -s -X DELETE -H "Authorization: Bearer ${DO_API_TOKEN}" \
110+
curl -s -X DELETE -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" \
111111
"https://api.digitalocean.com/v2/droplets/${ID}" || true
112112
done
113113
env:
114-
DO_API_TOKEN: ${{ secrets.DO_API_TOKEN }}
114+
DIGITALOCEAN_ACCESS_TOKEN: ${{ secrets.DO_API_TOKEN }}
115115

116116
- name: Cleanup old snapshots
117117
if: success()
118118
run: |
119119
PREFIX="spawn-${AGENT_NAME}-"
120-
SNAPSHOTS=$(curl -s -H "Authorization: Bearer ${DO_API_TOKEN}" \
120+
SNAPSHOTS=$(curl -s -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" \
121121
"https://api.digitalocean.com/v2/images?private=true&per_page=100" \
122122
| jq -r --arg prefix "$PREFIX" \
123123
'[.images[] | select(.name | startswith($prefix))] | sort_by(.created_at) | reverse | .[1:] | .[].id')
124124
125125
for ID in $SNAPSHOTS; do
126126
echo "Deleting old snapshot: ${ID}"
127-
curl -s -X DELETE -H "Authorization: Bearer ${DO_API_TOKEN}" \
127+
curl -s -X DELETE -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" \
128128
"https://api.digitalocean.com/v2/images/${ID}" || true
129129
done
130130
env:
131-
DO_API_TOKEN: ${{ secrets.DO_API_TOKEN }}
131+
DIGITALOCEAN_ACCESS_TOKEN: ${{ secrets.DO_API_TOKEN }}
132132
AGENT_NAME: ${{ matrix.agent }}
133133

134134
- name: Submit to DO Marketplace
@@ -162,7 +162,7 @@ jobs:
162162
HTTP_CODE=$(curl -s -o /tmp/mp-response.json -w "%{http_code}" \
163163
-X PATCH \
164164
-H "Content-Type: application/json" \
165-
-H "Authorization: Bearer ${DO_API_TOKEN}" \
165+
-H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" \
166166
-d "$(jq -n \
167167
--arg reason "Nightly rebuild — $(date -u '+%Y-%m-%d')" \
168168
--argjson imageId "$IMG_ID" \
@@ -177,6 +177,6 @@ jobs:
177177
exit 1 ;;
178178
esac
179179
env:
180-
DO_API_TOKEN: ${{ secrets.DO_API_TOKEN }}
180+
DIGITALOCEAN_ACCESS_TOKEN: ${{ secrets.DO_API_TOKEN }}
181181
AGENT_NAME: ${{ matrix.agent }}
182182
MARKETPLACE_APP_IDS: ${{ secrets.MARKETPLACE_APP_IDS }}

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ export OPENROUTER_API_KEY=sk-or-v1-xxxxx
206206
# Cloud-specific credentials (varies by provider)
207207
# Note: Sprite uses `sprite login` for authentication
208208
export HCLOUD_TOKEN=... # For Hetzner
209-
export DO_API_TOKEN=... # For DigitalOcean
209+
export DIGITALOCEAN_ACCESS_TOKEN=... # For DigitalOcean
210210

211211
# Run non-interactively
212212
spawn claude hetzner
@@ -258,7 +258,7 @@ If spawn fails to install, try these steps:
258258
2. **Set credentials via environment variables** before launching:
259259
```powershell
260260
$env:OPENROUTER_API_KEY = "sk-or-v1-xxxxx"
261-
$env:DO_API_TOKEN = "dop_v1_xxxxx" # For DigitalOcean
261+
$env:DIGITALOCEAN_ACCESS_TOKEN = "dop_v1_xxxxx" # For DigitalOcean
262262
$env:HCLOUD_TOKEN = "xxxxx" # For Hetzner
263263
spawn openclaw digitalocean
264264
```

manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,7 @@
411411
"description": "Cloud servers (account + payment method required)",
412412
"url": "https://www.digitalocean.com/",
413413
"type": "api",
414-
"auth": "DO_API_TOKEN",
414+
"auth": "DIGITALOCEAN_ACCESS_TOKEN",
415415
"provision_method": "POST /v2/droplets with user_data",
416416
"exec_method": "ssh root@IP",
417417
"interactive_method": "ssh -t root@IP",

packages/cli/src/__tests__/commands-exported-utils.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ describe("parseAuthEnvVars", () => {
4747
});
4848

4949
it("should extract env var starting with letter followed by digits", () => {
50-
expect(parseAuthEnvVars("DO_API_TOKEN")).toEqual([
51-
"DO_API_TOKEN",
50+
expect(parseAuthEnvVars("DIGITALOCEAN_ACCESS_TOKEN")).toEqual([
51+
"DIGITALOCEAN_ACCESS_TOKEN",
5252
]);
5353
});
5454
});

packages/cli/src/__tests__/do-cov.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ describe("digitalocean/getServerIp", () => {
259259
);
260260
const { getServerIp } = await import("../digitalocean/digitalocean");
261261
// Need to set the token state
262-
process.env.DO_API_TOKEN = "test-token";
262+
process.env.DIGITALOCEAN_ACCESS_TOKEN = "test-token";
263263
// getServerIp calls doApi which uses internal state token - need to set via ensureDoToken
264264
// But doApi will use _state.token. Since we can't easily set _state, we test the 404 path
265265
// by mocking fetch to always return 404

packages/cli/src/__tests__/do-payment-warning.test.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,15 @@ describe("ensureDoToken — payment method warning for first-time users", () =>
2525
let warnSpy: ReturnType<typeof spyOn>;
2626

2727
beforeEach(() => {
28-
// Save and clear DO_API_TOKEN
29-
savedEnv["DO_API_TOKEN"] = process.env.DO_API_TOKEN;
30-
delete process.env.DO_API_TOKEN;
28+
// Save and clear all accepted DigitalOcean token env vars
29+
for (const v of [
30+
"DIGITALOCEAN_ACCESS_TOKEN",
31+
"DIGITALOCEAN_API_TOKEN",
32+
"DO_API_TOKEN",
33+
]) {
34+
savedEnv[v] = process.env[v];
35+
delete process.env[v];
36+
}
3137

3238
// Fail OAuth connectivity check → tryDoOAuth returns null immediately
3339
globalThis.fetch = mock(() => Promise.reject(new Error("Network unreachable")));
@@ -73,7 +79,25 @@ describe("ensureDoToken — payment method warning for first-time users", () =>
7379
expect(warnMessages.some((msg: string) => msg.includes("payment method"))).toBe(false);
7480
});
7581

76-
it("does NOT show payment warning when DO_API_TOKEN env var is set", async () => {
82+
it("does NOT show payment warning when DIGITALOCEAN_ACCESS_TOKEN env var is set", async () => {
83+
process.env.DIGITALOCEAN_ACCESS_TOKEN = "dop_v1_invalid_env_token";
84+
85+
await expect(ensureDoToken()).rejects.toThrow();
86+
87+
const warnMessages = warnSpy.mock.calls.map((c: unknown[]) => String(c[0]));
88+
expect(warnMessages.some((msg: string) => msg.includes("payment method"))).toBe(false);
89+
});
90+
91+
it("does NOT show payment warning when DIGITALOCEAN_API_TOKEN env var is set", async () => {
92+
process.env.DIGITALOCEAN_API_TOKEN = "dop_v1_invalid_env_token";
93+
94+
await expect(ensureDoToken()).rejects.toThrow();
95+
96+
const warnMessages = warnSpy.mock.calls.map((c: unknown[]) => String(c[0]));
97+
expect(warnMessages.some((msg: string) => msg.includes("payment method"))).toBe(false);
98+
});
99+
100+
it("does NOT show payment warning when legacy DO_API_TOKEN env var is set", async () => {
77101
process.env.DO_API_TOKEN = "dop_v1_invalid_env_token";
78102

79103
await expect(ensureDoToken()).rejects.toThrow();

packages/cli/src/__tests__/run-path-credential-display.test.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ function makeManifest(overrides?: Partial<Manifest>): Manifest {
6868
price: "test",
6969
url: "https://digitalocean.com",
7070
type: "api",
71-
auth: "DO_API_TOKEN",
71+
auth: "DIGITALOCEAN_ACCESS_TOKEN",
7272
provision_method: "api",
7373
exec_method: "ssh root@IP",
7474
interactive_method: "ssh -t root@IP",
@@ -138,6 +138,8 @@ describe("prioritizeCloudsByCredentials", () => {
138138
// Save and clear credential env vars
139139
for (const v of [
140140
"HCLOUD_TOKEN",
141+
"DIGITALOCEAN_ACCESS_TOKEN",
142+
"DIGITALOCEAN_API_TOKEN",
141143
"DO_API_TOKEN",
142144
"UPCLOUD_USERNAME",
143145
"UPCLOUD_PASSWORD",
@@ -191,7 +193,7 @@ describe("prioritizeCloudsByCredentials", () => {
191193

192194
it("should move multiple credential clouds to front", () => {
193195
process.env.HCLOUD_TOKEN = "test-token";
194-
process.env.DO_API_TOKEN = "test-do-token";
196+
process.env.DIGITALOCEAN_ACCESS_TOKEN = "test-do-token";
195197
const manifest = makeManifest();
196198
const clouds = [
197199
"upcloud",
@@ -290,7 +292,7 @@ describe("prioritizeCloudsByCredentials", () => {
290292

291293
it("should preserve relative order within each group", () => {
292294
process.env.HCLOUD_TOKEN = "token";
293-
process.env.DO_API_TOKEN = "token";
295+
process.env.DIGITALOCEAN_ACCESS_TOKEN = "token";
294296
const manifest = makeManifest();
295297
// Input order: digitalocean before hetzner (both have creds)
296298
const clouds = [
@@ -331,7 +333,7 @@ describe("prioritizeCloudsByCredentials", () => {
331333

332334
it("should count all credential clouds correctly with all set", () => {
333335
process.env.HCLOUD_TOKEN = "t1";
334-
process.env.DO_API_TOKEN = "t2";
336+
process.env.DIGITALOCEAN_ACCESS_TOKEN = "t2";
335337
process.env.UPCLOUD_USERNAME = "u";
336338
process.env.UPCLOUD_PASSWORD = "p";
337339
const manifest = makeManifest();
@@ -350,4 +352,30 @@ describe("prioritizeCloudsByCredentials", () => {
350352
expect(result.sortedClouds.slice(3)).toContain("sprite");
351353
expect(result.sortedClouds.slice(3)).toContain("localcloud");
352354
});
355+
356+
it("should recognize legacy DO_API_TOKEN as alias for DIGITALOCEAN_ACCESS_TOKEN", () => {
357+
process.env.DO_API_TOKEN = "legacy-token";
358+
const manifest = makeManifest();
359+
const clouds = [
360+
"digitalocean",
361+
"hetzner",
362+
];
363+
const result = prioritizeCloudsByCredentials(clouds, manifest);
364+
365+
expect(result.credCount).toBe(1);
366+
expect(result.sortedClouds[0]).toBe("digitalocean");
367+
});
368+
369+
it("should recognize DIGITALOCEAN_API_TOKEN as alias for DIGITALOCEAN_ACCESS_TOKEN", () => {
370+
process.env.DIGITALOCEAN_API_TOKEN = "alt-token";
371+
const manifest = makeManifest();
372+
const clouds = [
373+
"digitalocean",
374+
"hetzner",
375+
];
376+
const result = prioritizeCloudsByCredentials(clouds, manifest);
377+
378+
expect(result.credCount).toBe(1);
379+
expect(result.sortedClouds[0]).toBe("digitalocean");
380+
});
353381
});

packages/cli/src/__tests__/script-failure-guidance.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -209,28 +209,28 @@ describe("getScriptFailureGuidance", () => {
209209

210210
it("should show specific env var name and setup hint for default case when authHint is provided", () => {
211211
const savedOR = process.env.OPENROUTER_API_KEY;
212-
const savedDO = process.env.DO_API_TOKEN;
212+
const savedDO = process.env.DIGITALOCEAN_ACCESS_TOKEN;
213213
delete process.env.OPENROUTER_API_KEY;
214-
delete process.env.DO_API_TOKEN;
215-
const lines = stripped_getScriptFailureGuidance(42, "digitalocean", "DO_API_TOKEN");
214+
delete process.env.DIGITALOCEAN_ACCESS_TOKEN;
215+
const lines = stripped_getScriptFailureGuidance(42, "digitalocean", "DIGITALOCEAN_ACCESS_TOKEN");
216216
const joined = lines.join("\n");
217-
expect(joined).toContain("DO_API_TOKEN");
217+
expect(joined).toContain("DIGITALOCEAN_ACCESS_TOKEN");
218218
expect(joined).toContain("OPENROUTER_API_KEY");
219219
expect(joined).toContain("spawn digitalocean");
220220
expect(joined).toContain("setup");
221221
if (savedOR !== undefined) {
222222
process.env.OPENROUTER_API_KEY = savedOR;
223223
}
224224
if (savedDO !== undefined) {
225-
process.env.DO_API_TOKEN = savedDO;
225+
process.env.DIGITALOCEAN_ACCESS_TOKEN = savedDO;
226226
}
227227
});
228228

229229
it("should show generic setup hint for default case when no authHint", () => {
230230
const lines = stripped_getScriptFailureGuidance(42, "digitalocean");
231231
const joined = lines.join("\n");
232232
expect(joined).toContain("spawn digitalocean");
233-
expect(joined).not.toContain("DO_API_TOKEN");
233+
expect(joined).not.toContain("DIGITALOCEAN_ACCESS_TOKEN");
234234
});
235235

236236
it("should handle multi-credential auth hint", () => {

packages/cli/src/commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export {
5858
getImplementedClouds,
5959
hasCloudCli,
6060
hasCloudCredentials,
61+
isAuthEnvVarSet,
6162
isInteractiveTTY,
6263
levenshtein,
6364
loadManifestWithSpinner,

0 commit comments

Comments
 (0)