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
@@ -71,7 +71,15 @@ export const OpenCodeCliSettings: React.FC = () => {
const handleSaveAndReload = React.useCallback(async () => { const handleSaveAndReload = React.useCallback(async () => {
setIsSaving(true); setIsSaving(true);
try { try {
await updateDesktopSettings({ opencodeBinary: value.trim() }); // Strip a wrapping quote pair (Windows "Copy as path" pastes) — literal
// quotes are never part of a real path.
const trimmed = value.trim();
const unquoted = trimmed.length >= 2
&& ((trimmed.startsWith('"') && trimmed.endsWith('"'))
|| (trimmed.startsWith("'") && trimmed.endsWith("'")))
? trimmed.slice(1, -1).trim()
: trimmed;
await updateDesktopSettings({ opencodeBinary: unquoted });
await reloadOpenCodeConfiguration({ await reloadOpenCodeConfiguration({
message: t('settings.openchamber.opencodeCli.actions.restartingOpenCode'), message: t('settings.openchamber.opencodeCli.actions.restartingOpenCode'),
mode: 'projects', mode: 'projects',
+43 -13
View File
@@ -123,14 +123,40 @@ function isExecutable(filePath: string): boolean {
} }
} }
function shouldUseWindowsShell(binary: string): boolean { // Windows launch spec: .cmd/.bat shims (and bare names, which resolve to .cmd
if (process.platform !== 'win32') return false; // 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(); const trimmed = (binary || '').trim();
if (!trimmed) return true;
const ext = path.extname(trimmed).toLowerCase(); const ext = path.extname(trimmed).toLowerCase();
if (ext === '.cmd' || ext === '.bat') return true; const isBatchShim = ext === '.cmd' || ext === '.bat';
// Bare command names often resolve to .cmd shims via PATHEXT. const isBareName = !ext && !trimmed.includes('\\') && !trimmed.includes('/');
return !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) { function appendToPath(dir: string) {
@@ -177,7 +203,7 @@ function normalizeConfiguredOpencodeBinary(raw: unknown): string | null {
if (typeof raw !== 'string') { if (typeof raw !== 'string') {
return null; return null;
} }
const trimmed = raw.trim(); const trimmed = stripWrappingQuotes(raw);
if (!trimmed) { if (!trimmed) {
return null; return null;
} }
@@ -193,7 +219,7 @@ function normalizeConfiguredOpencodeBinary(raw: unknown): string | null {
} }
function isMacOpenCodeAppBundlePath(candidate: string): boolean { 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 { function isWindowsOpenCodeDesktopAppPath(candidate: string): boolean {
@@ -318,7 +344,7 @@ function resolveOpencodeCliPath(): string | null {
process.env.OPENCHAMBER_OPENCODE_PATH, process.env.OPENCHAMBER_OPENCODE_PATH,
process.env.OPENCHAMBER_OPENCODE_BIN, process.env.OPENCHAMBER_OPENCODE_BIN,
] ]
.map((v) => (typeof v === 'string' ? v.trim() : '')) .map((v) => (typeof v === 'string' ? stripWrappingQuotes(v) : ''))
.filter(Boolean); .filter(Boolean);
for (const candidate of explicit) { for (const candidate of explicit) {
@@ -357,6 +383,10 @@ function resolveOpencodeCliPath(): string | null {
path.join(npmDir, 'opencode.exe'), path.join(npmDir, 'opencode.exe'),
path.join(npmDir, 'opencode.cmd'), path.join(npmDir, 'opencode.cmd'),
path.join(npmDir, 'opencode.bat'), 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(userProfile, 'scoop', 'shims', 'opencode.cmd'),
path.join(programData, 'chocolatey', 'bin', 'opencode.exe'), path.join(programData, 'chocolatey', 'bin', 'opencode.exe'),
path.join(programData, 'chocolatey', 'bin', 'opencode.cmd'), path.join(programData, 'chocolatey', 'bin', 'opencode.cmd'),
@@ -393,6 +423,7 @@ function resolveOpencodeCliPath(): string | null {
const result = spawnSync('where', ['opencode'], { const result = spawnSync('where', ['opencode'], {
encoding: 'utf8', encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
}); });
if (result.status === 0) { if (result.status === 0) {
const lines = (result.stdout || '') const lines = (result.stdout || '')
@@ -632,14 +663,13 @@ async function spawnManagedOpenCodeServer(
port: number, port: number,
timeoutMs: number timeoutMs: number
): Promise<{ url: string; close: () => void }> { ): Promise<{ url: string; close: () => void }> {
const binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode'; const binary = stripWrappingQuotes(process.env.OPENCODE_BINARY || 'opencode') || 'opencode';
const args = ['serve', '--hostname', '127.0.0.1', '--port', String(port)]; const launch = resolveWindowsLaunchSpec(binary, ['serve', '--hostname', '127.0.0.1', '--port', String(port)]);
const child = spawn(binary, args, { const child = spawn(launch.binary, launch.args, {
cwd: workingDirectory, cwd: workingDirectory,
env: { ...process.env }, env: { ...process.env },
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true, windowsHide: true,
shell: shouldUseWindowsShell(binary),
}); });
const url = await new Promise<string>((resolve, reject) => { const url = await new Promise<string>((resolve, reject) => {
@@ -317,6 +317,19 @@ export const createOpenCodeEnvRuntime = (deps) => {
state.resolvedWslDistro = null; 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 resolveOpencodeCliPath = () => {
const explicit = [ const explicit = [
process.env.OPENCODE_BINARY, process.env.OPENCODE_BINARY,
@@ -324,7 +337,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
process.env.OPENCHAMBER_OPENCODE_PATH, process.env.OPENCHAMBER_OPENCODE_PATH,
process.env.OPENCHAMBER_OPENCODE_BIN, process.env.OPENCHAMBER_OPENCODE_BIN,
] ]
.map((v) => (typeof v === 'string' ? v.trim() : '')) .map(stripWrappingQuotes)
.filter(Boolean); .filter(Boolean);
for (const candidate of explicit) { for (const candidate of explicit) {
@@ -353,6 +366,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
path.join(home, 'bin', 'opencode'), path.join(home, 'bin', 'opencode'),
'/opt/homebrew/bin/opencode', '/opt/homebrew/bin/opencode',
'/usr/local/bin/opencode', '/usr/local/bin/opencode',
'/home/linuxbrew/.linuxbrew/bin/opencode',
'/usr/bin/opencode', '/usr/bin/opencode',
'/bin/opencode', '/bin/opencode',
]; ];
@@ -363,10 +377,16 @@ export const createOpenCodeEnvRuntime = (deps) => {
const localAppData = process.env.LOCALAPPDATA || ''; const localAppData = process.env.LOCALAPPDATA || '';
const programData = process.env.ProgramData || 'C:\\ProgramData'; const programData = process.env.ProgramData || 'C:\\ProgramData';
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
return [ return [
path.join(userProfile, '.opencode', 'bin', 'opencode.exe'), path.join(userProfile, '.opencode', 'bin', 'opencode.exe'),
path.join(userProfile, '.opencode', 'bin', 'opencode.cmd'), path.join(userProfile, '.opencode', 'bin', 'opencode.cmd'),
path.join(appData, 'npm', '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(userProfile, 'scoop', 'shims', 'opencode.cmd'),
path.join(programData, 'chocolatey', 'bin', 'opencode.exe'), path.join(programData, 'chocolatey', 'bin', 'opencode.exe'),
path.join(programData, 'chocolatey', 'bin', 'opencode.cmd'), 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 }; return { binary: fallbackBinary, args: [], wrapperType: null };
}; };
@@ -873,7 +903,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
if (process.platform !== 'darwin' || typeof candidate !== 'string') { if (process.platform !== 'darwin' || typeof candidate !== 'string') {
return false; 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) const isKnownOpenCodeDesktopAppPath = (candidate) => isMacOpenCodeAppBundlePath(candidate)
@@ -17,7 +17,15 @@ export const createSettingsNormalizationRuntime = (dependencies) => {
return value; return value;
} }
const trimmed = value.trim(); let trimmed = value.trim();
// Paths pasted from Windows "Copy as path" (or quoted shell snippets)
// arrive wrapped in quotes — a literal quote character can never be part
// of a real path, and it breaks every fs.stat/executable check.
if (trimmed.length >= 2
&& ((trimmed.startsWith('"') && trimmed.endsWith('"'))
|| (trimmed.startsWith("'") && trimmed.endsWith("'")))) {
trimmed = trimmed.slice(1, -1).trim();
}
if (!trimmed) { if (!trimmed) {
return trimmed; return trimmed;
} }