import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { clearAppImageArgv0FromProcessEnv } from '../inherited-env.js'; import { mergePathValues } from './path-utils.js'; // Login-shell probes source the user's rc files. A slow or interactive rc // (nvm, pyenv, a prompt waiting for input) must not hold server startup // hostage: a probe that overruns is abandoned and resolution falls through // to the next candidate. Electron's own login-shell probe uses the same bound. const SHELL_PROBE_TIMEOUT_MS = 5_000; export const createOpenCodeEnvRuntime = (deps) => { const { state, normalizeDirectoryPath, readSettingsFromDiskMigrated, } = deps; const runSpawnSync = typeof deps.spawnSync === 'function' ? deps.spawnSync : spawnSync; const resolveHomeDir = typeof deps.homedir === 'function' ? deps.homedir : () => os.homedir(); const parseNullSeparatedEnvSnapshot = (raw) => { if (typeof raw !== 'string' || raw.length === 0) { return null; } const result = {}; const entries = raw.split('\0'); for (const entry of entries) { if (!entry) { continue; } const idx = entry.indexOf('='); if (idx <= 0) { continue; } const key = entry.slice(0, idx); const value = entry.slice(idx + 1); result[key] = value; } if (Object.keys(result).length === 0) { return null; } if (process.platform === 'win32' && typeof result.PATH !== 'string') { const pathEntry = Object.entries(result).find(([key]) => key.toLowerCase() === 'path'); if (pathEntry && typeof pathEntry[1] === 'string') { result.PATH = pathEntry[1]; } } return result; }; const isExecutable = (filePath) => { try { const stat = fs.statSync(filePath); if (!stat.isFile()) return false; if (process.platform === 'win32') { const ext = path.extname(filePath).toLowerCase(); if (!ext) return true; return ['.exe', '.cmd', '.bat', '.com'].includes(ext); } fs.accessSync(filePath, fs.constants.X_OK); return true; } catch { return false; } }; const resolveWindowsExecutablePath = (candidate) => { if (process.platform !== 'win32' || typeof candidate !== 'string' || candidate.trim().length === 0) { return candidate; } const trimmed = candidate.trim(); const ext = path.extname(trimmed).toLowerCase(); if (ext) { return isExecutable(trimmed) ? trimmed : null; } const pathExt = process.env.PATHEXT || process.env.PathExt || '.COM;.EXE;.BAT;.CMD'; for (const rawExt of pathExt.split(';')) { const normalizedExt = rawExt.trim(); if (!normalizedExt) continue; const withExt = `${trimmed}${normalizedExt.startsWith('.') ? normalizedExt : `.${normalizedExt}`}`; if (isExecutable(withExt)) { return withExt; } } return isExecutable(trimmed) ? trimmed : null; }; const searchPathFor = (binaryName, searchPath = process.env.PATH || '') => { const trimmed = typeof binaryName === 'string' ? binaryName.trim() : ''; if (!trimmed) { return null; } const parts = searchPath.split(path.delimiter).filter(Boolean); const candidateNames = []; if (process.platform === 'win32' && !path.extname(trimmed)) { const pathExt = process.env.PATHEXT || process.env.PathExt || '.COM;.EXE;.BAT;.CMD'; for (const ext of pathExt.split(';')) { const normalizedExt = ext.trim(); if (!normalizedExt) continue; const candidateName = `${trimmed}${normalizedExt.startsWith('.') ? normalizedExt : `.${normalizedExt}`}`; if (!candidateNames.some((existing) => existing.toLowerCase() === candidateName.toLowerCase())) { candidateNames.push(candidateName); } } } candidateNames.push(trimmed); for (const dir of parts) { for (const candidateName of candidateNames) { const candidate = path.join(dir, candidateName); if (isExecutable(candidate)) { return candidate; } } } return null; }; const prependToPath = (dir) => { const trimmed = typeof dir === 'string' ? dir.trim() : ''; if (!trimmed) return; const current = process.env.PATH || ''; const parts = current.split(path.delimiter).filter(Boolean); if (parts.includes(trimmed)) return; process.env.PATH = [trimmed, ...parts].join(path.delimiter); }; const getWindowsShellEnvSnapshot = () => { const parseResult = (stdout) => parseNullSeparatedEnvSnapshot(typeof stdout === 'string' ? stdout : ''); const psScript = [ '$entries = [ordered]@{}', 'Get-ChildItem Env: | ForEach-Object { $entries[$_.Name] = $_.Value }', "$pathValues = @([Environment]::GetEnvironmentVariable('Path', 'Machine'), [Environment]::GetEnvironmentVariable('Path', 'User'), [Environment]::GetEnvironmentVariable('Path', 'Process')) | Where-Object { $_ }", "if ($pathValues.Count -gt 0) { $entries['Path'] = ($pathValues -join ';') }", "$entries.GetEnumerator() | ForEach-Object { [Console]::Out.Write($_.Name); [Console]::Out.Write('='); [Console]::Out.Write($_.Value); [Console]::Out.Write([char]0) }", ].join('; '); const powershellCandidates = [ 'pwsh.exe', 'powershell.exe', path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), ]; for (const shellPath of powershellCandidates) { try { const result = runSpawnSync(shellPath, ['-NoLogo', '-Command', psScript], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 10 * 1024 * 1024, windowsHide: true, }); if (result.status !== 0) { continue; } const parsed = parseResult(result.stdout); if (parsed) { return parsed; } } catch { } } const comspec = process.env.ComSpec || 'cmd.exe'; try { const result = runSpawnSync(comspec, ['/d', '/s', '/c', 'set'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 10 * 1024 * 1024, windowsHide: true, }); if (result.status === 0 && typeof result.stdout === 'string' && result.stdout.length > 0) { return parseNullSeparatedEnvSnapshot(result.stdout.replace(/\r?\n/g, '\0')); } } catch { } return null; }; const getLoginShellEnvSnapshot = () => { if (state.cachedLoginShellEnvSnapshot !== undefined) { return state.cachedLoginShellEnvSnapshot; } if (process.platform === 'win32') { const windowsSnapshot = getWindowsShellEnvSnapshot(); state.cachedLoginShellEnvSnapshot = windowsSnapshot; return windowsSnapshot; } const shellCandidates = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); for (const shellPath of shellCandidates) { if (!isExecutable(shellPath)) { continue; } try { const result = runSpawnSync(shellPath, ['-lic', 'env -0'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 10 * 1024 * 1024, windowsHide: true, timeout: SHELL_PROBE_TIMEOUT_MS, }); if (result.status !== 0) { continue; } const parsed = parseNullSeparatedEnvSnapshot(result.stdout || ''); if (parsed) { state.cachedLoginShellEnvSnapshot = parsed; return parsed; } } catch { } } state.cachedLoginShellEnvSnapshot = null; return null; }; const applyLoginShellEnvSnapshot = () => { // Always clear AppImage ARGV0, even when no login-shell snapshot is available. // Otherwise a leaked process.env.ARGV0 survives into later child spawns (#2588). clearAppImageArgv0FromProcessEnv(); const snapshot = getLoginShellEnvSnapshot(); if (!snapshot) { return; } const skipKeys = new Set(['PWD', 'OLDPWD', 'SHLVL', '_', 'ARGV0']); for (const [key, value] of Object.entries(snapshot)) { if (skipKeys.has(key)) { continue; } const existing = process.env[key]; if (typeof existing === 'string' && existing.length > 0) { continue; } process.env[key] = value; } const currentPath = process.env.PATH || ''; const shellPath = snapshot.PATH || ''; if (!shellPath) { return; } process.env.PATH = mergePathValues(shellPath, currentPath, path.delimiter); }; const isWslExecutableValue = (value) => { if (typeof value !== 'string') return false; const trimmed = value.trim(); if (!trimmed) return false; return /(^|[\\/])wsl(\.exe)?$/i.test(trimmed); }; const isWindowsOpenCodeDesktopAppPath = (candidate) => { if (process.platform !== 'win32' || typeof candidate !== 'string') { return false; } const normalized = path.resolve(candidate).toLowerCase(); const localAppData = typeof process.env.LOCALAPPDATA === 'string' && process.env.LOCALAPPDATA.trim() ? path.resolve(process.env.LOCALAPPDATA).toLowerCase() : ''; if (!localAppData || !normalized.startsWith(`${localAppData}${path.sep}`)) { return false; } return normalized.endsWith(`${path.sep}programs${path.sep}opencode${path.sep}opencode.exe`); }; const bundledOpenCodeCliCandidates = () => { const names = process.platform === 'win32' ? ['opencode.exe'] : ['opencode']; const roots = [ process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR, typeof process.resourcesPath === 'string' ? path.join(process.resourcesPath, 'opencode-cli') : null, ] .map((value) => (typeof value === 'string' ? value.trim() : '')) .filter(Boolean); const candidates = []; for (const root of roots) { for (const name of names) { candidates.push(path.join(root, name)); } } return candidates; }; const resolveBundledOpenCodeCliPath = () => { for (const candidate of bundledOpenCodeCliCandidates()) { if (isExecutable(candidate) && !isWindowsOpenCodeDesktopAppPath(candidate)) { return candidate; } } return null; }; const canonicalExecutablePath = (candidate) => { if (typeof candidate !== 'string' || !candidate.trim()) return null; try { return fs.realpathSync.native(candidate.trim()); } catch { return path.resolve(candidate.trim()); } }; const isBundledOpenCodeCliPath = (candidate) => { const canonicalCandidate = canonicalExecutablePath(candidate); if (!canonicalCandidate) return false; return bundledOpenCodeCliCandidates().some((bundledCandidate) => ( canonicalExecutablePath(bundledCandidate) === canonicalCandidate )); }; const bundledOpenCodeCliFallback = () => { const bundled = resolveBundledOpenCodeCliPath(); if (!bundled) return null; clearWslOpencodeResolution(); state.resolvedOpencodeBinarySource = 'bundled'; return bundled; }; const clearWslOpencodeResolution = () => { state.useWslForOpencode = false; state.resolvedWslBinary = null; state.resolvedWslOpencodePath = 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 explicit = [ process.env.OPENCODE_BINARY, process.env.OPENCODE_PATH, process.env.OPENCHAMBER_OPENCODE_PATH, process.env.OPENCHAMBER_OPENCODE_BIN, ] .map(stripWrappingQuotes) .filter(Boolean); for (const candidate of explicit) { if (isExecutable(candidate) && !isWindowsOpenCodeDesktopAppPath(candidate)) { clearWslOpencodeResolution(); state.resolvedOpencodeBinarySource = 'env'; return candidate; } } const bundled = bundledOpenCodeCliFallback(); if (bundled) return bundled; const resolvedFromPath = searchPathFor('opencode'); if (resolvedFromPath) { clearWslOpencodeResolution(); state.resolvedOpencodeBinarySource = 'path'; return resolvedFromPath; } const home = resolveHomeDir(); const unixFallbacks = [ path.join(home, '.opencode', 'bin', 'opencode'), path.join(home, '.bun', 'bin', 'opencode'), path.join(home, '.local', 'bin', 'opencode'), path.join(home, 'bin', 'opencode'), '/opt/homebrew/bin/opencode', '/usr/local/bin/opencode', '/home/linuxbrew/.linuxbrew/bin/opencode', '/usr/bin/opencode', '/bin/opencode', ]; const winFallbacks = (() => { const userProfile = process.env.USERPROFILE || home; const appData = process.env.APPDATA || ''; 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'), path.join(userProfile, '.bun', 'bin', 'opencode.exe'), path.join(userProfile, '.bun', 'bin', 'opencode.cmd'), ].filter(Boolean); })(); const fallbacks = process.platform === 'win32' ? winFallbacks : unixFallbacks; for (const candidate of fallbacks) { if (isExecutable(candidate)) { clearWslOpencodeResolution(); state.resolvedOpencodeBinarySource = 'fallback'; return candidate; } } if (process.platform === 'win32') { try { const result = runSpawnSync('where', ['opencode'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); if (result.status === 0) { const lines = (result.stdout || '') .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); const found = lines.find((line) => isExecutable(line) && !isWindowsOpenCodeDesktopAppPath(line)); if (found) { clearWslOpencodeResolution(); state.resolvedOpencodeBinarySource = 'where'; return found; } } } catch { } // Do not auto-detect OpenCode from WSL. OpenCode sessions are keyed by // server-visible directories, and mixing Windows paths with WSL paths // creates duplicate/missing project state in the desktop app. return null; } const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); for (const shell of shells) { if (!isExecutable(shell)) continue; try { const result = runSpawnSync(shell, ['-lic', 'command -v opencode'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, timeout: SHELL_PROBE_TIMEOUT_MS, }); if (result.status === 0) { const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; if (found && isExecutable(found)) { clearWslOpencodeResolution(); state.resolvedOpencodeBinarySource = 'shell'; return found; } } } catch { } } return null; }; const resolveNodeCliPath = () => { const explicit = [process.env.NODE_BINARY, process.env.OPENCHAMBER_NODE_BINARY] .map((v) => (typeof v === 'string' ? v.trim() : '')) .filter(Boolean); for (const candidate of explicit) { if (isExecutable(candidate)) { return candidate; } } const resolvedFromPath = searchPathFor('node'); if (resolvedFromPath) { return resolvedFromPath; } const unixFallbacks = ['/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node', '/bin/node']; for (const candidate of unixFallbacks) { if (isExecutable(candidate)) { return candidate; } } if (process.platform === 'win32') { try { const result = runSpawnSync('where', ['node'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); if (result.status === 0) { const lines = (result.stdout || '') .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); const found = lines.find((line) => isExecutable(line)); if (found) return found; } } catch { } return null; } const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); for (const shell of shells) { if (!isExecutable(shell)) continue; try { const result = runSpawnSync(shell, ['-lic', 'command -v node'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, timeout: SHELL_PROBE_TIMEOUT_MS, }); if (result.status === 0) { const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; if (found && isExecutable(found)) { return found; } } } catch { } } return null; }; const resolveBunCliPath = () => { const explicit = [process.env.BUN_BINARY, process.env.OPENCHAMBER_BUN_BINARY] .map((v) => (typeof v === 'string' ? v.trim() : '')) .filter(Boolean); for (const candidate of explicit) { if (isExecutable(candidate)) { return candidate; } } const resolvedFromPath = searchPathFor('bun'); if (resolvedFromPath) { return resolvedFromPath; } const home = os.homedir(); const unixFallbacks = [ path.join(home, '.bun', 'bin', 'bun'), '/opt/homebrew/bin/bun', '/usr/local/bin/bun', '/usr/bin/bun', '/bin/bun', ]; for (const candidate of unixFallbacks) { if (isExecutable(candidate)) { return candidate; } } if (process.platform === 'win32') { const userProfile = process.env.USERPROFILE || home; const winFallbacks = [ path.join(userProfile, '.bun', 'bin', 'bun.exe'), path.join(userProfile, '.bun', 'bin', 'bun.cmd'), ]; for (const candidate of winFallbacks) { if (isExecutable(candidate)) return candidate; } try { const result = runSpawnSync('where', ['bun'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); if (result.status === 0) { const lines = (result.stdout || '') .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); const found = lines.find((line) => isExecutable(line)); if (found) return found; } } catch { } return null; } const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); for (const shell of shells) { if (!isExecutable(shell)) continue; try { const result = runSpawnSync(shell, ['-lic', 'command -v bun'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, timeout: SHELL_PROBE_TIMEOUT_MS, }); if (result.status === 0) { const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; if (found && isExecutable(found)) { return found; } } } catch { } } return null; }; const ensureBunCliEnv = () => { if (state.resolvedBunBinary) { return state.resolvedBunBinary; } const resolved = resolveBunCliPath(); if (resolved) { prependToPath(path.dirname(resolved)); state.resolvedBunBinary = resolved; return resolved; } return null; }; const ensureNodeCliEnv = () => { if (state.resolvedNodeBinary) { return state.resolvedNodeBinary; } const resolved = resolveNodeCliPath(); if (resolved) { prependToPath(path.dirname(resolved)); state.resolvedNodeBinary = resolved; return resolved; } return null; }; const WINDOWS_BATCH_EXTENSIONS = new Set(['.cmd', '.bat', '.com']); const normalizeExecutableCandidate = (value) => { if (typeof value !== 'string') { return null; } const trimmed = value.trim(); if (!trimmed) { return null; } if (process.platform === 'win32') { return resolveWindowsExecutablePath(trimmed); } return isExecutable(trimmed) ? trimmed : null; }; const getWindowsNativeOpencodePackageNames = () => { // TEMPORARY WORKAROUND — Windows ARM64: native opencode.exe fails with a Bun // FFI/TinyCC dlopen error (https://github.com/anomalyco/opencode/issues/19130). // prepare-opencode-cli.mjs bundles x64-baseline instead; match that here so // the runtime resolver looks for the same x64-baseline package. Restore the // arm64 branch below when the upstream issue is resolved. if (process.arch === 'arm64') { // --- ORIGINAL (restore when ARM64 is fixed) --- // return ['opencode-windows-arm64']; return ['opencode-windows-x64-baseline', 'opencode-windows-x64']; } if (process.arch === 'x64') { // Prefer the baseline build when bypassing package-manager wrappers so the // direct binary still runs on hosts without AVX2 support. return ['opencode-windows-x64-baseline', 'opencode-windows-x64']; } return []; }; const resolveNativeOpencodeBinaryFromNodeModules = (nodeModulesDir) => { if (typeof nodeModulesDir !== 'string' || nodeModulesDir.trim().length === 0) { return null; } const packageShim = path.join(nodeModulesDir, 'opencode-ai', 'bin', 'opencode.exe'); if (isExecutable(packageShim)) { return packageShim; } for (const packageName of getWindowsNativeOpencodePackageNames()) { const candidates = [ path.join(nodeModulesDir, packageName, 'bin', 'opencode.exe'), path.join(nodeModulesDir, 'opencode-ai', 'node_modules', packageName, 'bin', 'opencode.exe'), ]; for (const candidate of candidates) { if (isExecutable(candidate)) { return candidate; } } } return null; }; const resolveOpencodeNodeLaunchSpecFromNodeModules = (nodeModulesDir) => { if (typeof nodeModulesDir !== 'string' || nodeModulesDir.trim().length === 0) { return null; } const launcher = path.join(nodeModulesDir, 'opencode-ai', 'bin', 'opencode'); if (!isExecutable(launcher) && !fs.existsSync(launcher)) { return null; } const nodeBinary = ensureNodeCliEnv() || resolveNodeCliPath() || 'node'; return { binary: nodeBinary, args: [launcher], wrapperType: 'node-launcher', }; }; const resolveNodeModulesDirFromCmdWrapper = (wrapperPath) => { if (!wrapperPath || typeof wrapperPath !== 'string') { return null; } try { const content = fs.readFileSync(wrapperPath, 'utf8'); const launcherMatch = content.match(/node_modules[\\/]+opencode-ai[\\/]+bin[\\/]+opencode/i); if (!launcherMatch) { return null; } const launcherPath = path.resolve(path.dirname(wrapperPath), launcherMatch[0].replace(/[\\/]+/g, path.sep)); return path.dirname(path.dirname(path.dirname(launcherPath))); } catch { return null; } }; const resolveOpencodeNodeModulesDir = (opencodePath) => { if (typeof opencodePath !== 'string' || opencodePath.trim().length === 0) { return null; } const normalized = path.resolve(opencodePath); const lower = normalized.toLowerCase(); const fileDir = path.dirname(normalized); const nodeModulesCandidates = []; const pushCandidate = (candidate) => { if (typeof candidate !== 'string' || candidate.trim().length === 0) { return; } if (!nodeModulesCandidates.includes(candidate)) { nodeModulesCandidates.push(candidate); } }; if (lower.includes(`${path.sep}.bun${path.sep}bin${path.sep}opencode`)) { const bunRoot = path.dirname(path.dirname(normalized)); pushCandidate(path.join(bunRoot, 'install', 'global', 'node_modules')); } if (lower.endsWith(`${path.sep}node_modules${path.sep}.bin${path.sep}opencode`) || lower.endsWith(`${path.sep}node_modules${path.sep}.bin${path.sep}opencode.cmd`) || lower.endsWith(`${path.sep}node_modules${path.sep}.bin${path.sep}opencode.bat`) || lower.endsWith(`${path.sep}node_modules${path.sep}.bin${path.sep}opencode.exe`)) { pushCandidate(path.dirname(fileDir)); } if (lower.endsWith(`${path.sep}node_modules${path.sep}opencode-ai${path.sep}bin${path.sep}opencode`)) { pushCandidate(path.dirname(path.dirname(fileDir))); } if (path.basename(fileDir).toLowerCase() === 'npm') { pushCandidate(path.join(fileDir, 'node_modules')); } if (WINDOWS_BATCH_EXTENSIONS.has(path.extname(normalized).toLowerCase())) { pushCandidate(resolveNodeModulesDirFromCmdWrapper(normalized)); } for (const candidate of nodeModulesCandidates) { if (resolveNativeOpencodeBinaryFromNodeModules(candidate) || resolveOpencodeNodeLaunchSpecFromNodeModules(candidate)) { return candidate; } } return null; }; const resolveManagedOpenCodeLaunchSpec = (opencodePath) => { const fallbackBinary = typeof opencodePath === 'string' && opencodePath.trim().length > 0 ? opencodePath.trim() : 'opencode'; if (process.platform !== 'win32') { return { binary: fallbackBinary, args: [], wrapperType: null }; } const ext = path.extname(fallbackBinary).toLowerCase(); const candidatePaths = [fallbackBinary]; if (WINDOWS_BATCH_EXTENSIONS.has(ext)) { candidatePaths.push(fallbackBinary.slice(0, -ext.length) + '.exe'); } for (const candidate of candidatePaths) { const nodeModulesDir = resolveOpencodeNodeModulesDir(candidate); const nativeBinary = resolveNativeOpencodeBinaryFromNodeModules(nodeModulesDir); if (nativeBinary) { return { binary: nativeBinary, args: [], wrapperType: nativeBinary === fallbackBinary ? null : 'native-wrapper', }; } const nodeLaunchSpec = resolveOpencodeNodeLaunchSpecFromNodeModules(nodeModulesDir); if (nodeLaunchSpec) { return nodeLaunchSpec; } const interpreter = opencodeShimInterpreter(candidate); if (interpreter === 'node') { return { binary: ensureNodeCliEnv() || resolveNodeCliPath() || 'node', args: [candidate], wrapperType: 'node-shebang', }; } if (interpreter === 'bun') { return { binary: ensureBunCliEnv() || resolveBunCliPath() || 'bun', args: [candidate], wrapperType: 'bun-shebang', }; } const directBinary = normalizeExecutableCandidate(candidate); if (directBinary) { const directExt = path.extname(directBinary).toLowerCase(); if (WINDOWS_BATCH_EXTENSIONS.has(directExt)) { return { binary: process.env.ComSpec || 'cmd.exe', args: ['/d', '/s', '/c', 'call', directBinary], wrapperType: 'cmd-wrapper', }; } return { binary: directBinary, args: [], wrapperType: directBinary === fallbackBinary ? null : 'executable-wrapper', }; } } // 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 }; }; const readShebang = (opencodePath) => { if (!opencodePath || typeof opencodePath !== 'string') { return null; } try { const fd = fs.openSync(opencodePath, 'r'); try { const buf = Buffer.alloc(256); const bytes = fs.readSync(fd, buf, 0, buf.length, 0); const head = buf.subarray(0, bytes).toString('utf8'); const firstLine = head.split(/\r?\n/, 1)[0] || ''; if (!firstLine.startsWith('#!')) { return null; } const shebang = firstLine.slice(2).trim(); if (!shebang) { return null; } return shebang; } finally { try { fs.closeSync(fd); } catch { } } } catch { return null; } }; const opencodeShimInterpreter = (opencodePath) => { const shebang = readShebang(opencodePath); if (!shebang) return null; if (/\bnode\b/i.test(shebang)) return 'node'; if (/\bbun\b/i.test(shebang)) return 'bun'; return null; }; const ensureOpencodeShimRuntime = (opencodePath) => { const runtime = opencodeShimInterpreter(opencodePath); if (runtime === 'node') { ensureNodeCliEnv(); } if (runtime === 'bun') { ensureBunCliEnv(); } }; const isMacOpenCodeAppBundlePath = (candidate) => { if (process.platform !== 'darwin' || typeof candidate !== 'string') { return false; } return /\/OpenCode(?: Dev| Beta)?\.app\/Contents\/MacOS\/(?:OpenCode(?: Dev| Beta)?|opencode-cli)$/i.test(candidate); }; const isKnownOpenCodeDesktopAppPath = (candidate) => isMacOpenCodeAppBundlePath(candidate) || isWindowsOpenCodeDesktopAppPath(candidate); const createConfiguredOpencodeBinaryError = (raw, normalized) => { const configured = typeof raw === 'string' ? raw.trim() : ''; const candidate = typeof normalized === 'string' && normalized.trim().length > 0 ? normalized.trim() : configured; const messageSuffix = 'OpenChamber needs the standalone opencode CLI. Install it and set settings.opencodeBinary to the CLI path, for example ~/.opencode/bin/opencode, or leave the setting empty to use PATH lookup.'; const error = (() => { if (isKnownOpenCodeDesktopAppPath(candidate) || isKnownOpenCodeDesktopAppPath(configured)) { const platformName = process.platform === 'win32' ? 'Windows desktop app install' : 'macOS desktop app bundle'; return new Error(`Configured OpenCode binary points at the ${platformName}, not the CLI: ${candidate}. ${messageSuffix}`); } try { const configuredStat = fs.statSync(configured); if (configuredStat.isDirectory()) { return new Error(`Configured OpenCode binary directory does not contain an executable ${process.platform === 'win32' ? 'opencode.exe' : 'opencode'}: ${configured}. ${messageSuffix}`); } } catch { } try { const stat = fs.statSync(candidate); if (stat.isDirectory()) { return new Error(`Configured OpenCode binary directory does not contain an executable ${process.platform === 'win32' ? 'opencode.exe' : 'opencode'}: ${candidate}. ${messageSuffix}`); } if (!stat.isFile()) { return new Error(`Configured OpenCode binary is not a file: ${candidate}. ${messageSuffix}`); } return new Error(`Configured OpenCode binary is not executable: ${candidate}. ${messageSuffix}`); } catch { return new Error(`Configured OpenCode binary not found: ${candidate}. ${messageSuffix}`); } })(); error.code = 'OPENCODE_BINARY_INVALID'; return error; }; const createConfiguredWslOpencodeError = (raw) => new Error( `Configured settings.opencodeBinary uses WSL but OpenChamber could not resolve a WSL OpenCode command: ${raw}. Ensure WSL is available and opencode is installed in the configured distro.` ); const normalizeOpencodeBinarySetting = (raw) => { if (typeof raw !== 'string') { return null; } const trimmed = normalizeDirectoryPath(raw).trim(); if (!trimmed) { return ''; } try { const stat = fs.statSync(trimmed); if (stat.isDirectory()) { const bin = process.platform === 'win32' ? 'opencode.exe' : 'opencode'; return path.join(trimmed, bin); } } catch { } return trimmed; }; const applyOpencodeBinaryFromSettings = async (options = {}) => { const strict = options?.strict === true; try { const settings = await readSettingsFromDiskMigrated(); if (!settings || typeof settings !== 'object') { return null; } if (!Object.prototype.hasOwnProperty.call(settings, 'opencodeBinary')) { return null; } const normalized = normalizeOpencodeBinarySetting(settings.opencodeBinary); if (normalized === '') { // The empty-string sentinel drops a previously APPLIED settings // override (source === 'settings'). An OPENCODE_BINARY provided by // the user's own environment is explicit configuration and must not // be destroyed by an empty setting. if (state.resolvedOpencodeBinarySource === 'settings') { delete process.env.OPENCODE_BINARY; } state.resolvedOpencodeBinary = null; state.resolvedOpencodeBinarySource = null; clearWslOpencodeResolution(); return null; } const raw = typeof settings.opencodeBinary === 'string' ? settings.opencodeBinary.trim() : ''; const explicitWslPath = process.platform === 'win32' && typeof raw === 'string' ? raw.match(/^wsl:\s*(.+)$/i) : null; if (explicitWslPath && explicitWslPath[1] && explicitWslPath[1].trim().length > 0) { clearWslOpencodeResolution(); if (strict) { throw createConfiguredWslOpencodeError(raw); } console.warn(`Configured settings.opencodeBinary uses WSL, which is no longer supported by OpenChamber desktop: ${raw}`); return null; } if (process.platform === 'win32' && (isWslExecutableValue(raw) || isWslExecutableValue(normalized || ''))) { clearWslOpencodeResolution(); if (strict) { throw createConfiguredWslOpencodeError(raw); } console.warn(`Configured settings.opencodeBinary points to WSL, which is no longer supported by OpenChamber desktop: ${raw}`); return null; } if (normalized && isExecutable(normalized) && !isKnownOpenCodeDesktopAppPath(normalized)) { clearWslOpencodeResolution(); process.env.OPENCODE_BINARY = normalized; prependToPath(path.dirname(normalized)); state.resolvedOpencodeBinary = normalized; state.resolvedOpencodeBinarySource = 'settings'; ensureOpencodeShimRuntime(normalized); return normalized; } if (raw) { if (strict) { throw createConfiguredOpencodeBinaryError(raw, normalized); } console.warn(`Configured settings.opencodeBinary is not executable: ${raw}`); } } catch (error) { if (strict) { throw error; } } return null; }; const ensureOpencodeCliEnv = () => { if (state.resolvedOpencodeBinary) { if (state.useWslForOpencode) { return state.resolvedOpencodeBinary; } ensureOpencodeShimRuntime(state.resolvedOpencodeBinary); return state.resolvedOpencodeBinary; } const existing = typeof process.env.OPENCODE_BINARY === 'string' ? process.env.OPENCODE_BINARY.trim() : ''; if (existing && isExecutable(existing)) { clearWslOpencodeResolution(); state.resolvedOpencodeBinary = existing; state.resolvedOpencodeBinarySource = state.resolvedOpencodeBinarySource || 'env'; prependToPath(path.dirname(existing)); ensureOpencodeShimRuntime(existing); return state.resolvedOpencodeBinary; } const resolved = resolveOpencodeCliPath(); if (resolved) { if (state.useWslForOpencode) { state.resolvedOpencodeBinary = resolved; state.resolvedOpencodeBinarySource = state.resolvedOpencodeBinarySource || 'wsl'; console.log(`Resolved opencode CLI via WSL: ${state.resolvedWslOpencodePath || 'opencode'}`); return resolved; } process.env.OPENCODE_BINARY = resolved; prependToPath(path.dirname(resolved)); ensureOpencodeShimRuntime(resolved); state.resolvedOpencodeBinary = resolved; state.resolvedOpencodeBinarySource = state.resolvedOpencodeBinarySource || 'unknown'; console.log(`Resolved opencode CLI: ${resolved}`); return resolved; } clearWslOpencodeResolution(); return null; }; const resolveGitBinaryForSpawn = () => { if (process.platform !== 'win32') { return 'git'; } if (state.resolvedGitBinary) { return state.resolvedGitBinary; } const explicit = [process.env.GIT_BINARY, process.env.OPENCHAMBER_GIT_BINARY] .map((value) => (typeof value === 'string' ? value.trim() : '')) .filter(Boolean); for (const candidate of explicit) { if (isExecutable(candidate)) { state.resolvedGitBinary = candidate; return state.resolvedGitBinary; } } const candidates = []; const normalizeGitCandidate = (candidate) => { if (typeof candidate !== 'string') { return ''; } const trimmed = candidate.trim(); if (!trimmed) { return ''; } const ext = path.extname(trimmed).toLowerCase(); if (ext === '.cmd' || ext === '.bat' || ext === '.com') { const exeCandidate = trimmed.slice(0, -ext.length) + '.exe'; if (isExecutable(exeCandidate)) { return exeCandidate; } } return trimmed; }; const pathCandidate = normalizeGitCandidate(searchPathFor('git')); if (pathCandidate && isExecutable(pathCandidate)) { candidates.push(pathCandidate); } const pathExeCandidate = normalizeGitCandidate(searchPathFor('git.exe')); if (pathExeCandidate && isExecutable(pathExeCandidate)) { candidates.push(pathExeCandidate); } const programRoots = [ process.env.ProgramFiles, process.env['ProgramFiles(x86)'], process.env.LocalAppData, ] .map((value) => (typeof value === 'string' ? value.trim() : '')) .filter(Boolean); for (const root of programRoots) { const installCandidates = [ path.join(root, 'Git', 'cmd', 'git.exe'), path.join(root, 'Git', 'bin', 'git.exe'), path.join(root, 'Git', 'mingw64', 'bin', 'git.exe'), path.join(root, 'Programs', 'Git', 'cmd', 'git.exe'), path.join(root, 'Programs', 'Git', 'bin', 'git.exe'), ]; for (const candidate of installCandidates) { const normalized = normalizeGitCandidate(candidate); if (normalized && isExecutable(normalized)) { candidates.push(normalized); } } } const preferredExe = candidates.find((candidate) => candidate.toLowerCase().endsWith('.exe')); state.resolvedGitBinary = preferredExe || candidates[0] || 'git.exe'; return state.resolvedGitBinary; }; const clearResolvedOpenCodeBinary = () => { state.resolvedOpencodeBinary = null; }; return { applyLoginShellEnvSnapshot, ensureOpencodeCliEnv, applyOpencodeBinaryFromSettings, getLoginShellEnvSnapshot, resolveOpencodeCliPath, isBundledOpenCodeCliPath, resolveManagedOpenCodeLaunchSpec, isExecutable, searchPathFor, resolveGitBinaryForSpawn, clearResolvedOpenCodeBinary, }; };