diff --git a/src/opencode/process.ts b/src/opencode/process.ts index 4681d8ee..40a41d2e 100644 --- a/src/opencode/process.ts +++ b/src/opencode/process.ts @@ -1,4 +1,6 @@ import { exec, spawn, type ChildProcess } from "node:child_process"; +import { existsSync } from "node:fs"; +import * as path from "node:path"; import { promisify } from "node:util"; const execAsync = promisify(exec); @@ -43,16 +45,59 @@ export function resolveLocalOpencodeTarget(apiUrl: string): LocalOpencodeTarget } } +function resolveWindowsOpencodeExe(): string { + // npm on Windows usually puts opencode.cmd on PATH (not opencode.exe). + // We locate the shim and derive the real exe path from its directory. + const pathEnv = process.env.PATH ?? ""; + const pathEntries = pathEnv.split(path.delimiter).filter(Boolean); + + for (const entry of pathEntries) { + const opencodeCmd = path.join(entry, "opencode.cmd"); + if (!existsSync(opencodeCmd)) { + continue; + } + + const candidateExe = path.join(entry, "node_modules", "opencode-ai", "bin", "opencode.exe"); + if (existsSync(candidateExe)) { + return candidateExe; + } + + // Found the shim but not the exe where it usually lives. Stop searching. + break; + } + + return ""; +} + export function createOpencodeServeSpawnCommand( target: LocalOpencodeTarget, ): OpencodeServeSpawnCommand { const isWindows = process.platform === "win32"; const port = target.port.toString(); + if (isWindows) { + const resolvedExe = resolveWindowsOpencodeExe(); + + if (resolvedExe) { + return { + command: resolvedExe, + args: ["serve", "--port", port], + windowsHide: true, + }; + } + + // Safe fallback: works with default npm installs where only opencode.cmd is on PATH. + return { + command: "cmd.exe", + args: ["/c", "opencode", "serve", "--port", port], + windowsHide: true, + }; + } + return { - command: isWindows ? "cmd.exe" : "opencode", - args: isWindows ? ["/c", "opencode", "serve", "--port", port] : ["serve", "--port", port], - windowsHide: isWindows, + command: "opencode", + args: ["serve", "--port", port], + windowsHide: false, }; } diff --git a/tests/opencode/process.test.ts b/tests/opencode/process.test.ts index 893b77ea..0d3ef09c 100644 --- a/tests/opencode/process.test.ts +++ b/tests/opencode/process.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; +import * as path from "node:path"; +import * as fs from "node:fs"; +import * as os from "node:os"; + import { createOpencodeServeSpawnCommand, findUnixListeningPidInSs, @@ -29,11 +33,18 @@ describe("opencode/process", () => { const command = createOpencodeServeSpawnCommand({ host: "localhost", port: 4987 }); if (process.platform === "win32") { - expect(command).toEqual({ - command: "cmd.exe", - args: ["/c", "opencode", "serve", "--port", "4987"], - windowsHide: true, - }); + expect(command.windowsHide).toBe(true); + + // If we claim to spawn opencode.exe directly, it must be a real absolute path. + // Otherwise, spawn() will likely fail with ENOENT on default npm installs where + // only opencode.cmd is on PATH. + if (command.command.toLowerCase() === "cmd.exe") { + expect(command.args).toEqual(["/c", "opencode", "serve", "--port", "4987"]); + } else { + expect(path.isAbsolute(command.command)).toBe(true); + expect(command.command.toLowerCase().endsWith("\\opencode.exe")).toBe(true); + expect(command.args).toEqual(["serve", "--port", "4987"]); + } return; } @@ -43,4 +54,55 @@ describe("opencode/process", () => { windowsHide: false, }); }); + + it("falls back to cmd.exe on Windows when opencode.exe cannot be resolved", () => { + if (process.platform !== "win32") { + return; + } + + const originalPath = process.env.PATH; + + try { + process.env.PATH = ""; + + const command = createOpencodeServeSpawnCommand({ host: "localhost", port: 4987 }); + expect(command).toEqual({ + command: "cmd.exe", + args: ["/c", "opencode", "serve", "--port", "4987"], + windowsHide: true, + }); + } finally { + process.env.PATH = originalPath; + } + }); + + it("uses resolved opencode.exe on Windows when opencode.cmd is on PATH and exe exists", () => { + if (process.platform !== "win32") { + return; + } + + const originalPath = process.env.PATH; + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-telegram-bot-")); + const binDir = path.join(tempRoot, "bin"); + const exePath = path.join(binDir, "node_modules", "opencode-ai", "bin", "opencode.exe"); + const cmdPath = path.join(binDir, "opencode.cmd"); + + try { + fs.mkdirSync(path.dirname(exePath), { recursive: true }); + fs.writeFileSync(exePath, "", "utf8"); + fs.writeFileSync(cmdPath, "@echo off\r\nexit /b 0\r\n", "utf8"); + + process.env.PATH = [binDir, originalPath].filter(Boolean).join(path.delimiter); + + const command = createOpencodeServeSpawnCommand({ host: "localhost", port: 4987 }); + expect(command).toEqual({ + command: exePath, + args: ["serve", "--port", "4987"], + windowsHide: true, + }); + } finally { + process.env.PATH = originalPath; + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); });