Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 48 additions & 3 deletions src/opencode/process.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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,
};
}

Expand Down
72 changes: 67 additions & 5 deletions tests/opencode/process.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
}

Expand All @@ -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 });
}
});
});
Loading