diff --git a/README.md b/README.md index a13c3bd..770ae6d 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ Laguna S 2.1 free requires an active Go (or higher) account with credits on file ```text OpenCode └─ /v1/chat/completions - └─ Bun.serve proxy (port 8797) + └─ Bun.serve proxy (dynamic port, prefer 8797) └─ POST https://api.commandcode.ai/alpha/generate └─ poolside/laguna-s-2.1-free (default) ``` @@ -111,7 +111,7 @@ Debug: `OPENCODE_COMMANDCODE_DEBUG=1`. Optional knobs: -- `OPENCODE_COMMANDCODE_PROXY_PORT` — fixed local proxy port (default `8797`) +- `OPENCODE_COMMANDCODE_PROXY_PORT` — pin a fixed local proxy port (otherwise dynamic: prefer `8797`, then scan upward / ephemeral) - `OPENCODE_COMMANDCODE_CWD` — working directory reported to the gateway - `COMMANDCODE_API_URL` — override API base (default `https://api.commandcode.ai`) diff --git a/src/index.ts b/src/index.ts index 13f2c9f..69bd15d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -289,9 +289,7 @@ export const CommandCodePlugin: Plugin = async ( return { async config(config) { - // Always seed Laguna catalog so the provider is discoverable pre-login. - ensureProviderConfig(config as Record, getCommandModels()); - + // Bind proxy first so provider baseURL matches the actual listening port. await startProxy(async () => { try { const authClient = input.client.auth as { @@ -311,6 +309,9 @@ export const CommandCodePlugin: Plugin = async ( const synced = syncCommandCodeCredentialsToOpenCode(); return synced?.access ?? null; }); + + // Always seed Laguna catalog so the provider is discoverable pre-login. + ensureProviderConfig(config as Record, getCommandModels()); }, "chat.headers": async (hookInput, output) => { diff --git a/src/proxy.ts b/src/proxy.ts index fb77902..4751d61 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -50,14 +50,16 @@ import { } from "./usage.js"; const DEFAULT_PROXY_PORT = 8797; +const PROXY_PORT_SCAN_ATTEMPTS = 40; const SHARED_PROXY_HEALTH_TIMEOUT_MS = 750; -function configuredProxyPort(): number { +/** Explicit pin via env, or `null` → pick a free port dynamically. */ +function envProxyPort(): number | null { const raw = process.env.OPENCODE_COMMANDCODE_PROXY_PORT; const parsed = raw ? Number(raw) : NaN; return Number.isInteger(parsed) && parsed > 0 && parsed < 65536 ? parsed - : DEFAULT_PROXY_PORT; + : null; } const SSE_HEADERS = { @@ -93,11 +95,12 @@ export function setStreamGenerateForTests( } export function getCommandProxyBaseUrl(): string { - return `http://127.0.0.1:${proxyPort ?? configuredProxyPort()}/v1`; + const port = proxyPort ?? envProxyPort() ?? DEFAULT_PROXY_PORT; + return `http://127.0.0.1:${port}/v1`; } export function getProxyPort(): number | null { - return proxyPort ?? configuredProxyPort(); + return proxyPort ?? envProxyPort(); } function isAddrInUseError(err: unknown): boolean { @@ -111,19 +114,16 @@ function isAddrInUseError(err: unknown): boolean { ); } -async function isSharedProxyHealthy(): Promise { +async function isProxyHealthyOn(port: number): Promise { const controller = new AbortController(); const timeout = setTimeout( () => controller.abort(), SHARED_PROXY_HEALTH_TIMEOUT_MS, ); try { - const res = await fetch( - `http://127.0.0.1:${configuredProxyPort()}/v1/models`, - { - signal: controller.signal, - }, - ); + const res = await fetch(`http://127.0.0.1:${port}/v1/models`, { + signal: controller.signal, + }); if (!res.ok) return false; const body = (await res.json().catch(() => undefined)) as | { object?: unknown; data?: unknown } @@ -136,44 +136,76 @@ async function isSharedProxyHealthy(): Promise { } } +async function bindProxy(port: number): Promise { + const hostname = "127.0.0.1"; + server = Bun.serve({ + hostname, + port, + async fetch(req) { + return handleRequest(req); + }, + }); + proxyPort = server.port ?? port; + log.info( + `[opencode-commandcode] proxy listening on ${getCommandProxyBaseUrl()}`, + ); + return proxyPort; +} + +/** + * Start the local OpenAI-compatible proxy. + * + * Port selection: + * - `OPENCODE_COMMANDCODE_PROXY_PORT` → bind/reuse that exact port + * - otherwise prefer `8797`, then scan upward, then fall back to OS ephemeral (`0`) + */ export async function startProxy(tokenProvider: TokenProvider): Promise { getAccessToken = tokenProvider; if (server && proxyPort) return proxyPort; - const desiredPort = configuredProxyPort(); + const fixed = envProxyPort(); + if (fixed !== null) { + if (await isProxyHealthyOn(fixed)) { + proxyPort = fixed; + log.info( + `[opencode-commandcode] reusing healthy proxy on ${getCommandProxyBaseUrl()}`, + ); + return proxyPort; + } + try { + return await bindProxy(fixed); + } catch (err) { + if (isAddrInUseError(err) && (await isProxyHealthyOn(fixed))) { + proxyPort = fixed; + log.info( + `[opencode-commandcode] port ${fixed} in use; reusing existing proxy`, + ); + return proxyPort; + } + throw err; + } + } - if (await isSharedProxyHealthy()) { - proxyPort = desiredPort; + // Dynamic: reuse preferred default when a healthy proxy is already there. + if (await isProxyHealthyOn(DEFAULT_PROXY_PORT)) { + proxyPort = DEFAULT_PROXY_PORT; log.info( `[opencode-commandcode] reusing healthy proxy on ${getCommandProxyBaseUrl()}`, ); return proxyPort; } - const hostname = "127.0.0.1"; - try { - server = Bun.serve({ - hostname, - port: desiredPort, - async fetch(req) { - return handleRequest(req); - }, - }); - proxyPort = server.port ?? desiredPort; - log.info( - `[opencode-commandcode] proxy listening on ${getCommandProxyBaseUrl()}`, - ); - return proxyPort; - } catch (err) { - if (isAddrInUseError(err) && (await isSharedProxyHealthy())) { - proxyPort = desiredPort; - log.info( - `[opencode-commandcode] port ${desiredPort} in use; reusing existing proxy`, - ); - return proxyPort; + for (let i = 0; i < PROXY_PORT_SCAN_ATTEMPTS; i++) { + const candidate = DEFAULT_PROXY_PORT + i; + try { + return await bindProxy(candidate); + } catch (err) { + if (!isAddrInUseError(err)) throw err; } - throw err; } + + // Last resort — let the OS pick an ephemeral port. + return await bindProxy(0); } export async function stopProxy(): Promise { diff --git a/test/live-laguna.ts b/test/live-laguna.ts index c5c138f..5ce7c29 100644 --- a/test/live-laguna.ts +++ b/test/live-laguna.ts @@ -71,9 +71,6 @@ function sseAssistantText(sse: string): string { } async function main() { - // Avoid colliding with an OpenCode CLI proxy already bound to :8797. - process.env.OPENCODE_COMMANDCODE_PROXY_PORT ||= "8798"; - const apiKey = requireAuth(); setStreamGenerateForTests(null); resetUsageStore(); diff --git a/test/live-matrix.ts b/test/live-matrix.ts index 5ffc34c..0dfdcb4 100644 --- a/test/live-matrix.ts +++ b/test/live-matrix.ts @@ -202,9 +202,6 @@ const TOOL_MCPISH = { }; async function main() { - // Keep live matrix off the default OpenCode proxy port (8797). - process.env.OPENCODE_COMMANDCODE_PROXY_PORT ||= "8798"; - const apiKey = requireAuth(); setStreamGenerateForTests(null); resetUsageStore(); diff --git a/test/smoke.ts b/test/smoke.ts index e2cbf24..e87b792 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -596,6 +596,30 @@ async function main() { await stopProxy(); + // Dynamic port: when preferred 8797 is occupied by a non-proxy listener, + // startProxy must bind another free port (not fail). + { + delete process.env.OPENCODE_COMMANDCODE_PROXY_PORT; + const blocker = Bun.serve({ + hostname: "127.0.0.1", + port: 8797, + fetch() { + return new Response("blocked"); + }, + }); + try { + const dynPort = await startProxy(async () => "test-api-key"); + assert.ok(dynPort > 0); + assert.notEqual(dynPort, 8797); + assert.ok(getCommandProxyBaseUrl().includes(String(dynPort))); + const healthDyn = await fetch(`http://127.0.0.1:${dynPort}/health`); + assert.equal(healthDyn.status, 200); + await stopProxy(); + } finally { + blocker.stop(true); + } + } + // TypeScript build const build = spawnSync("bun", ["run", "build"], { cwd: new URL("..", import.meta.url).pathname,