From 26aea8b69424346af8ec385bcb492b6046254a4b Mon Sep 17 00:00:00 2001 From: danieliyahu1 Date: Thu, 18 Jun 2026 15:25:29 +0300 Subject: [PATCH 1/2] fix(process): spawn opencode.exe directly on Windows to prevent visible console window --- src/opencode/process.ts | 31 ++++++++++++++++++++++++++++--- tests/opencode/process.test.ts | 8 +++----- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/opencode/process.ts b/src/opencode/process.ts index 4681d8ee..1f192e7a 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,39 @@ export function resolveLocalOpencodeTarget(apiUrl: string): LocalOpencodeTarget } } +function resolveWindowsOpencodeExe(): string { + const npmPrefix = path.dirname(process.execPath); + const candidate = path.join( + npmPrefix, + "node_modules", + "opencode-ai", + "bin", + "opencode.exe", + ); + if (existsSync(candidate)) { + return candidate; + } + return "opencode.exe"; +} + export function createOpencodeServeSpawnCommand( target: LocalOpencodeTarget, ): OpencodeServeSpawnCommand { const isWindows = process.platform === "win32"; const port = target.port.toString(); + if (isWindows) { + return { + command: resolveWindowsOpencodeExe(), + args: ["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..81a393bd 100644 --- a/tests/opencode/process.test.ts +++ b/tests/opencode/process.test.ts @@ -29,11 +29,9 @@ 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); + expect(command.command).not.toBe("cmd.exe"); + expect(command.args).toEqual(["serve", "--port", "4987"]); return; } From b69a6c3890391eb2c95df7df240218ff2aac3022 Mon Sep 17 00:00:00 2001 From: danieliyahu1 Date: Thu, 2 Jul 2026 11:46:11 +0300 Subject: [PATCH 2/2] fix(process): resolve opencode.exe from opencode.cmd on Windows - Locate opencode.cmd on PATH and derive real opencode.exe path - Fall back to cmd.exe /c opencode when exe cannot be resolved - Add tests to catch ENOENT-style resolution bugs --- src/opencode/process.ts | 46 ++++++++++++++++------- tests/opencode/process.test.ts | 68 +++++++++++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 15 deletions(-) diff --git a/src/opencode/process.ts b/src/opencode/process.ts index 1f192e7a..40a41d2e 100644 --- a/src/opencode/process.ts +++ b/src/opencode/process.ts @@ -46,18 +46,27 @@ export function resolveLocalOpencodeTarget(apiUrl: string): LocalOpencodeTarget } function resolveWindowsOpencodeExe(): string { - const npmPrefix = path.dirname(process.execPath); - const candidate = path.join( - npmPrefix, - "node_modules", - "opencode-ai", - "bin", - "opencode.exe", - ); - if (existsSync(candidate)) { - return candidate; + // 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 "opencode.exe"; + + return ""; } export function createOpencodeServeSpawnCommand( @@ -67,9 +76,20 @@ export function createOpencodeServeSpawnCommand( 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: resolveWindowsOpencodeExe(), - args: ["serve", "--port", port], + command: "cmd.exe", + args: ["/c", "opencode", "serve", "--port", port], windowsHide: true, }; } diff --git a/tests/opencode/process.test.ts b/tests/opencode/process.test.ts index 81a393bd..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, @@ -30,8 +34,17 @@ describe("opencode/process", () => { if (process.platform === "win32") { expect(command.windowsHide).toBe(true); - expect(command.command).not.toBe("cmd.exe"); - expect(command.args).toEqual(["serve", "--port", "4987"]); + + // 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; } @@ -41,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 }); + } + }); });