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.
This commit is contained in:
Bohdan Triapitsyn
2026-07-05 10:06:09 +03:00
parent 0820778763
commit 7ec45785d9
2 changed files with 27 additions and 15 deletions
+15 -10
View File
@@ -11,6 +11,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
readSettingsFromDiskMigrated, readSettingsFromDiskMigrated,
} = deps; } = deps;
const runSpawnSync = typeof deps.spawnSync === 'function' ? deps.spawnSync : spawnSync; const runSpawnSync = typeof deps.spawnSync === 'function' ? deps.spawnSync : spawnSync;
const resolveHomeDir = typeof deps.homedir === 'function' ? deps.homedir : () => os.homedir();
const parseNullSeparatedEnvSnapshot = (raw) => { const parseNullSeparatedEnvSnapshot = (raw) => {
if (typeof raw !== 'string' || raw.length === 0) { if (typeof raw !== 'string' || raw.length === 0) {
@@ -301,6 +302,14 @@ export const createOpenCodeEnvRuntime = (deps) => {
return null; return null;
}; };
const bundledOpenCodeCliFallback = () => {
const bundled = resolveBundledOpenCodeCliPath();
if (!bundled) return null;
clearWslOpencodeResolution();
state.resolvedOpencodeBinarySource = 'bundled';
return bundled;
};
const clearWslOpencodeResolution = () => { const clearWslOpencodeResolution = () => {
state.useWslForOpencode = false; state.useWslForOpencode = false;
state.resolvedWslBinary = null; state.resolvedWslBinary = null;
@@ -326,13 +335,9 @@ export const createOpenCodeEnvRuntime = (deps) => {
} }
} }
const bundled = resolveBundledOpenCodeCliPath(); // The bundled CLI is the LAST resort (see bundledOpenCodeCliFallback at the
if (bundled) { // exit points below): a user's own OpenCode install — PATH, known install
clearWslOpencodeResolution(); // locations, or shell-resolved — must win over the pinned bundled copy.
state.resolvedOpencodeBinarySource = 'bundled';
return bundled;
}
const resolvedFromPath = searchPathFor('opencode'); const resolvedFromPath = searchPathFor('opencode');
if (resolvedFromPath) { if (resolvedFromPath) {
clearWslOpencodeResolution(); clearWslOpencodeResolution();
@@ -340,7 +345,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
return resolvedFromPath; return resolvedFromPath;
} }
const home = os.homedir(); const home = resolveHomeDir();
const unixFallbacks = [ const unixFallbacks = [
path.join(home, '.opencode', 'bin', 'opencode'), path.join(home, '.opencode', 'bin', 'opencode'),
path.join(home, '.bun', '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 // Do not auto-detect OpenCode from WSL. OpenCode sessions are keyed by
// server-visible directories, and mixing Windows paths with WSL paths // server-visible directories, and mixing Windows paths with WSL paths
// creates duplicate/missing project state in the desktop app. // 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); 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 = () => { const resolveNodeCliPath = () => {
@@ -111,6 +111,7 @@ const createRuntime = (settings, options = {}) => {
normalizeDirectoryPath: (value) => value, normalizeDirectoryPath: (value) => value,
readSettingsFromDiskMigrated: async () => settings, readSettingsFromDiskMigrated: async () => settings,
spawnSync: options.spawnSync, spawnSync: options.spawnSync,
homedir: options.homedir,
}); });
return { runtime, state }; return { runtime, state };
@@ -149,7 +150,7 @@ describe('OpenCode env runtime', () => {
expect(state.resolvedOpencodeBinarySource).toBe('settings'); 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 bundledDir = createTempDir('openchamber-bundled-opencode-');
const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode'); const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
const pathDir = createTempDir('openchamber-path-opencode-'); const pathDir = createTempDir('openchamber-path-opencode-');
@@ -165,8 +166,8 @@ describe('OpenCode env runtime', () => {
delete process.env.OPENCODE_BINARY; delete process.env.OPENCODE_BINARY;
const { runtime, state } = createRuntime({}); const { runtime, state } = createRuntime({});
expect(runtime.resolveOpencodeCliPath()).toBe(bundledBinary); expect(runtime.resolveOpencodeCliPath()).toBe(pathBinary);
expect(state.resolvedOpencodeBinarySource).toBe('bundled'); expect(state.resolvedOpencodeBinarySource).toBe('path');
}); });
it('keeps explicit OpenCode binary ahead of bundled CLI', () => { it('keeps explicit OpenCode binary ahead of bundled CLI', () => {
@@ -188,7 +189,7 @@ describe('OpenCode env runtime', () => {
expect(state.resolvedOpencodeBinarySource).toBe('env'); 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 resourcesPath = createTempDir('openchamber-resources-');
const bundledDir = path.join(resourcesPath, 'opencode-cli'); const bundledDir = path.join(resourcesPath, 'opencode-cli');
const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode'); 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-'); process.env.PATH = createTempDir('openchamber-empty-path-');
delete process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR; delete process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR;
delete process.env.OPENCODE_BINARY; 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(runtime.resolveOpencodeCliPath()).toBe(bundledBinary);
expect(state.resolvedOpencodeBinarySource).toBe('bundled'); expect(state.resolvedOpencodeBinarySource).toBe('bundled');