fix(opencode): robust Windows CLI discovery and launch across surfaces

Launching: the VS Code extension spawned .cmd shims with shell:true,
which builds an unquoted command line — a path like
"C:\Program Files\nodejs\opencode.cmd" broke with "'C:\Program' is not
recognized". It now spawns cmd.exe directly with the shim path as its
own argv element (the web server's existing cmd-wrapper pattern), and the
web server's final launch-spec fallback routes raw .cmd/.bat through the
same wrapper instead of handing them to spawn.

Configured paths: wrapping quote pairs (Windows "Copy as path" pastes)
are now stripped everywhere a binary path enters — the VS Code setting
and shared settings.json, env vars on both surfaces, the server's
directory-path normalization, and the desktop settings input.

Discovery: added the system-wide npm prefix (Program Files\nodejs) and
scoop's .exe shim to the Windows candidates, Linuxbrew on Linux, and the
where-probe now runs with windowsHide. The macOS desktop-app exclusion
also covers the OpenCode Dev/Beta app bundles.
This commit is contained in:
Bohdan Triapitsyn
2026-07-05 10:39:09 +03:00
parent 7ec45785d9
commit 997116c736
4 changed files with 93 additions and 17 deletions
+43 -13
View File
@@ -123,14 +123,40 @@ function isExecutable(filePath: string): boolean {
}
}
function shouldUseWindowsShell(binary: string): boolean {
if (process.platform !== 'win32') return false;
// Windows launch spec: .cmd/.bat shims (and bare names, which resolve to .cmd
// shims via PATHEXT) must run under cmd.exe. Spawn cmd.exe DIRECTLY with the
// shim path as its own argv element (shell:false) — `shell: true` builds an
// unquoted command line, so a space-containing path like
// "C:\Program Files\nodejs\opencode.cmd" broke with
// "'C:\Program' is not recognized as an internal or external command".
function resolveWindowsLaunchSpec(binary: string, args: string[]): { binary: string; args: string[] } {
if (process.platform !== 'win32') {
return { binary, args };
}
const trimmed = (binary || '').trim();
if (!trimmed) return true;
const ext = path.extname(trimmed).toLowerCase();
if (ext === '.cmd' || ext === '.bat') return true;
// Bare command names often resolve to .cmd shims via PATHEXT.
return !ext && !trimmed.includes('\\') && !trimmed.includes('/');
const isBatchShim = ext === '.cmd' || ext === '.bat';
const isBareName = !ext && !trimmed.includes('\\') && !trimmed.includes('/');
if (!isBatchShim && !isBareName) {
return { binary: trimmed, args };
}
return {
binary: process.env.ComSpec || 'cmd.exe',
args: ['/d', '/s', '/c', 'call', trimmed, ...args],
};
}
// Strip a single wrapping quote pair (Windows "Copy as path" and quoted shell
// snippets) — literal quotes are never part of a real path and break every
// executable check.
function stripWrappingQuotes(value: string): string {
const trimmed = (value || '').trim();
if (trimmed.length >= 2
&& ((trimmed.startsWith('"') && trimmed.endsWith('"'))
|| (trimmed.startsWith("'") && trimmed.endsWith("'")))) {
return trimmed.slice(1, -1).trim();
}
return trimmed;
}
function appendToPath(dir: string) {
@@ -177,7 +203,7 @@ function normalizeConfiguredOpencodeBinary(raw: unknown): string | null {
if (typeof raw !== 'string') {
return null;
}
const trimmed = raw.trim();
const trimmed = stripWrappingQuotes(raw);
if (!trimmed) {
return null;
}
@@ -193,7 +219,7 @@ function normalizeConfiguredOpencodeBinary(raw: unknown): string | null {
}
function isMacOpenCodeAppBundlePath(candidate: string): boolean {
return process.platform === 'darwin' && /\/OpenCode\.app\/Contents\/MacOS\/(?:OpenCode|opencode-cli)$/i.test(candidate);
return process.platform === 'darwin' && /\/OpenCode(?: Dev| Beta)?\.app\/Contents\/MacOS\/(?:OpenCode(?: Dev| Beta)?|opencode-cli)$/i.test(candidate);
}
function isWindowsOpenCodeDesktopAppPath(candidate: string): boolean {
@@ -318,7 +344,7 @@ function resolveOpencodeCliPath(): string | null {
process.env.OPENCHAMBER_OPENCODE_PATH,
process.env.OPENCHAMBER_OPENCODE_BIN,
]
.map((v) => (typeof v === 'string' ? v.trim() : ''))
.map((v) => (typeof v === 'string' ? stripWrappingQuotes(v) : ''))
.filter(Boolean);
for (const candidate of explicit) {
@@ -357,6 +383,10 @@ function resolveOpencodeCliPath(): string | null {
path.join(npmDir, 'opencode.exe'),
path.join(npmDir, 'opencode.cmd'),
path.join(npmDir, 'opencode.bat'),
// System-wide Node installer keeps the global npm prefix here
// (npm i -g opencode-ai → opencode.cmd shim).
path.join(process.env.ProgramFiles || 'C:\\Program Files', 'nodejs', 'opencode.cmd'),
path.join(userProfile, 'scoop', 'shims', 'opencode.exe'),
path.join(userProfile, 'scoop', 'shims', 'opencode.cmd'),
path.join(programData, 'chocolatey', 'bin', 'opencode.exe'),
path.join(programData, 'chocolatey', 'bin', 'opencode.cmd'),
@@ -393,6 +423,7 @@ function resolveOpencodeCliPath(): string | null {
const result = spawnSync('where', ['opencode'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
if (result.status === 0) {
const lines = (result.stdout || '')
@@ -632,14 +663,13 @@ async function spawnManagedOpenCodeServer(
port: number,
timeoutMs: number
): Promise<{ url: string; close: () => void }> {
const binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode';
const args = ['serve', '--hostname', '127.0.0.1', '--port', String(port)];
const child = spawn(binary, args, {
const binary = stripWrappingQuotes(process.env.OPENCODE_BINARY || 'opencode') || 'opencode';
const launch = resolveWindowsLaunchSpec(binary, ['serve', '--hostname', '127.0.0.1', '--port', String(port)]);
const child = spawn(launch.binary, launch.args, {
cwd: workingDirectory,
env: { ...process.env },
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
shell: shouldUseWindowsShell(binary),
});
const url = await new Promise<string>((resolve, reject) => {