From 33ecd628bdff7e6fb6f752df584f6791314e0672 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 2 Jul 2026 17:43:33 +0300 Subject: [PATCH] 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. --- .github/workflows/build-macos-arm64-dmg.yml | 17 ++ .github/workflows/release-desktop-smoke.yml | 48 ++++- .github/workflows/release.yml | 42 +++- README.md | 2 +- bun.lock | 8 +- package.json | 2 +- packages/electron/.gitignore | 3 + packages/electron/README.md | 21 +- packages/electron/package.json | 9 +- .../electron/resources/opencode-cli/.gitkeep | 0 .../electron/scripts/prepare-opencode-cli.mjs | 181 ++++++++++++++++++ .../electron/scripts/verify-opencode-cli.mjs | 107 +++++++++++ packages/ui/package.json | 2 +- packages/ui/src/sync/sync-context.tsx | 71 +++++-- packages/vscode/package.json | 2 +- packages/web/package.json | 2 +- .../web/server/lib/opencode/env-runtime.js | 34 ++++ .../server/lib/opencode/env-runtime.test.js | 74 +++++++ packages/web/server/lib/opencode/routes.js | 36 ++++ scripts/oc-dev.mjs | 13 ++ 20 files changed, 640 insertions(+), 34 deletions(-) create mode 100644 packages/electron/resources/opencode-cli/.gitkeep create mode 100644 packages/electron/scripts/prepare-opencode-cli.mjs create mode 100644 packages/electron/scripts/verify-opencode-cli.mjs diff --git a/.github/workflows/build-macos-arm64-dmg.yml b/.github/workflows/build-macos-arm64-dmg.yml index c819b9c9..19479538 100644 --- a/.github/workflows/build-macos-arm64-dmg.yml +++ b/.github/workflows/build-macos-arm64-dmg.yml @@ -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: | diff --git a/.github/workflows/release-desktop-smoke.yml b/.github/workflows/release-desktop-smoke.yml index 022292a6..7686eb7c 100644 --- a/.github/workflows/release-desktop-smoke.yml +++ b/.github/workflows/release-desktop-smoke.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 717be367..ef66d355 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/README.md b/README.md index 08ada712..f01b25be 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/bun.lock b/bun.lock index f4626363..e49df436 100644 --- a/bun.lock +++ b/bun.lock @@ -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", diff --git a/package.json b/package.json index bd58e5e7..91851ef8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/electron/.gitignore b/packages/electron/.gitignore index 2f8dd8d3..9827bb50 100644 --- a/packages/electron/.gitignore +++ b/packages/electron/.gitignore @@ -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 diff --git a/packages/electron/README.md b/packages/electron/README.md index d722c878..5939a916 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -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 | diff --git a/packages/electron/package.json b/packages/electron/package.json index b7208b28..a1d9514e 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -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", diff --git a/packages/electron/resources/opencode-cli/.gitkeep b/packages/electron/resources/opencode-cli/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/packages/electron/scripts/prepare-opencode-cli.mjs b/packages/electron/scripts/prepare-opencode-cli.mjs new file mode 100644 index 00000000..d7f5ede5 --- /dev/null +++ b/packages/electron/scripts/prepare-opencode-cli.mjs @@ -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); +}); diff --git a/packages/electron/scripts/verify-opencode-cli.mjs b/packages/electron/scripts/verify-opencode-cli.mjs new file mode 100644 index 00000000..5d9da4f1 --- /dev/null +++ b/packages/electron/scripts/verify-opencode-cli.mjs @@ -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); +} diff --git a/packages/ui/package.json b/packages/ui/package.json index daef0b33..c68903e7 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 3e614210..89ace18f 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -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 } | 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: () => { diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 20f556a5..53165781 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -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", diff --git a/packages/web/package.json b/packages/web/package.json index 19911bf9..65b4438d 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -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", diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index bee62da1..fabfa0a4 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -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(); diff --git a/packages/web/server/lib/opencode/env-runtime.test.js b/packages/web/server/lib/opencode/env-runtime.test.js index 0e432a11..f5c5c7c5 100644 --- a/packages/web/server/lib/opencode/env-runtime.test.js +++ b/packages/web/server/lib/opencode/env-runtime.test.js @@ -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' }); diff --git a/packages/web/server/lib/opencode/routes.js b/packages/web/server/lib/opencode/routes.js index f7c54db5..08cdb0ac 100644 --- a/packages/web/server/lib/opencode/routes.js +++ b/packages/web/server/lib/opencode/routes.js @@ -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', diff --git a/scripts/oc-dev.mjs b/scripts/oc-dev.mjs index 94170fd2..ade8433e 100755 --- a/scripts/oc-dev.mjs +++ b/scripts/oc-dev.mjs @@ -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;