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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user