diff --git a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts index 027ef949c..e4ad07d14 100644 --- a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts +++ b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts @@ -1,10 +1,12 @@ import type { ChildProcess } from "node:child_process"; import { EventEmitter } from "node:events"; import { mkdtempSync, rmSync } from "node:fs"; +import net from "node:net"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, test, vi } from "vitest"; +import { findExecutable } from "../../../executable-resolution/executable-resolution.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import type { ManagedProcessRecord, @@ -236,6 +238,58 @@ describe("OpenCodeServerManager managed process ledger", () => { }); }); +describe.runIf(process.platform === "win32")( + "OpenCodeServerManager Windows OpenCode npm install", + () => { + test("starts the helper server from opencode.exe instead of the npm opencode.cmd shim", async () => { + const detectedOpenCode = await findExecutable("opencode"); + expect(detectedOpenCode, "Windows CI must install opencode-ai before server tests").not.toBe( + null, + ); + expect(path.extname(detectedOpenCode!).toLowerCase()).toBe(".cmd"); + + const tempDir = mkdtempSync(path.join(os.tmpdir(), "opencode-real-windows-")); + const opencodeHomeDir = path.join(tempDir, "opencode-home"); + const managedProcesses = new FakeManagedProcesses(); + const manager = new OpenCodeServerManager({ + logger: createTestLogger(), + managedProcesses, + resolveHomeDir: () => opencodeHomeDir, + }); + let acquiredPort: number | null = null; + + try { + const acquisition = await manager.acquireDedicated({ + OPENCODE_AUTH_CONTENT: "{}", + OPENCODE_DISABLE_AUTOUPDATE: "1", + OPENCODE_DISABLE_AUTOCOMPACT: "1", + OPENCODE_DISABLE_MODELS_FETCH: "1", + OPENCODE_DISABLE_PROJECT_CONFIG: "1", + OPENCODE_PURE: "1", + OPENCODE_TEST_HOME: path.join(tempDir, "test-home"), + }); + acquiredPort = acquisition.server.port; + + const records = await managedProcesses.list(); + expect(records).toHaveLength(1); + const record = records[0]!; + expect(path.extname(record.command).toLowerCase()).toBe(".exe"); + expect(path.normalize(record.command).toLowerCase()).toContain( + path.normalize("node_modules/opencode-ai/bin/opencode.exe").toLowerCase(), + ); + expect(record.command.toLowerCase()).not.toBe(detectedOpenCode!.toLowerCase()); + expect(record.args).toEqual(["serve", "--port", String(acquiredPort)]); + } finally { + await manager.shutdown().catch(() => undefined); + if (acquiredPort !== null) { + await waitForClosedPort(acquiredPort, 5_000); + } + rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + }, 60_000); + }, +); + function createTestManager( ports: number[], options: { autoAnnounce?: boolean; opencodeHomeDir?: string } = {}, @@ -413,3 +467,37 @@ class FakeManagedProcesses implements ManagedProcessRegistry { }; } } + +async function waitForClosedPort(port: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!(await canConnectToPort(port))) { + return; + } + await sleep(100); + } + throw new Error(`OpenCode helper server still accepts connections on port ${port}`); +} + +function canConnectToPort(port: number): Promise { + return new Promise((resolve) => { + const socket = net.createConnection({ host: "127.0.0.1", port }); + let settled = false; + const settle = (connected: boolean) => { + if (settled) { + return; + } + settled = true; + socket.destroy(); + resolve(connected); + }; + + socket.once("connect", () => settle(true)); + socket.once("error", () => settle(false)); + socket.setTimeout(500, () => settle(false)); + }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/server/src/server/agent/providers/opencode/server-manager.ts b/packages/server/src/server/agent/providers/opencode/server-manager.ts index 634cc55c6..566151f73 100644 --- a/packages/server/src/server/agent/providers/opencode/server-manager.ts +++ b/packages/server/src/server/agent/providers/opencode/server-manager.ts @@ -1,6 +1,8 @@ import type { ChildProcess } from "node:child_process"; import { mkdirSync } from "node:fs"; +import { stat } from "node:fs/promises"; import net from "node:net"; +import path from "node:path"; import type { Logger } from "pino"; import { findExecutable } from "../../../../executable-resolution/executable-resolution.js"; @@ -495,12 +497,50 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { async function resolveOpenCodeBinary(): Promise { const found = await findExecutable("opencode"); - if (found) { - return found; + if (!found) { + throw new Error( + "OpenCode binary not found. Install OpenCode (https://github.com/opencode-ai/opencode) and ensure it is available in your shell PATH.", + ); + } + + if (process.platform === "win32" && path.extname(found).toLowerCase() === ".cmd") { + // Global npm: /opencode.cmd → /node_modules/opencode-ai/bin/opencode.exe + const globalCandidate = path.join( + path.dirname(found), + "node_modules", + "opencode-ai", + "bin", + "opencode.exe", + ); + if (await pathExists(globalCandidate)) return globalCandidate; + + // Local/pnpm: /node_modules/.bin/opencode.cmd → /node_modules/opencode-ai/bin/opencode.exe + const localCandidate = path.join( + path.dirname(found), + "..", + "opencode-ai", + "bin", + "opencode.exe", + ); + if (await pathExists(localCandidate)) return localCandidate; + + console.warn( + "[opencode-server] Found opencode.cmd but could not resolve the real opencode.exe. " + + "The process may not be properly terminated on exit. Path: %s", + found, + ); + } + + return found; +} + +async function pathExists(filePath: string): Promise { + try { + await stat(filePath); + return true; + } catch { + return false; } - throw new Error( - "OpenCode binary not found. Install OpenCode (https://github.com/opencode-ai/opencode) and ensure it is available in your shell PATH.", - ); } function findAvailablePort(): Promise {