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;