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.
182 lines
6.6 KiB
JavaScript
182 lines
6.6 KiB
JavaScript
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);
|
|
});
|