* feat: add Windows ARM64 support with x64-baseline CLI workaround Windows ARM64 native opencode.exe fails with a Bun FFI/TinyCC dlopen error (anomalyco/opencode#19130). As a temporary workaround: - Bundle x64-baseline OpenCode CLI on ARM64 instead of native ARM64 (prepare-opencode-cli.mjs, env-runtime.js) - Disable OpenCode self-upgrade on ARM64 in server, VS Code, and UI (upgrade-capability.js, opencode-upgrade-runtime.ts, useUIStore.ts, OpenCodeCliSettings.tsx, search.ts, SettingsView.tsx, platform.ts) - Add ARM64 Windows cross-compile builds to release and smoke workflows (release.yml, release-desktop-smoke.yml) - Refactor Windows latest.yml to use combine-electron-manifests pattern matching macOS, since two arches now produce per-arch manifests The CI ARM64 build itself is permanent; only the x64-baseline CLI bundling and upgrade disablement are temporary and should be reverted when the upstream issue is resolved. * fix(desktop): select Windows updater by architecture --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
194 lines
7.5 KiB
JavaScript
194 lines
7.5 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';
|
|
import { resolveTargetArchitecture } from './target-architecture.mjs';
|
|
|
|
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 artifactForPlatform = (platform, targetArchitecture) => {
|
|
const arch = targetArchitecture.opencode;
|
|
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') {
|
|
// TEMPORARY WORKAROUND — Windows ARM64: native opencode.exe fails with a Bun
|
|
// FFI/TinyCC dlopen error (https://github.com/anomalyco/opencode/issues/19130).
|
|
// Bundle x64-baseline instead (runs under x64 emulation); OpenCode self-upgrade
|
|
// is disabled elsewhere so it can't overwrite with the broken ARM64 build.
|
|
// Remove this block and restore the original below when the upstream issue
|
|
// is resolved.
|
|
// --- ORIGINAL (restore when ARM64 is fixed) ---
|
|
// 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' };
|
|
// --- END ORIGINAL ---
|
|
if (arch === 'arm64') return { name: 'opencode-windows-x64-baseline.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 targetArchitecture = resolveTargetArchitecture();
|
|
const artifact = artifactForPlatform(process.platform, targetArchitecture);
|
|
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}-${targetArchitecture.opencode}`);
|
|
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);
|
|
});
|