Files
openchamber/packages/web/server/lib/opencode/env-runtime.js
T
Bohdan Triapitsyn 7ec45785d9 fix(desktop): prefer the user's OpenCode install over the bundled CLI
The bundled CLI used to outrank PATH and known install locations, so
updating the desktop app silently switched people off their own OpenCode.
It is now the last resort: explicit env/settings, PATH, known install
locations, and shell discovery all win; the bundle only serves machines
with no OpenCode install at all. The env-runtime accepts an injectable
homedir so the fallback test stays hermetic on machines with a real
~/.opencode install.
2026-07-05 10:06:09 +03:00

1145 lines
36 KiB
JavaScript

import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { mergePathValues } from './path-utils.js';
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) => {
const trimmed = typeof binaryName === 'string' ? binaryName.trim() : '';
if (!trimmed) {
return null;
}
const current = process.env.PATH || '';
const parts = current.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,
});
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 = () => {
const snapshot = getLoginShellEnvSnapshot();
if (!snapshot) {
return;
}
const skipKeys = new Set(['PWD', 'OLDPWD', 'SHLVL', '_']);
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 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;
};
const resolveOpencodeCliPath = () => {
const explicit = [
process.env.OPENCODE_BINARY,
process.env.OPENCODE_PATH,
process.env.OPENCHAMBER_OPENCODE_PATH,
process.env.OPENCHAMBER_OPENCODE_BIN,
]
.map((v) => (typeof v === 'string' ? v.trim() : ''))
.filter(Boolean);
for (const candidate of explicit) {
if (isExecutable(candidate) && !isWindowsOpenCodeDesktopAppPath(candidate)) {
clearWslOpencodeResolution();
state.resolvedOpencodeBinarySource = 'env';
return candidate;
}
}
// The bundled CLI is the LAST resort (see bundledOpenCodeCliFallback at the
// exit points below): a user's own OpenCode install — PATH, known install
// locations, or shell-resolved — must win over the pinned bundled copy.
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',
'/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';
return [
path.join(userProfile, '.opencode', 'bin', 'opencode.exe'),
path.join(userProfile, '.opencode', 'bin', 'opencode.cmd'),
path.join(appData, 'npm', 'opencode.cmd'),
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 bundledOpenCodeCliFallback();
}
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,
});
if (result.status === 0) {
const found = (result.stdout || '').trim().split(/\s+/).pop() || '';
if (found && isExecutable(found)) {
clearWslOpencodeResolution();
state.resolvedOpencodeBinarySource = 'shell';
return found;
}
}
} catch {
}
}
return bundledOpenCodeCliFallback();
};
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,
});
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,
});
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 = () => {
if (process.arch === 'arm64') {
return ['opencode-windows-arm64'];
}
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',
};
}
}
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\.app\/Contents\/MacOS\/(?:OpenCode|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 === '') {
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,
resolveManagedOpenCodeLaunchSpec,
isExecutable,
searchPathFor,
resolveGitBinaryForSpawn,
clearResolvedOpenCodeBinary,
};
};