diff --git a/packages/cli/package.json b/packages/cli/package.json index 66d4c592..d718167d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.15.15", + "version": "0.15.16", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/orchestrate.test.ts b/packages/cli/src/__tests__/orchestrate.test.ts index 5544b7de..292ba4c0 100644 --- a/packages/cli/src/__tests__/orchestrate.test.ts +++ b/packages/cli/src/__tests__/orchestrate.test.ts @@ -169,6 +169,31 @@ describe("runOrchestration", () => { exitSpy.mockRestore(); }); + it("obtains API key before preProvision (no surprise prompts after cloud auth)", async () => { + const callOrder: string[] = []; + mockGetOrPromptApiKey.mockImplementation(async () => { + callOrder.push("getApiKey"); + return "sk-or-v1-test-key"; + }); + const cloud = createMockCloud({ + authenticate: mock(async () => { + callOrder.push("authenticate"); + }), + }); + const agent = createMockAgent({ + preProvision: mock(async () => { + callOrder.push("preProvision"); + }), + }); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + expect(callOrder.indexOf("authenticate")).toBeLessThan(callOrder.indexOf("getApiKey")); + expect(callOrder.indexOf("getApiKey")).toBeLessThan(callOrder.indexOf("preProvision")); + stderrSpy.mockRestore(); + exitSpy.mockRestore(); + }); + it("passes API key to agent.envVars", async () => { const envVarsFn = mock((key: string) => [ `OPENROUTER_API_KEY=${key}`, diff --git a/packages/cli/src/digitalocean/main.ts b/packages/cli/src/digitalocean/main.ts index 14f73c87..16b5dde4 100644 --- a/packages/cli/src/digitalocean/main.ts +++ b/packages/cli/src/digitalocean/main.ts @@ -6,7 +6,6 @@ import type { CloudOrchestrator } from "../shared/orchestrate"; import { saveLaunchCmd } from "../history.js"; import { runOrchestration } from "../shared/orchestrate"; -import { logStep } from "../shared/ui"; import { agents, resolveAgent } from "./agents"; import { checkAccountStatus, @@ -49,12 +48,8 @@ async function main() { }, async authenticate() { await promptSpawnName(); - const usedBrowserAuth = await ensureDoToken(); + await ensureDoToken(); await ensureSshKey(); - if (usedBrowserAuth) { - logStep("Next step: OpenRouter authentication (opening browser in 5s)..."); - await new Promise((r) => setTimeout(r, 5000)); - } }, async checkAccountReady() { await checkAccountStatus(); diff --git a/packages/cli/src/shared/oauth.ts b/packages/cli/src/shared/oauth.ts index a955004f..e958b191 100644 --- a/packages/cli/src/shared/oauth.ts +++ b/packages/cli/src/shared/oauth.ts @@ -1,9 +1,12 @@ // shared/oauth.ts — OpenRouter OAuth flow + API key management +import { mkdirSync, readFileSync } from "node:fs"; +import { dirname } from "node:path"; import * as v from "valibot"; import { OAUTH_CODE_REGEX } from "./oauth-constants"; import { parseJsonWith } from "./parse"; -import { logError, logInfo, logStep, logWarn, openBrowser, prompt } from "./ui"; +import { isString } from "./type-guards"; +import { getSpawnCloudConfigPath, logError, logInfo, logStep, logWarn, openBrowser, prompt } from "./ui"; // ─── Schemas ───────────────────────────────────────────────────────────────── @@ -211,6 +214,49 @@ async function tryOauthFlow(callbackPort = 5180, agentSlug?: string, cloudSlug?: } } +// ─── API Key Persistence ───────────────────────────────────────────────────── + +/** Save OpenRouter API key to ~/.config/spawn/openrouter.json so it persists across runs. */ +async function saveOpenRouterKey(key: string): Promise { + try { + const configPath = getSpawnCloudConfigPath("openrouter"); + mkdirSync(dirname(configPath), { + recursive: true, + mode: 0o700, + }); + await Bun.write( + configPath, + JSON.stringify( + { + api_key: key, + }, + null, + 2, + ) + "\n", + { + mode: 0o600, + }, + ); + } catch { + // non-fatal — key still works in memory for this session + } +} + +/** Load a previously saved OpenRouter API key from ~/.config/spawn/openrouter.json. */ +function loadSavedOpenRouterKey(): string | null { + try { + const configPath = getSpawnCloudConfigPath("openrouter"); + const data = JSON.parse(readFileSync(configPath, "utf-8")); + const key = isString(data.api_key) ? data.api_key : ""; + if (key && /^sk-or-v1-[a-f0-9]{64}$/.test(key)) { + return key; + } + return null; + } catch { + return null; + } +} + // ─── Main API Key Acquisition ──────────────────────────────────────────────── async function promptAndValidateApiKey(): Promise { @@ -249,12 +295,24 @@ export async function getOrPromptApiKey(agentSlug?: string, cloudSlug?: string): logWarn("Environment key failed validation, prompting for a new one..."); } - // 2. Try OAuth + manual fallback (3 attempts) + // 2. Check saved key from previous session + const savedKey = loadSavedOpenRouterKey(); + if (savedKey) { + logInfo("Using saved OpenRouter API key"); + if (await verifyOpenrouterKey(savedKey)) { + process.env.OPENROUTER_API_KEY = savedKey; + return savedKey; + } + logWarn("Saved key failed validation, prompting for a new one..."); + } + + // 3. Try OAuth + manual fallback (3 attempts) for (let attempt = 1; attempt <= 3; attempt++) { // Try OAuth first const key = await tryOauthFlow(5180, agentSlug, cloudSlug); if (key && (await verifyOpenrouterKey(key))) { process.env.OPENROUTER_API_KEY = key; + await saveOpenRouterKey(key); return key; } @@ -278,6 +336,7 @@ export async function getOrPromptApiKey(agentSlug?: string, cloudSlug?: string): const manualKey = await promptAndValidateApiKey(); if (manualKey && (await verifyOpenrouterKey(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 6222f567..518e8df0 100644 --- a/packages/cli/src/shared/orchestrate.ts +++ b/packages/cli/src/shared/orchestrate.ts @@ -79,7 +79,11 @@ export async function runOrchestration( } } - // 2. Pre-provision hooks + // 2. Get API key (immediately after cloud auth — before any other prompts + // so the "opening browser" message leads directly to OpenRouter OAuth) + const apiKey = await getOrPromptApiKey(agentName, cloud.cloudName); + + // 3. Pre-provision hooks (e.g., GitHub auth prompt — non-fatal) if (agent.preProvision) { try { await agent.preProvision(); @@ -88,9 +92,6 @@ export async function runOrchestration( } } - // 3. Get API key (before provisioning so user isn't waiting) - const apiKey = await getOrPromptApiKey(agentName, cloud.cloudName); - // 4. Model ID (use agent default — no interactive prompt) const modelId = agent.modelDefault || process.env.MODEL_ID;