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:
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user