2026-03-31 18:47:00 +03:00
import { spawnSync } from 'node:child_process' ;
import fs from 'node:fs' ;
import os from 'node:os' ;
import path from 'node:path' ;
2026-04-28 01:19:12 +03:00
import { mergePathValues } from './path-utils.js' ;
2026-03-31 18:47:00 +03:00
export const createOpenCodeEnvRuntime = ( deps ) => {
const {
state ,
normalizeDirectoryPath ,
readSettingsFromDiskMigrated ,
} = deps ;
2026-06-05 19:09:42 +03:00
const runSpawnSync = typeof deps . spawnSync === 'function' ? deps . spawnSync : spawnSync ;
2026-03-31 18:47:00 +03:00
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 ;
}
2026-06-04 20:15:56 +03:00
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 ;
2026-03-31 18:47:00 +03:00
};
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 ;
}
};
2026-05-26 11:13:59 -04:00
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 ;
};
2026-03-31 18:47:00 +03:00
const searchPathFor = ( binaryName ) => {
2026-04-28 12:35:00 +03:00
const trimmed = typeof binaryName === 'string' ? binaryName . trim () : '' ;
if ( ! trimmed ) {
return null ;
}
2026-03-31 18:47:00 +03:00
const current = process . env . PATH || '' ;
const parts = current . split ( path . delimiter ). filter ( Boolean );
2026-05-26 11:13:59 -04:00
const candidateNames = [];
2026-04-28 12:35:00 +03:00
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 );
}
}
}
2026-05-26 11:13:59 -04:00
candidateNames . push ( trimmed );
2026-03-31 18:47:00 +03:00
for ( const dir of parts ) {
2026-04-28 12:35:00 +03:00
for ( const candidateName of candidateNames ) {
const candidate = path . join ( dir , candidateName );
if ( isExecutable ( candidate )) {
return candidate ;
}
2026-03-31 18:47:00 +03:00
}
}
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 : '' );
2026-06-04 20:15:56 +03:00
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 ( '; ' );
2026-03-31 18:47:00 +03:00
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 {
2026-06-05 19:09:42 +03:00
const result = runSpawnSync ( shellPath , [ '-NoLogo' , '-Command' , psScript ], {
2026-03-31 18:47:00 +03:00
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 {
2026-06-05 19:09:42 +03:00
const result = runSpawnSync ( comspec , [ '/d' , '/s' , '/c' , 'set' ], {
2026-03-31 18:47:00 +03:00
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 {
2026-06-05 19:09:42 +03:00
const result = runSpawnSync ( shellPath , [ '-lic' , 'env -0' ], {
2026-03-31 18:47:00 +03:00
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 ;
}
2026-04-25 20:44:07 +03:00
const currentPath = process . env . PATH || '' ;
const shellPath = snapshot . PATH || '' ;
2026-04-28 01:19:12 +03:00
if ( ! shellPath ) {
return ;
2026-04-25 20:44:07 +03:00
}
2026-04-28 01:19:12 +03:00
process . env . PATH = mergePathValues ( shellPath , currentPath , path . delimiter );
2026-03-31 18:47:00 +03:00
};
const isWslExecutableValue = ( value ) => {
if ( typeof value !== 'string' ) return false ;
const trimmed = value . trim ();
if ( ! trimmed ) return false ;
return /(^|[\\/])wsl(\.exe)?$/i . test ( trimmed );
};
2026-07-02 00:48:09 +03:00
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` );
};
2026-07-02 17:43:33 +03:00
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 ;
};
2026-03-31 18:47:00 +03:00
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 ) {
2026-07-02 00:48:09 +03:00
if ( isExecutable ( candidate ) && ! isWindowsOpenCodeDesktopAppPath ( candidate )) {
2026-03-31 18:47:00 +03:00
clearWslOpencodeResolution ();
state . resolvedOpencodeBinarySource = 'env' ;
return candidate ;
}
}
2026-07-02 17:43:33 +03:00
const bundled = resolveBundledOpenCodeCliPath ();
if ( bundled ) {
clearWslOpencodeResolution ();
state . resolvedOpencodeBinarySource = 'bundled' ;
return bundled ;
}
2026-03-31 18:47:00 +03:00
const resolvedFromPath = searchPathFor ( 'opencode' );
if ( resolvedFromPath ) {
clearWslOpencodeResolution ();
state . resolvedOpencodeBinarySource = 'path' ;
return resolvedFromPath ;
}
const home = os . homedir ();
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 {
2026-06-05 19:09:42 +03:00
const result = runSpawnSync ( 'where' , [ 'opencode' ], {
2026-03-31 18:47:00 +03:00
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 );
2026-07-02 00:48:09 +03:00
const found = lines . find (( line ) => isExecutable ( line ) && ! isWindowsOpenCodeDesktopAppPath ( line ));
2026-03-31 18:47:00 +03:00
if ( found ) {
clearWslOpencodeResolution ();
state . resolvedOpencodeBinarySource = 'where' ;
return found ;
}
}
} catch {
}
2026-06-05 20:35:53 +03:00
// 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.
2026-03-31 18:47:00 +03:00
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 {
2026-06-05 19:09:42 +03:00
const result = runSpawnSync ( shell , [ '-lic' , 'command -v opencode' ], {
2026-03-31 18:47:00 +03:00
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 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 {
2026-06-05 19:09:42 +03:00
const result = runSpawnSync ( 'where' , [ 'node' ], {
2026-03-31 18:47:00 +03:00
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 {
2026-06-05 19:09:42 +03:00
const result = runSpawnSync ( shell , [ '-lic' , 'command -v node' ], {
2026-03-31 18:47:00 +03:00
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 {
2026-06-05 19:09:42 +03:00
const result = runSpawnSync ( 'where' , [ 'bun' ], {
2026-03-31 18:47:00 +03:00
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 {
2026-06-05 19:09:42 +03:00
const result = runSpawnSync ( shell , [ '-lic' , 'command -v bun' ], {
2026-03-31 18:47:00 +03:00
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 ;
};
2026-04-12 07:29:09 +12:00
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 ;
}
2026-05-26 11:13:59 -04:00
if ( process . platform === 'win32' ) {
return resolveWindowsExecutablePath ( trimmed );
}
2026-04-12 07:29:09 +12:00
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 ;
}
2026-05-26 11:13:59 -04:00
const packageShim = path . join ( nodeModulesDir , 'opencode-ai' , 'bin' , 'opencode.exe' );
if ( isExecutable ( packageShim )) {
return packageShim ;
}
2026-04-12 07:29:09 +12:00
for ( const packageName of getWindowsNativeOpencodePackageNames ()) {
2026-05-26 11:13:59 -04:00
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 ;
}
2026-04-12 07:29:09 +12:00
}
}
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 ;
}
2026-06-05 19:09:42 +03:00
const launcherPath = path . resolve ( path . dirname ( wrapperPath ), launcherMatch [ 0 ]. replace ( /[\\/]+/g , path . sep ));
2026-04-12 07:29:09 +12:00
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 ) {
2026-05-26 11:13:59 -04:00
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' ,
};
}
2026-04-12 07:29:09 +12:00
return {
binary : directBinary ,
args : [],
wrapperType : directBinary === fallbackBinary ? null : 'executable-wrapper' ,
};
}
}
return { binary : fallbackBinary , args : [], wrapperType : null };
};
2026-03-31 18:47:00 +03:00
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 ();
}
};
2026-05-06 02:06:16 +03:00
const isMacOpenCodeAppBundlePath = ( candidate ) => {
if ( process . platform !== 'darwin' || typeof candidate !== 'string' ) {
return false ;
}
return /\/OpenCode\.app\/Contents\/MacOS\/(?:OpenCode|opencode-cli)$/i . test ( candidate );
};
2026-07-02 00:48:09 +03:00
const isKnownOpenCodeDesktopAppPath = ( candidate ) => isMacOpenCodeAppBundlePath ( candidate )
|| isWindowsOpenCodeDesktopAppPath ( candidate );
2026-05-06 02:06:16 +03:00
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 = (() => {
2026-07-02 00:48:09 +03:00
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 } ` );
2026-05-06 02:06:16 +03:00
}
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.`
);
2026-03-31 18:47:00 +03:00
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 ;
};
2026-05-06 02:06:16 +03:00
const applyOpencodeBinaryFromSettings = async ( options = {}) => {
const strict = options ? . strict === true ;
2026-03-31 18:47:00 +03:00
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 ) {
2026-06-05 20:35:53 +03:00
clearWslOpencodeResolution ();
2026-05-06 02:06:16 +03:00
if ( strict ) {
throw createConfiguredWslOpencodeError ( raw );
}
2026-06-05 20:35:53 +03:00
console . warn ( `Configured settings.opencodeBinary uses WSL, which is no longer supported by OpenChamber desktop: ${ raw } ` );
return null ;
2026-03-31 18:47:00 +03:00
}
if ( process . platform === 'win32' && ( isWslExecutableValue ( raw ) || isWslExecutableValue ( normalized || '' ))) {
2026-06-05 20:35:53 +03:00
clearWslOpencodeResolution ();
2026-05-06 02:06:16 +03:00
if ( strict ) {
throw createConfiguredWslOpencodeError ( raw );
}
2026-06-05 20:35:53 +03:00
console . warn ( `Configured settings.opencodeBinary points to WSL, which is no longer supported by OpenChamber desktop: ${ raw } ` );
return null ;
2026-03-31 18:47:00 +03:00
}
2026-07-02 00:48:09 +03:00
if ( normalized && isExecutable ( normalized ) && ! isKnownOpenCodeDesktopAppPath ( normalized )) {
2026-03-31 18:47:00 +03:00
clearWslOpencodeResolution ();
process . env . OPENCODE_BINARY = normalized ;
prependToPath ( path . dirname ( normalized ));
state . resolvedOpencodeBinary = normalized ;
state . resolvedOpencodeBinarySource = 'settings' ;
ensureOpencodeShimRuntime ( normalized );
return normalized ;
}
if ( raw ) {
2026-05-06 02:06:16 +03:00
if ( strict ) {
throw createConfiguredOpencodeBinaryError ( raw , normalized );
}
2026-03-31 18:47:00 +03:00
console . warn ( `Configured settings.opencodeBinary is not executable: ${ raw } ` );
}
2026-05-06 02:06:16 +03:00
} catch ( error ) {
if ( strict ) {
throw error ;
}
2026-03-31 18:47:00 +03:00
}
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 ,
2026-04-12 07:29:09 +12:00
resolveManagedOpenCodeLaunchSpec ,
2026-03-31 18:47:00 +03:00
isExecutable ,
searchPathFor ,
resolveGitBinaryForSpawn ,
clearResolvedOpenCodeBinary ,
};
};