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:
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user