From 62b5ddd426e3e4c2b1e1e4272e37c86373bc742d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 5 Feb 2026 13:57:50 +0200 Subject: [PATCH] fix(opencode): improve binary resolution and HOME handling --- packages/desktop/src-tauri/src/main.rs | 24 +++- packages/vscode/src/opencode.ts | 1 + packages/web/server/index.js | 174 ++++++++++++++++++++++++- 3 files changed, 191 insertions(+), 8 deletions(-) diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 5dc7c149..241fa473 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -858,6 +858,19 @@ async fn spawn_local_server(app: &tauri::AppHandle) -> Result { let mut path_segments: Vec = Vec::new(); let mut seen = std::collections::HashSet::::new(); + let resolved_home_dir = app + .path() + .home_dir() + .ok() + .and_then(|p| { + let s = p.to_string_lossy().to_string(); + if s.trim().is_empty() { + None + } else { + Some(s) + } + }); + let mut push_unique = |value: String| { let trimmed = value.trim(); if trimmed.is_empty() { @@ -895,16 +908,13 @@ async fn spawn_local_server(app: &tauri::AppHandle) -> Result { push_unique("/usr/sbin".to_string()); push_unique("/sbin".to_string()); - if let Ok(home) = env::var("HOME") { - let home = home.trim(); - if !home.is_empty() { + if let Some(home) = resolved_home_dir.as_deref() { // OpenCode installer default. push_unique(format!("{home}/.opencode/bin")); push_unique(format!("{home}/.local/bin")); push_unique(format!("{home}/.bun/bin")); push_unique(format!("{home}/.cargo/bin")); push_unique(format!("{home}/bin")); - } } if let Ok(existing) = env::var("PATH") { @@ -922,7 +932,7 @@ async fn spawn_local_server(app: &tauri::AppHandle) -> Result { }; let url = build_local_url(port); - let cmd = app + let mut cmd = app .shell() .sidecar(SIDECAR_NAME) .map_err(|err| anyhow!("Failed to resolve sidecar '{SIDECAR_NAME}': {err}"))? @@ -934,6 +944,10 @@ async fn spawn_local_server(app: &tauri::AppHandle) -> Result { .env("NO_PROXY", no_proxy) .env("no_proxy", no_proxy); + if let Some(home) = resolved_home_dir.as_deref() { + cmd = cmd.env("HOME", home); + } + let (rx, child) = match cmd.spawn() { Ok(v) => v, Err(err) => { diff --git a/packages/vscode/src/opencode.ts b/packages/vscode/src/opencode.ts index 24faa274..d2deccbb 100644 --- a/packages/vscode/src/opencode.ts +++ b/packages/vscode/src/opencode.ts @@ -102,6 +102,7 @@ function resolveOpencodeCliPath(): string | null { 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'), ]; diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 6064acd2..9b547cda 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -1866,6 +1866,170 @@ const ENV_CONFIGURED_API_PREFIX = normalizeApiPrefix( let globalEventWatcherAbortController = null; +let resolvedOpencodeBinary = null; + +function 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; + } +} + +function 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); +} + +function searchPathFor(binaryName) { + const current = process.env.PATH || ''; + const parts = current.split(path.delimiter).filter(Boolean); + for (const dir of parts) { + const candidate = path.join(dir, binaryName); + if (isExecutable(candidate)) { + return candidate; + } + } + return null; +} + +function 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)) { + return candidate; + } + } + + const resolvedFromPath = searchPathFor('opencode'); + if (resolvedFromPath) { + 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'), + localAppData ? path.join(localAppData, 'Programs', 'opencode', 'opencode.exe') : '', + ].filter(Boolean); + })(); + + const fallbacks = process.platform === 'win32' ? winFallbacks : unixFallbacks; + for (const candidate of fallbacks) { + if (isExecutable(candidate)) { + return candidate; + } + } + + if (process.platform === 'win32') { + try { + const result = spawnSync('where', ['opencode'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + 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 { + // ignore + } + 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 = spawnSync(shell, ['-lic', 'command -v opencode'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.status === 0) { + const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; + if (found && isExecutable(found)) { + return found; + } + } + } catch { + // ignore + } + } + + return null; +} + +function ensureOpencodeCliEnv() { + if (resolvedOpencodeBinary) { + return resolvedOpencodeBinary; + } + + const existing = typeof process.env.OPENCODE_BINARY === 'string' ? process.env.OPENCODE_BINARY.trim() : ''; + if (existing && isExecutable(existing)) { + resolvedOpencodeBinary = existing; + prependToPath(path.dirname(existing)); + return resolvedOpencodeBinary; + } + + const resolved = resolveOpencodeCliPath(); + if (resolved) { + process.env.OPENCODE_BINARY = resolved; + prependToPath(path.dirname(resolved)); + resolvedOpencodeBinary = resolved; + console.log(`Resolved opencode CLI: ${resolved}`); + return resolved; + } + + return null; +} + const startGlobalEventWatcher = async () => { if (globalEventWatcherAbortController) { return; @@ -2710,6 +2874,8 @@ async function startOpenCode() { ); // Note: SDK starts in current process CWD. openCodeWorkingDirectory is tracked but not used for spawn in SDK. + ensureOpencodeCliEnv(); + try { const serverInstance = await createOpencodeServer({ hostname: '127.0.0.1', @@ -3019,13 +3185,15 @@ async function refreshOpenCodeAfterConfigChange(reason, options = {}) { } function setupProxy(app) { - if (!openCodePort) return; - if (app.get('opencodeProxyConfigured')) { return; } - console.log(`Setting up proxy to OpenCode on port ${openCodePort}`); + if (openCodePort) { + console.log(`Setting up proxy to OpenCode on port ${openCodePort}`); + } else { + console.log('Setting up OpenCode API gate (OpenCode not started yet)'); + } app.set('opencodeProxyConfigured', true); app.use('/api', (req, res, next) => {