diff --git a/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx index 110585e1..5a66daba 100644 --- a/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx @@ -71,7 +71,15 @@ export const OpenCodeCliSettings: React.FC = () => { const handleSaveAndReload = React.useCallback(async () => { setIsSaving(true); 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({ message: t('settings.openchamber.opencodeCli.actions.restartingOpenCode'), mode: 'projects', diff --git a/packages/vscode/src/opencode.ts b/packages/vscode/src/opencode.ts index c7e5cd85..c1f60f73 100644 --- a/packages/vscode/src/opencode.ts +++ b/packages/vscode/src/opencode.ts @@ -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((resolve, reject) => { diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index 7e4cf9d6..154608d7 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -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) diff --git a/packages/web/server/lib/opencode/settings-normalization-runtime.js b/packages/web/server/lib/opencode/settings-normalization-runtime.js index f237e559..a5343633 100644 --- a/packages/web/server/lib/opencode/settings-normalization-runtime.js +++ b/packages/web/server/lib/opencode/settings-normalization-runtime.js @@ -17,7 +17,15 @@ export const createSettingsNormalizationRuntime = (dependencies) => { 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) { return trimmed; }