From af300ba24824313e585f65fd914b60bd2a064c9b Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Wed, 18 Mar 2026 01:18:06 -0700 Subject: [PATCH] fix(digitalocean): paginate SSH keys/droplets and harden key registration check (#2758) Add doGetAll() pagination helper (matching Hetzner's hetznerGetAll pattern) and use it for all three unpaginated DO API calls: - ensureSshKey(): /account/keys (was silently truncated at 20 keys) - createServer(): /account/keys (same issue for SSH key ID collection) - listServers(): /droplets (was silently truncated at 20 droplets) Replace fragile `regText.includes('"id"')` string check with proper `parseJsonObj(regText)?.ssh_key` validation for SSH key registration. Fixes #2748 Fixes #2749 Agent: code-health Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 --- packages/cli/src/digitalocean/digitalocean.ts | 48 ++++++++++++------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/digitalocean/digitalocean.ts b/packages/cli/src/digitalocean/digitalocean.ts index f28ae66f..ca8e3fa7 100644 --- a/packages/cli/src/digitalocean/digitalocean.ts +++ b/packages/cli/src/digitalocean/digitalocean.ts @@ -223,6 +223,30 @@ async function doApi(method: string, endpoint: string, body?: string, maxRetries throw new Error("doApi: unreachable"); } +/** + * Paginate a DigitalOcean GET collection endpoint. + * Returns all items from the given `key` across all pages. + */ +async function doGetAll(endpoint: string, key: string): Promise[]> { + const perPage = 50; + const sep = endpoint.includes("?") ? "&" : "?"; + let page = 1; + const all: Record[] = []; + for (;;) { + const resp = await doApi("GET", `${endpoint}${sep}per_page=${perPage}&page=${page}`); + const data = parseJsonObj(resp); + const items = toObjectArray(data?.[key]); + for (const item of items) { + all.push(toRecord(item) ?? {}); + } + if (items.length < perPage) { + break; + } + page = page + 1; + } + return all; +} + // ─── Token Persistence ─────────────────────────────────────────────────────── function loadConfig(): Record | null { @@ -769,10 +793,8 @@ export async function ensureDoToken(): Promise { export async function ensureSshKey(): Promise { const selectedKeys = await ensureSshKeys(); - // Fetch registered keys once before the loop to avoid N+1 API calls - const keysText = await doApi("GET", "/account/keys"); - const data = parseJsonObj(keysText); - const keys = toObjectArray(data?.ssh_keys); + // Fetch all registered keys (paginated) once before the loop to avoid N+1 API calls + const keys = await doGetAll("/account/keys", "ssh_keys"); for (const key of selectedKeys) { const fingerprint = getSshFingerprint(key.pubPath); @@ -809,9 +831,8 @@ export async function ensureSshKey(): Promise { logWarn(`SSH key '${key.name}' registration may have failed, continuing...`); continue; } - const regText = regResult.data; - - if (regText.includes('"id"')) { + const regData = parseJsonObj(regResult.data); + if (regData?.ssh_key) { logInfo(`SSH key '${key.name}' registered with DigitalOcean`); continue; } @@ -1003,12 +1024,9 @@ export async function createServer( `Creating DigitalOcean droplet '${name}' (size: ${size}, region: ${effectiveRegion}, image: ${imageLabel})...`, ); - // Get all SSH key IDs - const keysText = await doApi("GET", "/account/keys"); - const keysData = parseJsonObj(keysText); - const sshKeyIds: number[] = toObjectArray(keysData?.ssh_keys) - .map((k) => (isNumber(k.id) ? k.id : 0)) - .filter((n) => n > 0); + // Get all SSH key IDs (paginated to avoid missing keys beyond page 1) + const allKeys = await doGetAll("/account/keys", "ssh_keys"); + const sshKeyIds: number[] = allKeys.map((k) => (isNumber(k.id) ? k.id : 0)).filter((n) => n > 0); const dropletConfig: Record = { name, @@ -1519,9 +1537,7 @@ export async function getServerIp(dropletId: string): Promise { /** List all DigitalOcean droplets. Returns simplified instance info for the remap picker. */ export async function listServers(): Promise { - const resp = await doApi("GET", "/droplets"); - const data = parseJsonObj(resp); - const droplets = toObjectArray(data?.droplets); + const droplets = await doGetAll("/droplets", "droplets"); const results: CloudInstance[] = []; for (const d of droplets) { const v4Networks = toObjectArray(d?.networks?.v4);