feat(desktop): bundle pinned OpenCode CLI

Bundle the official OpenCode CLI into Electron desktop builds instead of relying on whichever opencode executable happens to be first on PATH. Pin @opencode-ai/sdk to an exact version and use that version as the source of truth for the downloaded CLI artifact.

Add an Electron prepare script that maps the current platform/arch to the official OpenCode release artifact, downloads it from GitHub releases, caches the archive under packages/electron/.cache, stages the binary under resources/opencode-cli, verifies opencode --version, and skips work when the staged binary already matches.

Prefer explicit OpenCode binary overrides first, then the bundled Electron CLI, then PATH/system installs. Keep rejecting the Windows OpenCode desktop app executable as a CLI candidate and add resolver tests for bundled priority, explicit override priority, resourcesPath lookup, and desktop-app rejection.

Suppress OpenCode CLI update prompts when the active CLI source is bundled. The server now reports upgrade-status as unavailable for bundled CLI while still returning the current OpenCode version for About, and rejects direct upgrade attempts with a 409 instead of trying to mutate the bundled binary.

Update desktop release, smoke, and manual macOS DMG workflows to prepare and verify the bundled CLI before packaging, verify the packaged app contains the expected CLI, cache downloads by OS/arch/OpenCode version, and align the Windows smoke runner with production windows-2022.

Document desktop bundling behavior, ignore generated CLI/cache files, add oc-dev helpers, and keep Web/VS Code behavior dependent on installed OpenCode CLI rather than desktop bundled resources.
This commit is contained in:
Bohdan Triapitsyn
2026-07-02 17:43:33 +03:00
parent bd8ab070e7
commit 33ecd628bd
20 changed files with 640 additions and 34 deletions
@@ -274,6 +274,33 @@ export const createOpenCodeEnvRuntime = (deps) => {
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 clearWslOpencodeResolution = () => {
state.useWslForOpencode = false;
state.resolvedWslBinary = null;
@@ -299,6 +326,13 @@ export const createOpenCodeEnvRuntime = (deps) => {
}
}
const bundled = resolveBundledOpenCodeCliPath();
if (bundled) {
clearWslOpencodeResolution();
state.resolvedOpencodeBinarySource = 'bundled';
return bundled;
}
const resolvedFromPath = searchPathFor('opencode');
if (resolvedFromPath) {
clearWslOpencodeResolution();
@@ -9,6 +9,8 @@ const originalComSpec = process.env.ComSpec;
const originalPath = process.env.PATH;
const originalLocalAppData = process.env.LOCALAPPDATA;
const originalSystemRoot = process.env.SystemRoot;
const originalBundledOpencodeCliDir = process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR;
const originalResourcesPath = process.resourcesPath;
const originalWslBinary = process.env.WSL_BINARY;
const originalOpenChamberWslBinary = process.env.OPENCHAMBER_WSL_BINARY;
const originalPlatform = process.platform;
@@ -66,6 +68,17 @@ afterEach(() => {
delete process.env.LOCALAPPDATA;
}
if (typeof originalBundledOpencodeCliDir === 'string') {
process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR = originalBundledOpencodeCliDir;
} else {
delete process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR;
}
Object.defineProperty(process, 'resourcesPath', {
configurable: true,
value: originalResourcesPath,
});
if (typeof originalWslBinary === 'string') {
process.env.WSL_BINARY = originalWslBinary;
} else {
@@ -136,6 +149,67 @@ describe('OpenCode env runtime', () => {
expect(state.resolvedOpencodeBinarySource).toBe('settings');
});
it('resolves bundled OpenCode CLI before PATH lookup', () => {
const bundledDir = createTempDir('openchamber-bundled-opencode-');
const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
const pathDir = createTempDir('openchamber-path-opencode-');
const pathBinary = path.join(pathDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
fs.writeFileSync(bundledBinary, '#!/bin/sh\nexit 0\n');
fs.writeFileSync(pathBinary, '#!/bin/sh\nexit 0\n');
if (process.platform !== 'win32') {
fs.chmodSync(bundledBinary, 0o755);
fs.chmodSync(pathBinary, 0o755);
}
process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR = bundledDir;
process.env.PATH = pathDir;
delete process.env.OPENCODE_BINARY;
const { runtime, state } = createRuntime({});
expect(runtime.resolveOpencodeCliPath()).toBe(bundledBinary);
expect(state.resolvedOpencodeBinarySource).toBe('bundled');
});
it('keeps explicit OpenCode binary ahead of bundled CLI', () => {
const bundledDir = createTempDir('openchamber-bundled-opencode-');
const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
const explicitDir = createTempDir('openchamber-explicit-opencode-');
const explicitBinary = path.join(explicitDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
fs.writeFileSync(bundledBinary, '#!/bin/sh\nexit 0\n');
fs.writeFileSync(explicitBinary, '#!/bin/sh\nexit 0\n');
if (process.platform !== 'win32') {
fs.chmodSync(bundledBinary, 0o755);
fs.chmodSync(explicitBinary, 0o755);
}
process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR = bundledDir;
process.env.OPENCODE_BINARY = explicitBinary;
const { runtime, state } = createRuntime({});
expect(runtime.resolveOpencodeCliPath()).toBe(explicitBinary);
expect(state.resolvedOpencodeBinarySource).toBe('env');
});
it('resolves bundled OpenCode CLI from Electron resourcesPath', () => {
const resourcesPath = createTempDir('openchamber-resources-');
const bundledDir = path.join(resourcesPath, 'opencode-cli');
const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
fs.mkdirSync(bundledDir, { recursive: true });
fs.writeFileSync(bundledBinary, '#!/bin/sh\nexit 0\n');
if (process.platform !== 'win32') {
fs.chmodSync(bundledBinary, 0o755);
}
Object.defineProperty(process, 'resourcesPath', {
configurable: true,
value: resourcesPath,
});
process.env.PATH = createTempDir('openchamber-empty-path-');
delete process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR;
delete process.env.OPENCODE_BINARY;
const { runtime, state } = createRuntime({});
expect(runtime.resolveOpencodeCliPath()).toBe(bundledBinary);
expect(state.resolvedOpencodeBinarySource).toBe('bundled');
});
itIf(process.platform === 'darwin')('rejects known macOS OpenCode app bundle executable paths', async () => {
const { runtime } = createRuntime({ opencodeBinary: '/Applications/OpenCode.app/Contents/MacOS/OpenCode' });
@@ -41,6 +41,25 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
return trimmed || null;
};
const isBundledOpenCodeBinaryActive = async () => {
const settings = await readSettingsFromDiskMigrated();
const resolution = await getOpenCodeResolutionSnapshot(settings);
return resolution?.source === 'bundled' || resolution?.detectedSourceNow === 'bundled';
};
const readOpenCodeCurrentVersion = async () => {
const healthResponse = await fetch(buildOpenCodeUrl('/global/health', ''), {
method: 'GET',
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
});
const health = await healthResponse.json().catch(() => null);
if (!healthResponse.ok) {
return { ok: false, status: healthResponse.status, error: health?.error || healthResponse.statusText };
}
const currentVersion = typeof health?.version === 'string' ? health.version.replace(/^v/, '') : null;
return { ok: true, currentVersion };
};
const parseVersionForComparison = (value) => {
const normalized = String(value || '').replace(/^v/, '').split('+')[0];
const prereleaseIndex = normalized.indexOf('-');
@@ -136,6 +155,13 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
app.post('/api/opencode/upgrade', async (req, res) => {
try {
if (await isBundledOpenCodeBinaryActive()) {
return res.status(409).json({
success: false,
error: 'OpenCode is bundled with OpenChamber Desktop and cannot be upgraded separately.',
});
}
const target = typeof req.body?.target === 'string' && req.body.target.trim().length > 0
? req.body.target.trim()
: undefined;
@@ -180,6 +206,16 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
app.get('/api/opencode/upgrade-status', async (_req, res) => {
try {
if (await isBundledOpenCodeBinaryActive()) {
const current = await readOpenCodeCurrentVersion().catch(() => ({ ok: false, currentVersion: null }));
return res.json({
available: false,
currentVersion: current.ok ? current.currentVersion : null,
latestVersion: null,
source: 'bundled',
});
}
const [healthResponse, latestVersion] = await Promise.all([
fetch(buildOpenCodeUrl('/global/health', ''), {
method: 'GET',