From 7ec45785d96eef4021cfb3ab8665d0fa69598852 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 5 Jul 2026 10:06:09 +0300 Subject: [PATCH] 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. --- .../web/server/lib/opencode/env-runtime.js | 25 +++++++++++-------- .../server/lib/opencode/env-runtime.test.js | 17 +++++++++---- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index fabfa0a4..7e4cf9d6 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -11,6 +11,7 @@ export const createOpenCodeEnvRuntime = (deps) => { 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) { @@ -301,6 +302,14 @@ export const createOpenCodeEnvRuntime = (deps) => { 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; @@ -326,13 +335,9 @@ export const createOpenCodeEnvRuntime = (deps) => { } } - const bundled = resolveBundledOpenCodeCliPath(); - if (bundled) { - clearWslOpencodeResolution(); - state.resolvedOpencodeBinarySource = 'bundled'; - return bundled; - } - + // 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(); @@ -340,7 +345,7 @@ export const createOpenCodeEnvRuntime = (deps) => { return resolvedFromPath; } - const home = os.homedir(); + const home = resolveHomeDir(); const unixFallbacks = [ path.join(home, '.opencode', 'bin', 'opencode'), path.join(home, '.bun', 'bin', 'opencode'), @@ -403,7 +408,7 @@ export const createOpenCodeEnvRuntime = (deps) => { // 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; + return bundledOpenCodeCliFallback(); } const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); @@ -427,7 +432,7 @@ export const createOpenCodeEnvRuntime = (deps) => { } } - return null; + return bundledOpenCodeCliFallback(); }; const resolveNodeCliPath = () => { diff --git a/packages/web/server/lib/opencode/env-runtime.test.js b/packages/web/server/lib/opencode/env-runtime.test.js index f5c5c7c5..8f61b515 100644 --- a/packages/web/server/lib/opencode/env-runtime.test.js +++ b/packages/web/server/lib/opencode/env-runtime.test.js @@ -111,6 +111,7 @@ const createRuntime = (settings, options = {}) => { normalizeDirectoryPath: (value) => value, readSettingsFromDiskMigrated: async () => settings, spawnSync: options.spawnSync, + homedir: options.homedir, }); return { runtime, state }; @@ -149,7 +150,7 @@ describe('OpenCode env runtime', () => { expect(state.resolvedOpencodeBinarySource).toBe('settings'); }); - it('resolves bundled OpenCode CLI before PATH lookup', () => { + it('prefers a user-installed OpenCode from PATH over the bundled CLI', () => { const bundledDir = createTempDir('openchamber-bundled-opencode-'); const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode'); const pathDir = createTempDir('openchamber-path-opencode-'); @@ -165,8 +166,8 @@ describe('OpenCode env runtime', () => { delete process.env.OPENCODE_BINARY; const { runtime, state } = createRuntime({}); - expect(runtime.resolveOpencodeCliPath()).toBe(bundledBinary); - expect(state.resolvedOpencodeBinarySource).toBe('bundled'); + expect(runtime.resolveOpencodeCliPath()).toBe(pathBinary); + expect(state.resolvedOpencodeBinarySource).toBe('path'); }); it('keeps explicit OpenCode binary ahead of bundled CLI', () => { @@ -188,7 +189,7 @@ describe('OpenCode env runtime', () => { expect(state.resolvedOpencodeBinarySource).toBe('env'); }); - it('resolves bundled OpenCode CLI from Electron resourcesPath', () => { + it('falls back to the bundled OpenCode CLI from Electron resourcesPath when nothing else is installed', () => { const resourcesPath = createTempDir('openchamber-resources-'); const bundledDir = path.join(resourcesPath, 'opencode-cli'); const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode'); @@ -204,7 +205,13 @@ describe('OpenCode env runtime', () => { process.env.PATH = createTempDir('openchamber-empty-path-'); delete process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR; delete process.env.OPENCODE_BINARY; - const { runtime, state } = createRuntime({}); + // The bundled CLI is the LAST resort now — hide the machine's own installs + // from the home-directory fallbacks and shell discovery. + const emptyHome = createTempDir('openchamber-empty-home-'); + const { runtime, state } = createRuntime({}, { + spawnSync: () => ({ status: 1, stdout: '', stderr: '' }), + homedir: () => emptyHome, + }); expect(runtime.resolveOpencodeCliPath()).toBe(bundledBinary); expect(state.resolvedOpencodeBinarySource).toBe('bundled');