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
@@ -317,6 +317,19 @@ export const createOpenCodeEnvRuntime = (deps) => {
state.resolvedWslDistro = null;
};
// 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.
const stripWrappingQuotes = (value) => {
const trimmed = typeof value === 'string' ? value.trim() : '';
if (trimmed.length >= 2
&& ((trimmed.startsWith('"') && trimmed.endsWith('"'))
|| (trimmed.startsWith("'") && trimmed.endsWith("'")))) {
return trimmed.slice(1, -1).trim();
}
return trimmed;
};
const resolveOpencodeCliPath = () => {
const explicit = [
process.env.OPENCODE_BINARY,
@@ -324,7 +337,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
process.env.OPENCHAMBER_OPENCODE_PATH,
process.env.OPENCHAMBER_OPENCODE_BIN,
]
.map((v) => (typeof v === 'string' ? v.trim() : ''))
.map(stripWrappingQuotes)
.filter(Boolean);
for (const candidate of explicit) {
@@ -353,6 +366,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
path.join(home, 'bin', 'opencode'),
'/opt/homebrew/bin/opencode',
'/usr/local/bin/opencode',
'/home/linuxbrew/.linuxbrew/bin/opencode',
'/usr/bin/opencode',
'/bin/opencode',
];
@@ -363,10 +377,16 @@ export const createOpenCodeEnvRuntime = (deps) => {
const localAppData = process.env.LOCALAPPDATA || '';
const programData = process.env.ProgramData || 'C:\\ProgramData';
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
return [
path.join(userProfile, '.opencode', 'bin', 'opencode.exe'),
path.join(userProfile, '.opencode', 'bin', 'opencode.cmd'),
path.join(appData, 'npm', 'opencode.cmd'),
// System-wide Node installer keeps the global npm prefix here
// (npm i -g opencode-ai → opencode.cmd shim).
path.join(programFiles, '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'),
@@ -818,6 +838,16 @@ export const createOpenCodeEnvRuntime = (deps) => {
}
}
// Final fallback: never hand a raw .cmd/.bat to spawn(shell:false) — cmd
// shims need cmd.exe, and unquoted space-containing paths break there.
if (WINDOWS_BATCH_EXTENSIONS.has(ext)) {
return {
binary: process.env.ComSpec || 'cmd.exe',
args: ['/d', '/s', '/c', 'call', fallbackBinary],
wrapperType: 'cmd-wrapper',
};
}
return { binary: fallbackBinary, args: [], wrapperType: null };
};
@@ -873,7 +903,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
if (process.platform !== 'darwin' || typeof candidate !== 'string') {
return false;
}
return /\/OpenCode\.app\/Contents\/MacOS\/(?:OpenCode|opencode-cli)$/i.test(candidate);
return /\/OpenCode(?: Dev| Beta)?\.app\/Contents\/MacOS\/(?:OpenCode(?: Dev| Beta)?|opencode-cli)$/i.test(candidate);
};
const isKnownOpenCodeDesktopAppPath = (candidate) => isMacOpenCodeAppBundlePath(candidate)