mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-17 04:11:23 +00:00
fix(digitalocean): use canonical DIGITALOCEAN_ACCESS_TOKEN env var (#3099)
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: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b9473f25b8
commit
0bd8930c09
16 changed files with 147 additions and 60 deletions
|
|
@ -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.
|
||||
|
|
|
|||
22
.github/workflows/packer-snapshots.yml
vendored
22
.github/workflows/packer-snapshots.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -408,7 +408,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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -25,9 +25,15 @@ describe("ensureDoToken — payment method warning for first-time users", () =>
|
|||
let warnSpy: ReturnType<typeof spyOn>;
|
||||
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ function makeManifest(overrides?: Partial<Manifest>): 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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ export {
|
|||
getImplementedClouds,
|
||||
hasCloudCli,
|
||||
hasCloudCredentials,
|
||||
isAuthEnvVarSet,
|
||||
isInteractiveTTY,
|
||||
levenshtein,
|
||||
loadManifestWithSpinner,
|
||||
|
|
|
|||
|
|
@ -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<string, string[]> = {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -666,14 +666,14 @@ async function tryDoOAuth(): Promise<string | null> {
|
|||
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<string | null> {
|
|||
|
||||
/** Returns true if browser OAuth was triggered (so caller can delay before next OAuth). */
|
||||
export async function ensureDoToken(): Promise<boolean> {
|
||||
// 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<boolean> {
|
|||
|
||||
// 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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue