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
@@ -37,6 +37,20 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Get bundled OpenCode CLI version
id: opencode_cli_version
run: |
VERSION=$(node -p "require('./package.json').dependencies['@opencode-ai/sdk']")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Cache bundled OpenCode CLI artifact
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: packages/electron/.cache/opencode-cli
key: opencode-cli-${{ runner.os }}-arm64-${{ steps.opencode_cli_version.outputs.version }}
restore-keys: |
opencode-cli-${{ runner.os }}-arm64-
- name: Install Apple Certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
@@ -68,9 +82,12 @@ jobs:
ELECTRON_BUILDER_ARCH: arm64
run: |
bun run build:web-assets
bun run prepare:opencode-cli
bun run verify:opencode-cli
bun run bundle:main
bun run rebuild:native
./node_modules/.bin/electron-builder --mac --arm64 --publish=never
bun run verify:opencode-cli:packaged
- name: Prepare DMG artifact
run: |
+46 -2
View File
@@ -70,6 +70,20 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Get bundled OpenCode CLI version
id: opencode_cli_version
run: |
VERSION=$(node -p "require('./package.json').dependencies['@opencode-ai/sdk']")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Cache bundled OpenCode CLI artifact
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: packages/electron/.cache/opencode-cli
key: opencode-cli-${{ runner.os }}-${{ matrix.arch }}-${{ steps.opencode_cli_version.outputs.version }}
restore-keys: |
opencode-cli-${{ runner.os }}-${{ matrix.arch }}-
- name: Install Apple Certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
@@ -101,12 +115,15 @@ jobs:
ELECTRON_BUILDER_ARCH: ${{ matrix.arch }}
run: |
bun run build:web-assets
bun run prepare:opencode-cli
bun run verify:opencode-cli
bun run bundle:main
# npmRebuild=false in package.json, so electron-builder won't
# recompile native deps on its own. Rebuild against the target
# Electron ABI before packaging, matching the release workflow.
bun run rebuild:native
bunx electron-builder --mac --${{ matrix.arch }} --publish=never
bun run verify:opencode-cli:packaged
- name: Verify signature + entitlements + notarization
run: |
@@ -165,7 +182,10 @@ jobs:
build-windows-electron:
if: ${{ inputs.build_windows }}
name: Build Windows Electron (x64)
runs-on: windows-latest
# Match the production release workflow. windows-latest currently resolves
# to a runner with Visual Studio 18, which this Electron/node-gyp stack does
# not detect correctly.
runs-on: windows-2022
strategy:
fail-fast: false
matrix:
@@ -191,10 +211,32 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Get bundled OpenCode CLI version
id: opencode_cli_version
shell: bash
run: |
VERSION=$(node -p "require('./package.json').dependencies['@opencode-ai/sdk']")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Cache bundled OpenCode CLI artifact
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: packages/electron/.cache/opencode-cli
key: opencode-cli-${{ runner.os }}-${{ matrix.arch }}-${{ steps.opencode_cli_version.outputs.version }}
restore-keys: |
opencode-cli-${{ runner.os }}-${{ matrix.arch }}-
- name: Build web assets
working-directory: packages/electron
run: bun run build:web-assets
- name: Prepare bundled OpenCode CLI
working-directory: packages/electron
shell: bash
run: |
bun run prepare:opencode-cli
bun run verify:opencode-cli
- name: Bundle main process
working-directory: packages/electron
run: bun run bundle:main
@@ -210,7 +252,9 @@ jobs:
- name: Build Windows app
working-directory: packages/electron
shell: bash
run: node ./scripts/package.mjs --win --${{ matrix.arch }} --publish=never
run: |
node ./scripts/package.mjs --win --${{ matrix.arch }} --publish=never
bun run verify:opencode-cli:packaged
- name: Upload Windows installable artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+41 -1
View File
@@ -146,6 +146,20 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Get bundled OpenCode CLI version
id: opencode_cli_version
run: |
VERSION=$(node -p "require('./package.json').dependencies['@opencode-ai/sdk']")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Cache bundled OpenCode CLI artifact
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: packages/electron/.cache/opencode-cli
key: opencode-cli-${{ runner.os }}-${{ matrix.arch }}-${{ steps.opencode_cli_version.outputs.version }}
restore-keys: |
opencode-cli-${{ runner.os }}-${{ matrix.arch }}-
- name: Install Apple Certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
@@ -179,6 +193,8 @@ jobs:
ELECTRON_BUILDER_ARCH: ${{ matrix.arch }}
run: |
bun run build:web-assets
bun run prepare:opencode-cli
bun run verify:opencode-cli
bun run bundle:main
# npmRebuild=false in package.json, so electron-builder won't
# recompile native deps on its own — we must rebuild against the
@@ -186,6 +202,7 @@ jobs:
# node-pty/bun-pty crash on require inside the packaged app.
bun run rebuild:native
bunx electron-builder --mac --${{ matrix.arch }} --publish=never
bun run verify:opencode-cli:packaged
- name: Verify signature + entitlements + notarization
run: |
@@ -275,10 +292,31 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Get bundled OpenCode CLI version
id: opencode_cli_version
run: |
VERSION=$(node -p "require('./package.json').dependencies['@opencode-ai/sdk']")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Cache bundled OpenCode CLI artifact
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: packages/electron/.cache/opencode-cli
key: opencode-cli-${{ runner.os }}-${{ matrix.arch }}-${{ steps.opencode_cli_version.outputs.version }}
restore-keys: |
opencode-cli-${{ runner.os }}-${{ matrix.arch }}-
- name: Build web assets
working-directory: packages/electron
run: bun run build:web-assets
- name: Prepare bundled OpenCode CLI
working-directory: packages/electron
shell: bash
run: |
bun run prepare:opencode-cli
bun run verify:opencode-cli
- name: Bundle main process
working-directory: packages/electron
run: bun run bundle:main
@@ -294,7 +332,9 @@ jobs:
- name: Build Windows app
working-directory: packages/electron
shell: bash
run: node ./scripts/package.mjs --win --${{ matrix.arch }} --publish=never
run: |
node ./scripts/package.mjs --win --${{ matrix.arch }} --publish=never
bun run verify:opencode-cli:packaged
- name: Upload installer to release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
+1 -1
View File
@@ -86,7 +86,7 @@
## Quick Start
> **Prerequisite:** [OpenCode CLI](https://opencode.ai) installed.
> **Prerequisite:** Desktop bundles the matching OpenCode CLI. CLI/Web and VS Code use your installed [OpenCode CLI](https://opencode.ai).
### **Desktop (macOS + Windows)**
Download from [Releases](https://github.com/btriapitsyn/openchamber/releases).
+4 -4
View File
@@ -30,7 +30,7 @@
"@heroui/theme": "^2.4.23",
"@lezer/highlight": "^1.2.3",
"@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "^1.17.12",
"@opencode-ai/sdk": "1.17.12",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -167,7 +167,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@lezer/highlight": "^1.2.3",
"@opencode-ai/sdk": "^1.17.12",
"@opencode-ai/sdk": "1.17.12",
"@pierre/diffs": "1.3.0-beta.6",
"@replit/codemirror-vim": "^6.3.0",
"@simplewebauthn/browser": "13.3.0",
@@ -239,7 +239,7 @@
"version": "1.13.8",
"dependencies": {
"@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "^1.17.12",
"@opencode-ai/sdk": "1.17.12",
"adm-zip": "^0.5.16",
"jsonc-parser": "^3.3.1",
"react": "^19.1.1",
@@ -266,7 +266,7 @@
"dependencies": {
"@clack/prompts": "^1.1.0",
"@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "^1.17.12",
"@opencode-ai/sdk": "1.17.12",
"@simplewebauthn/server": "13.3.1",
"adm-zip": "^0.5.16",
"better-sqlite3": "^12.10.0",
+1 -1
View File
@@ -107,7 +107,7 @@
"@heroui/theme": "^2.4.23",
"@lezer/highlight": "^1.2.3",
"@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "^1.17.12",
"@opencode-ai/sdk": "1.17.12",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
+3
View File
@@ -8,6 +8,9 @@ dist-bundle/
# Generated packaging resources
resources/web-dist/
resources/sidecar/
resources/opencode-cli/*
!resources/opencode-cli/.gitkeep
.cache/
# OS-specific
.DS_Store
+18 -3
View File
@@ -21,6 +21,7 @@ The preload bridge exposes desktop-only APIs to the web UI through `window.__OPE
| `ssh-manager.mjs` | SSH host import, connection lifecycle, tunnel/port forwarding helpers |
| `scripts/electron-dev.mjs` | Desktop dev launcher with Vite HMR support |
| `scripts/build-web-assets.mjs` | Builds `packages/web` and stages UI assets into `resources/web-dist` |
| `scripts/prepare-opencode-cli.mjs` | Downloads and stages the pinned OpenCode CLI into `resources/opencode-cli` |
| `scripts/bundle-main.mjs` | Bundles Electron main code into `dist-bundle/main.mjs` for packaging |
| `scripts/rebuild-native.mjs` | Rebuilds native modules against the Electron runtime |
| `scripts/package.mjs` | Runs `electron-builder`, with unsigned Windows builds when signing env is missing |
@@ -58,9 +59,10 @@ bun run electron:build
That runs, in order:
1. `build:web-assets` to build the web UI and copy it into `packages/electron/resources/web-dist`.
2. `bundle:main` to create `packages/electron/dist-bundle/main.mjs`.
3. `rebuild:native` to rebuild native modules for Electron.
4. `package.mjs` to run `electron-builder`.
2. `prepare:opencode-cli` to download/cache the pinned OpenCode CLI and copy it into `packages/electron/resources/opencode-cli`.
3. `bundle:main` to create `packages/electron/dist-bundle/main.mjs`.
4. `rebuild:native` to rebuild native modules for Electron.
5. `package.mjs` to run `electron-builder`.
Build output goes to `packages/electron/dist`.
@@ -74,6 +76,18 @@ Windows packaging needs NSIS support through `electron-builder`. If no Windows s
The package supports macOS and Windows desktop features. Some native discovery helpers are platform-specific. For example, app icon fetching and app filtering currently only work on macOS, while opening files in installed apps works on macOS and Windows.
## Bundled OpenCode CLI
Packaged Desktop builds include the official OpenCode CLI that matches the pinned `@opencode-ai/sdk` version in the root `package.json`. `prepare:opencode-cli` downloads the platform-specific release artifact, caches it under `packages/electron/.cache/opencode-cli`, stages `opencode` or `opencode.exe` into `resources/opencode-cli`, and verifies `opencode --version` before packaging. Re-running the step is fast when the staged binary already matches the pinned version.
Managed local Desktop startup prefers OpenCode binaries in this order:
1. Explicit overrides: `settings.opencodeBinary`, `OPENCODE_BINARY`, `OPENCODE_PATH`, `OPENCHAMBER_OPENCODE_PATH`, or `OPENCHAMBER_OPENCODE_BIN`.
2. The bundled Desktop CLI in `process.resourcesPath/opencode-cli`.
3. System installs discovered from PATH and known npm/Bun/Scoop/Chocolatey locations.
Use an explicit override when testing a different OpenCode CLI build or when a user needs to point Desktop at a custom binary. The configured path must point to the standalone CLI, not the OpenCode Desktop app executable.
## Common Env Vars
| Variable | Use |
@@ -83,6 +97,7 @@ The package supports macOS and Windows desktop features. Some native discovery h
| `OPENCHAMBER_HMR_UI_PORT` | Preferred Vite UI port for desktop dev, default `5173` |
| `OPENCHAMBER_HMR_API_PORT` | Preferred API port for desktop dev, default `3901` |
| `OPENCHAMBER_RUNTIME=desktop` | Set by Electron before starting the web server |
| `OPENCHAMBER_OPENCODE_CLI_VERSION` | Optional packaging override for the bundled OpenCode CLI version; defaults to the pinned root `@opencode-ai/sdk` version |
| `OPENCHAMBER_DESKTOP_NOTIFY=true` | Enables desktop notification flow in the web server |
| `OPENCHAMBER_SKIP_API_COMPRESSION=true` | Defaulted by Desktop to reduce local CPU overhead |
| `OPENCODE_HOST` / `OPENCODE_PORT` / `OPENCODE_SKIP_START` | Connect Desktop to an external OpenCode server instead of starting one locally |
+8 -1
View File
@@ -27,10 +27,13 @@
"dev": "node ./scripts/electron-dev.mjs",
"build:web-assets": "node ./scripts/build-web-assets.mjs",
"build": "bun -e \"process.exit(0)\"",
"prepare:opencode-cli": "node ./scripts/prepare-opencode-cli.mjs",
"verify:opencode-cli": "node ./scripts/verify-opencode-cli.mjs --staged",
"verify:opencode-cli:packaged": "node ./scripts/verify-opencode-cli.mjs --packaged",
"bundle:main": "bun ./scripts/bundle-main.mjs",
"generate:macos-icon": "node ./scripts/generate-macos-icon-assets.cjs",
"rebuild:native": "node ./scripts/rebuild-native.mjs",
"package": "bun run build:web-assets && bun run bundle:main && bun run rebuild:native && node ./scripts/package.mjs",
"package": "bun run build:web-assets && bun run prepare:opencode-cli && bun run bundle:main && bun run rebuild:native && node ./scripts/package.mjs",
"finalize:latest-yml": "node ./scripts/finalize-latest-yml.mjs",
"type-check": "node --check ./main.mjs && node --check ./preload.mjs",
"lint": "node -e \"process.exit(0)\""
@@ -54,6 +57,10 @@
{
"from": "resources/icons/tray",
"to": "icons/tray"
},
{
"from": "resources/opencode-cli",
"to": "opencode-cli"
}
],
"afterPack": "scripts/after-pack.cjs",
@@ -0,0 +1,181 @@
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const electronRoot = path.resolve(__dirname, '..');
const workspaceRoot = path.resolve(electronRoot, '../..');
const outputDir = path.join(electronRoot, 'resources', 'opencode-cli');
const cacheRoot = path.join(electronRoot, '.cache', 'opencode-cli');
const rootPackagePath = path.join(workspaceRoot, 'package.json');
const run = (command, args, options = {}) => {
const result = spawnSync(command, args, {
encoding: 'utf8',
stdio: options.stdio || 'pipe',
windowsHide: true,
...options,
});
if (result.status !== 0) {
const stderr = result.stderr ? `\n${result.stderr.trim()}` : '';
const stdout = result.stdout ? `\n${result.stdout.trim()}` : '';
throw new Error(`Command failed: ${command} ${args.join(' ')}${stderr}${stdout}`);
}
return result;
};
const readPinnedSdkVersion = () => {
const pkg = JSON.parse(fs.readFileSync(rootPackagePath, 'utf8'));
const version = pkg.dependencies?.['@opencode-ai/sdk'];
if (typeof version !== 'string' || !version.trim()) {
throw new Error('Missing @opencode-ai/sdk dependency in root package.json');
}
const trimmed = version.trim();
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(trimmed)) {
throw new Error(`@opencode-ai/sdk must be pinned to an exact version for desktop CLI bundling, got: ${trimmed}`);
}
return trimmed;
};
const artifactForCurrentPlatform = () => {
const { platform, arch } = process;
if (platform === 'darwin') {
if (arch === 'arm64') return { name: 'opencode-darwin-arm64.zip', binary: 'opencode' };
if (arch === 'x64') return { name: 'opencode-darwin-x64-baseline.zip', binary: 'opencode' };
}
if (platform === 'win32') {
if (arch === 'arm64') return { name: 'opencode-windows-arm64.zip', binary: 'opencode.exe' };
if (arch === 'x64') return { name: 'opencode-windows-x64-baseline.zip', binary: 'opencode.exe' };
}
if (platform === 'linux') {
if (arch === 'arm64') return { name: 'opencode-linux-arm64.tar.gz', binary: 'opencode' };
if (arch === 'x64') return { name: 'opencode-linux-x64-baseline.tar.gz', binary: 'opencode' };
}
throw new Error(`No OpenCode CLI artifact mapping for ${platform}/${arch}`);
};
const outputBinaryPath = (binaryName) => path.join(outputDir, binaryName);
const readBinaryVersion = (binaryPath) => {
if (!fs.existsSync(binaryPath)) return null;
const result = spawnSync(binaryPath, ['--version'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 15000,
windowsHide: true,
});
if (result.status !== 0) return null;
return (result.stdout || '').trim().split(/\s+/)[0] || null;
};
const ensureExecutable = (filePath) => {
if (process.platform !== 'win32') {
fs.chmodSync(filePath, 0o755);
}
};
const download = async (url, destination) => {
fs.mkdirSync(path.dirname(destination), { recursive: true });
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`);
}
const temp = `${destination}.tmp`;
fs.writeFileSync(temp, Buffer.from(await response.arrayBuffer()));
fs.renameSync(temp, destination);
};
const extractArchive = (archivePath, destination) => {
fs.rmSync(destination, { recursive: true, force: true });
fs.mkdirSync(destination, { recursive: true });
if (archivePath.endsWith('.zip')) {
if (process.platform === 'win32') {
run('powershell.exe', [
'-NoLogo',
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-Command',
`Expand-Archive -LiteralPath ${JSON.stringify(archivePath)} -DestinationPath ${JSON.stringify(destination)} -Force`,
]);
return;
}
run('unzip', ['-q', archivePath, '-d', destination]);
return;
}
if (archivePath.endsWith('.tar.gz')) {
run('tar', ['-xzf', archivePath, '-C', destination]);
return;
}
throw new Error(`Unsupported OpenCode CLI archive: ${archivePath}`);
};
const findBinary = (root, binaryName) => {
const entries = fs.readdirSync(root, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(root, entry.name);
if (entry.isFile() && entry.name.toLowerCase() === binaryName.toLowerCase()) {
return fullPath;
}
if (entry.isDirectory()) {
const found = findBinary(fullPath, binaryName);
if (found) return found;
}
}
return null;
};
const main = async () => {
const version = process.env.OPENCHAMBER_OPENCODE_CLI_VERSION || readPinnedSdkVersion();
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error(`Invalid OpenCode CLI version: ${version}`);
}
const artifact = artifactForCurrentPlatform();
const outputBinary = outputBinaryPath(artifact.binary);
const existingVersion = readBinaryVersion(outputBinary);
if (existingVersion === version) {
console.log(`[electron] bundled OpenCode CLI already prepared: ${outputBinary} (${version})`);
return;
}
const cacheDir = path.join(cacheRoot, version, `${process.platform}-${process.arch}`);
const archivePath = path.join(cacheDir, artifact.name);
const url = `https://github.com/anomalyco/opencode/releases/download/v${version}/${artifact.name}`;
if (!fs.existsSync(archivePath)) {
console.log(`[electron] downloading OpenCode CLI ${version}: ${artifact.name}`);
await download(url, archivePath);
} else {
console.log(`[electron] using cached OpenCode CLI archive: ${archivePath}`);
}
const extractDir = path.join(cacheDir, 'extract');
extractArchive(archivePath, extractDir);
const extractedBinary = findBinary(extractDir, artifact.binary);
if (!extractedBinary) {
throw new Error(`Archive ${archivePath} did not contain ${artifact.binary}`);
}
fs.mkdirSync(outputDir, { recursive: true });
for (const entry of fs.readdirSync(outputDir)) {
if (entry === '.gitkeep') continue;
fs.rmSync(path.join(outputDir, entry), { recursive: true, force: true });
}
fs.copyFileSync(extractedBinary, outputBinary);
ensureExecutable(outputBinary);
const preparedVersion = readBinaryVersion(outputBinary);
if (preparedVersion !== version) {
throw new Error(`Prepared OpenCode CLI version mismatch: expected ${version}, got ${preparedVersion || 'unknown'}`);
}
console.log(`[electron] prepared OpenCode CLI ${version}: ${outputBinary}`);
};
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
@@ -0,0 +1,107 @@
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const electronRoot = path.resolve(__dirname, '..');
const workspaceRoot = path.resolve(electronRoot, '../..');
const readExpectedVersion = () => {
const pkg = JSON.parse(fs.readFileSync(path.join(workspaceRoot, 'package.json'), 'utf8'));
const version = pkg.dependencies?.['@opencode-ai/sdk'];
if (typeof version !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error(`Expected root @opencode-ai/sdk to be pinned to an exact version, got: ${version || '(missing)'}`);
}
return version;
};
const binaryName = () => process.platform === 'win32' ? 'opencode.exe' : 'opencode';
const runVersion = (binaryPath) => {
const result = spawnSync(binaryPath, ['--version'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 15000,
windowsHide: true,
});
if (result.status !== 0) {
const stderr = result.stderr ? `\n${result.stderr.trim()}` : '';
const stdout = result.stdout ? `\n${result.stdout.trim()}` : '';
throw new Error(`Failed to run bundled OpenCode CLI: ${binaryPath}${stderr}${stdout}`);
}
return (result.stdout || '').trim().split(/\s+/)[0] || '';
};
const assertBinary = (binaryPath, expectedVersion) => {
if (!fs.existsSync(binaryPath)) {
throw new Error(`Bundled OpenCode CLI not found: ${binaryPath}`);
}
const stat = fs.statSync(binaryPath);
if (!stat.isFile()) {
throw new Error(`Bundled OpenCode CLI is not a file: ${binaryPath}`);
}
if (process.platform !== 'win32' && (stat.mode & 0o111) === 0) {
throw new Error(`Bundled OpenCode CLI is not executable: ${binaryPath}`);
}
const actualVersion = runVersion(binaryPath);
if (actualVersion !== expectedVersion) {
throw new Error(`Bundled OpenCode CLI version mismatch at ${binaryPath}: expected ${expectedVersion}, got ${actualVersion || '(empty)'}`);
}
console.log(`[electron] verified bundled OpenCode CLI ${actualVersion}: ${binaryPath}`);
};
const findPackagedBinaries = () => {
const distDir = path.join(electronRoot, 'dist');
if (!fs.existsSync(distDir)) return [];
const candidates = [];
const targetBinary = binaryName().toLowerCase();
const visit = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
visit(fullPath);
continue;
}
if (!entry.isFile() || entry.name.toLowerCase() !== targetBinary) continue;
const parent = path.basename(path.dirname(fullPath)).toLowerCase();
if (parent === 'opencode-cli') {
candidates.push(fullPath);
}
}
};
visit(distDir);
return candidates;
};
const usage = () => {
console.error('Usage: node scripts/verify-opencode-cli.mjs --staged|--packaged');
process.exit(2);
};
const main = () => {
const mode = process.argv[2];
if (mode !== '--staged' && mode !== '--packaged') usage();
const expectedVersion = readExpectedVersion();
if (mode === '--staged') {
assertBinary(path.join(electronRoot, 'resources', 'opencode-cli', binaryName()), expectedVersion);
return;
}
const packagedBinaries = findPackagedBinaries();
if (packagedBinaries.length === 0) {
throw new Error('No packaged OpenCode CLI found under packages/electron/dist');
}
for (const packagedBinary of packagedBinaries) {
assertBinary(packagedBinary, expectedVersion);
}
};
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
+1 -1
View File
@@ -43,7 +43,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@lezer/highlight": "^1.2.3",
"@opencode-ai/sdk": "^1.17.12",
"@opencode-ai/sdk": "1.17.12",
"@pierre/diffs": "1.3.0-beta.6",
"@replit/codemirror-vim": "^6.3.0",
"@simplewebauthn/browser": "13.3.0",
+53 -18
View File
@@ -43,13 +43,14 @@ import * as sessionActions from "./session-actions"
import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization"
import { openSessionFromToast } from "./session-navigation"
import { getRuntimeLiveStatusSeed, LIVE_STATUS_TTL_MS } from "./runtime-live-memory"
import { getRuntimeKey } from "@/lib/runtime-switch"
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"
import { setSessionPrefetch } from "./session-prefetch-cache"
import { listGlobalSessionPages } from "@/stores/globalSessions"
import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests"
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history"
import { getRuntimeKey } from "@/lib/runtime-switch"
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"
import { setSessionPrefetch } from "./session-prefetch-cache"
import { listGlobalSessionPages } from "@/stores/globalSessions"
import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests"
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history"
import { runtimeFetch } from "@/lib/runtime-fetch"
// ---------------------------------------------------------------------------
// Context
@@ -1644,10 +1645,44 @@ function handleEvent(
// Provider
// ---------------------------------------------------------------------------
const dispatchOpenCodeUpdateAvailable = (payload: { version: string }) => {
if (typeof window === "undefined") return
window.dispatchEvent(new CustomEvent("openchamber:opencode-update-available", { detail: payload }))
}
const dispatchOpenCodeUpdateAvailable = (payload: { version: string }) => {
if (typeof window === "undefined") return
window.dispatchEvent(new CustomEvent("openchamber:opencode-update-available", { detail: payload }))
}
let bundledOpenCodeRuntimeCache: { runtimeKey: string; promise: Promise<boolean> } | null = null
const isBundledOpenCodeRuntime = async () => {
const runtimeKey = getRuntimeKey()
if (!bundledOpenCodeRuntimeCache || bundledOpenCodeRuntimeCache.runtimeKey !== runtimeKey) {
bundledOpenCodeRuntimeCache = {
runtimeKey,
promise: runtimeFetch("/api/config/opencode-resolution", { signal: AbortSignal.timeout(4000) })
.then(async (response) => {
if (response.ok) {
const resolution = await response.json() as { source?: unknown; detectedSourceNow?: unknown }
return resolution.source === "bundled" || resolution.detectedSourceNow === "bundled"
}
const healthResponse = await runtimeFetch("/health", { signal: AbortSignal.timeout(4000) })
if (!healthResponse.ok) return false
const health = await healthResponse.json() as { opencodeBinarySource?: unknown }
return health.opencodeBinarySource === "bundled"
})
.catch(() => false),
}
}
return bundledOpenCodeRuntimeCache.promise
}
const dispatchOpenCodeUpdateAvailableUnlessBundled = (payload: { version: string }) => {
if (typeof window === "undefined") return
void isBundledOpenCodeRuntime().then((isBundled) => {
if (!isBundled) {
dispatchOpenCodeUpdateAvailable(payload)
}
})
}
export function SyncProvider(props: {
sdk: OpencodeClient
@@ -1859,13 +1894,13 @@ export function SyncProvider(props: {
lastStreamActivityAtRef.current = Date.now()
dispatchVSCodeRuntimeNotificationEvent(directory, payload)
if (payload.type === "installation.update-available") {
const version = typeof (payload.properties as { version?: unknown })?.version === "string"
? (payload.properties as { version: string }).version
: ""
if (version) {
dispatchOpenCodeUpdateAvailable({ version })
}
}
const version = typeof (payload.properties as { version?: unknown })?.version === "string"
? (payload.properties as { version: string }).version
: ""
if (version) {
dispatchOpenCodeUpdateAvailableUnlessBundled({ version })
}
}
handleEvent(directory, payload, childStores, routingIndex)
},
onReconnect: () => {
+1 -1
View File
@@ -244,7 +244,7 @@
},
"dependencies": {
"@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "^1.17.12",
"@opencode-ai/sdk": "1.17.12",
"adm-zip": "^0.5.16",
"jsonc-parser": "^3.3.1",
"react": "^19.1.1",
+1 -1
View File
@@ -25,7 +25,7 @@
"dependencies": {
"@clack/prompts": "^1.1.0",
"@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "^1.17.12",
"@opencode-ai/sdk": "1.17.12",
"@simplewebauthn/server": "13.3.1",
"adm-zip": "^0.5.16",
"better-sqlite3": "^12.10.0",
@@ -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',
+13
View File
@@ -53,6 +53,7 @@ Actions:
start-mobile-dev Start mobile app with dev server live reload
mobile-tools Mobile build/sync/deploy helper menu
start-electron-app Start Electron app in dev mode
prepare-opencode-cli Download/cache bundled OpenCode CLI for Electron
build-electron-app Build Electron app artifacts
start-vscode-extension Build + launch VS Code extension host
install-vscode-extension-local Build, package, and install local VSIX
@@ -180,6 +181,8 @@ function normalizeAction(action = '') {
'mobile-menu': 'mobile-tools',
'remote-deploy-web': 'remote-deploy-web',
'electron-dev': 'start-electron-app',
'opencode-cli': 'prepare-opencode-cli',
'electron-opencode-cli': 'prepare-opencode-cli',
'electron-build': 'build-electron-app',
'vscode-dev': 'start-vscode-extension',
'vscode-install-local': 'install-vscode-extension-local',
@@ -474,10 +477,16 @@ async function mobileTools(options, config) {
}
function startElectronApp() {
prepareOpenCodeCli();
run('bun', ['run', 'electron:dev']);
}
function prepareOpenCodeCli() {
step('Preparing bundled OpenCode CLI', () => run('bun', ['--filter', '@openchamber/electron', 'prepare:opencode-cli']));
}
function buildElectronApp() {
prepareOpenCodeCli();
run('bun', ['run', 'electron:build'], { env: { CSC_IDENTITY_AUTO_DISCOVERY: 'false' } });
const distDir = path.join(repoRoot, 'packages/electron/dist');
if (!existsSync(distDir) || !isMac) return;
@@ -543,6 +552,7 @@ async function chooseAction(config) {
{ value: 'start-mobile-dev', label: 'Start mobile dev' },
{ value: 'mobile-tools', label: 'Mobile tools' },
{ value: 'start-electron-app', label: 'Start Electron app' },
{ value: 'prepare-opencode-cli', label: 'Prepare bundled OpenCode CLI' },
{ value: 'build-electron-app', label: 'Build Electron app' },
{ value: 'start-vscode-extension', label: 'Start VS Code extension' },
{ value: 'install-vscode-extension-local', label: 'Install VS Code extension locally' },
@@ -589,6 +599,9 @@ async function main() {
case 'start-electron-app':
startElectronApp();
break;
case 'prepare-opencode-cli':
prepareOpenCodeCli();
break;
case 'build-electron-app':
buildElectronApp();
break;