fix: 修复 OpenCode 运行时启动与本地打包
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -9,12 +10,33 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const require = createRequire(import.meta.url);
|
||||
const outputDir = path.join(root, 'build', 'opencode-ai');
|
||||
const rootPackageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
||||
|
||||
const targetArgs = process.argv.slice(2);
|
||||
for (const arg of targetArgs) {
|
||||
if (!/^--(?:platform|arch)=\S+$/.test(arg)) {
|
||||
throw new Error(`Unknown bundle target option: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
function targetOption(name, fallback) {
|
||||
const prefix = `--${name}=`;
|
||||
const option = targetArgs.find((arg) => arg.startsWith(prefix));
|
||||
return option ? option.slice(prefix.length) : fallback;
|
||||
}
|
||||
|
||||
const targetPlatform = targetOption('platform', process.platform);
|
||||
const targetArch = targetOption('arch', process.arch);
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[bundle-opencode] ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (targetPlatform !== process.platform) {
|
||||
fail(`Cross-OS OpenCode staging is not supported: host ${process.platform}, target ${targetPlatform}.`);
|
||||
}
|
||||
|
||||
function resolvePackageRoot(packageName) {
|
||||
try {
|
||||
return path.dirname(require.resolve(`${packageName}/package.json`, {
|
||||
@@ -32,6 +54,13 @@ if (!packageRoot) {
|
||||
|
||||
const packageJsonPath = path.join(packageRoot, 'package.json');
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
const declaredVersion = rootPackageJson.devDependencies?.['opencode-ai']
|
||||
?? rootPackageJson.dependencies?.['opencode-ai'];
|
||||
if (declaredVersion !== packageJson.version) {
|
||||
fail(
|
||||
`The opencode-ai dependency must be pinned exactly to the bundled version. Declared ${declaredVersion ?? 'missing'}, installed ${packageJson.version ?? 'unknown'}.`,
|
||||
);
|
||||
}
|
||||
const packageRequire = createRequire(packageJsonPath);
|
||||
const bin = packageJson.bin;
|
||||
const hasOpencodeBin = typeof bin === 'string'
|
||||
@@ -53,11 +82,6 @@ fs.cpSync(packageRoot, outputDir, {
|
||||
},
|
||||
});
|
||||
|
||||
const runtimeDependencies = {
|
||||
...(packageJson.dependencies && typeof packageJson.dependencies === 'object' ? packageJson.dependencies : {}),
|
||||
...(packageJson.optionalDependencies && typeof packageJson.optionalDependencies === 'object' ? packageJson.optionalDependencies : {}),
|
||||
};
|
||||
|
||||
const platformMap = {
|
||||
darwin: 'darwin',
|
||||
linux: 'linux',
|
||||
@@ -69,30 +93,9 @@ const archMap = {
|
||||
arm: 'arm',
|
||||
};
|
||||
|
||||
function copyRuntimeDependency(packageName) {
|
||||
let dependencyPackageJsonPath;
|
||||
try {
|
||||
dependencyPackageJsonPath = packageRequire.resolve(`${packageName}/package.json`);
|
||||
} catch {
|
||||
console.warn(`[bundle-opencode] optional dependency not installed: ${packageName}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const dependencyRoot = path.dirname(dependencyPackageJsonPath);
|
||||
const outputPackageDir = path.join(outputDir, 'node_modules', ...packageName.split('/'));
|
||||
fs.rmSync(outputPackageDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(path.dirname(outputPackageDir), { recursive: true });
|
||||
fs.cpSync(dependencyRoot, outputPackageDir, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
filter: (src) => !src.includes(`${path.sep}.git${path.sep}`),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function getNativePackageCandidates() {
|
||||
const platform = platformMap[process.platform] ?? process.platform;
|
||||
const arch = archMap[process.arch] ?? process.arch;
|
||||
const platform = platformMap[targetPlatform] ?? targetPlatform;
|
||||
const arch = archMap[targetArch] ?? targetArch;
|
||||
const base = `opencode-${platform}-${arch}`;
|
||||
|
||||
if (arch === 'x64') {
|
||||
@@ -103,7 +106,7 @@ function getNativePackageCandidates() {
|
||||
}
|
||||
|
||||
function bundleNativeLauncher() {
|
||||
const binaryName = process.platform === 'win32' ? 'opencode.exe' : 'opencode';
|
||||
const binaryName = targetPlatform === 'win32' ? 'opencode.exe' : 'opencode';
|
||||
const outputBinary = path.join(outputDir, 'bin', binaryName);
|
||||
|
||||
for (const packageName of getNativePackageCandidates()) {
|
||||
@@ -126,12 +129,35 @@ function bundleNativeLauncher() {
|
||||
}
|
||||
|
||||
fail(
|
||||
`Missing native opencode binary for ${process.platform}/${process.arch}. Tried ${getNativePackageCandidates().join(', ')}.`,
|
||||
`Missing native opencode binary for ${targetPlatform}/${targetArch}. Tried ${getNativePackageCandidates().join(', ')}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const copiedDependencies = Object.keys(runtimeDependencies).filter(copyRuntimeDependency);
|
||||
const nativeLauncher = bundleNativeLauncher();
|
||||
const verificationEnv = {
|
||||
...process.env,
|
||||
PATH: process.platform === 'win32'
|
||||
? [process.env.SystemRoot && path.join(process.env.SystemRoot, 'System32'), process.env.SystemRoot]
|
||||
.filter(Boolean)
|
||||
.join(path.delimiter)
|
||||
: '/usr/bin:/bin',
|
||||
};
|
||||
delete verificationEnv.NODE_PATH;
|
||||
delete verificationEnv.BUN_INSTALL;
|
||||
const versionProbe = spawnSync(nativeLauncher.outputBinary, ['--version'], {
|
||||
cwd: outputDir,
|
||||
encoding: 'utf8',
|
||||
env: verificationEnv,
|
||||
});
|
||||
if (versionProbe.error || versionProbe.status !== 0) {
|
||||
fail(
|
||||
`Bundled native launcher failed its self-contained version probe: ${versionProbe.error?.message ?? versionProbe.stderr ?? versionProbe.stdout}`,
|
||||
);
|
||||
}
|
||||
const reportedVersion = `${versionProbe.stdout ?? ''}${versionProbe.stderr ?? ''}`.trim();
|
||||
if (reportedVersion !== packageJson.version) {
|
||||
fail(`Bundled native launcher reported ${reportedVersion || 'no version'}, expected ${packageJson.version}.`);
|
||||
}
|
||||
console.log(
|
||||
`[bundle-opencode] bundled opencode-ai@${packageJson.version ?? 'unknown'} with ${copiedDependencies.length} runtime dependency package(s) and native launcher ${nativeLauncher.packageName} -> ${path.relative(root, outputDir)}`,
|
||||
`[bundle-opencode] bundled and verified self-contained opencode-ai@${packageJson.version} launcher ${nativeLauncher.packageName} -> ${path.relative(root, outputDir)}`,
|
||||
);
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
closeSync,
|
||||
createReadStream,
|
||||
existsSync,
|
||||
openSync,
|
||||
readSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
@@ -81,6 +84,37 @@ function assertEqual(actual, expected, label) {
|
||||
}
|
||||
}
|
||||
|
||||
function assertPeMachine(filePath, expectedMachine, label) {
|
||||
const handle = openSync(filePath, 'r');
|
||||
try {
|
||||
const dosHeader = Buffer.alloc(64);
|
||||
if (readSync(handle, dosHeader, 0, dosHeader.length, 0) !== dosHeader.length) {
|
||||
throw new Error(`${label}: truncated DOS header`);
|
||||
}
|
||||
if (dosHeader.toString('ascii', 0, 2) !== 'MZ') {
|
||||
throw new Error(`${label}: missing MZ signature`);
|
||||
}
|
||||
|
||||
const peOffset = dosHeader.readUInt32LE(0x3c);
|
||||
const coffHeader = Buffer.alloc(6);
|
||||
if (readSync(handle, coffHeader, 0, coffHeader.length, peOffset) !== coffHeader.length) {
|
||||
throw new Error(`${label}: truncated PE header`);
|
||||
}
|
||||
if (coffHeader.readUInt32LE(0) !== 0x00004550) {
|
||||
throw new Error(`${label}: missing PE signature`);
|
||||
}
|
||||
|
||||
const machine = coffHeader.readUInt16LE(4);
|
||||
if (machine !== expectedMachine) {
|
||||
throw new Error(
|
||||
`${label}: expected PE machine 0x${expectedMachine.toString(16)}, got 0x${machine.toString(16)}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
closeSync(handle);
|
||||
}
|
||||
}
|
||||
|
||||
function isPathInside(parentPath, candidatePath) {
|
||||
const relativePath = relative(resolve(parentPath), resolve(candidatePath));
|
||||
return relativePath !== ''
|
||||
@@ -106,12 +140,78 @@ const expectedNode = option('--expected-node', '24.15.0');
|
||||
const expectedCommit = option('--expected-commit', null);
|
||||
const expectedBuildId = option('--expected-build-id', null);
|
||||
const manifestPath = option('--manifest', null);
|
||||
const appAsarPath = join(dirname(appExecutable), 'resources', 'app.asar');
|
||||
const appAsarUnpackedPath = join(dirname(appExecutable), 'resources', 'app.asar.unpacked');
|
||||
for (const filePath of [appExecutable, installerPath]) {
|
||||
const resourcesDir = join(dirname(appExecutable), 'resources');
|
||||
const appAsarPath = join(resourcesDir, 'app.asar');
|
||||
const appAsarUnpackedPath = join(resourcesDir, 'app.asar.unpacked');
|
||||
const opencodeRuntimeDir = join(resourcesDir, 'opencode-ai');
|
||||
const opencodePackagePath = join(opencodeRuntimeDir, 'package.json');
|
||||
const opencodeExecutable = join(opencodeRuntimeDir, 'bin', 'opencode.exe');
|
||||
const pythonRuntimeDir = join(resourcesDir, 'python');
|
||||
const pythonExecutable = join(pythonRuntimeDir, 'python.exe');
|
||||
const toolsBinDir = join(resourcesDir, 'bin');
|
||||
const uvExecutable = join(toolsBinDir, 'uv.exe');
|
||||
for (const filePath of [
|
||||
appExecutable,
|
||||
installerPath,
|
||||
appAsarPath,
|
||||
opencodePackagePath,
|
||||
opencodeExecutable,
|
||||
pythonExecutable,
|
||||
uvExecutable,
|
||||
]) {
|
||||
if (!existsSync(filePath)) throw new Error(`Missing artifact: ${filePath}`);
|
||||
}
|
||||
|
||||
const PE_MACHINE_AMD64 = 0x8664;
|
||||
for (const [label, executable] of [
|
||||
['Makelore executable', appExecutable],
|
||||
['OpenCode executable', opencodeExecutable],
|
||||
['Python executable', pythonExecutable],
|
||||
['uv executable', uvExecutable],
|
||||
]) {
|
||||
assertPeMachine(executable, PE_MACHINE_AMD64, label);
|
||||
}
|
||||
|
||||
const declaredOpencodeVersion = packageJson.devDependencies?.['opencode-ai']
|
||||
?? packageJson.dependencies?.['opencode-ai'];
|
||||
const packagedOpencode = JSON.parse(readFileSync(opencodePackagePath, 'utf8'));
|
||||
assertEqual(packagedOpencode.version, declaredOpencodeVersion, 'packaged OpenCode version');
|
||||
|
||||
const systemRoot = process.env.SystemRoot ?? process.env.WINDIR ?? 'C:\\Windows';
|
||||
const installLocalProbeEnv = {
|
||||
...process.env,
|
||||
PATH: [join(systemRoot, 'System32'), systemRoot].join(';'),
|
||||
};
|
||||
for (const key of ['NODE_PATH', 'BUN_INSTALL', 'OPENCODE_BIN', 'OPENCODE_PATH', 'NIANCODE_PYTHON_PATH', 'NIANCODE_UV_PATH']) {
|
||||
delete installLocalProbeEnv[key];
|
||||
}
|
||||
const opencodeVersion = run(opencodeExecutable, ['--version'], {
|
||||
cwd: opencodeRuntimeDir,
|
||||
env: installLocalProbeEnv,
|
||||
});
|
||||
assertEqual(opencodeVersion, declaredOpencodeVersion, 'OpenCode executable version');
|
||||
const uvVersion = run(uvExecutable, ['--version'], {
|
||||
cwd: toolsBinDir,
|
||||
env: installLocalProbeEnv,
|
||||
});
|
||||
if (!/^uv\s+\d+\.\d+\.\d+/.test(uvVersion)) {
|
||||
throw new Error(`Invalid bundled uv version output: ${uvVersion}`);
|
||||
}
|
||||
const pythonProbe = JSON.parse(run(pythonExecutable, [
|
||||
'-I',
|
||||
'-c',
|
||||
'import json,pip,sqlite3,ssl,sys; print(json.dumps({"executable":sys.executable,"pip":pip.__file__,"sqlite3":sqlite3.__file__,"ssl":ssl.__file__}))',
|
||||
], {
|
||||
cwd: pythonRuntimeDir,
|
||||
env: installLocalProbeEnv,
|
||||
}));
|
||||
assertEqual(resolve(pythonProbe.executable), resolve(pythonExecutable), 'Python executable path');
|
||||
for (const [moduleName, modulePath] of Object.entries(pythonProbe).filter(([name]) => name !== 'executable')) {
|
||||
if (typeof modulePath !== 'string' || !isPathInside(pythonRuntimeDir, modulePath)) {
|
||||
throw new Error(`Python ${moduleName} was not loaded from the installation: ${modulePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
const verificationHead = run('git', ['rev-parse', 'HEAD']);
|
||||
const gitStatus = run('git', ['status', '--porcelain']);
|
||||
if (gitStatus && !args.includes('--allow-dirty')) {
|
||||
@@ -127,6 +227,8 @@ const appRequire = createRequire(join(dirname(process.execPath), 'resources', 'a
|
||||
const packagedPackage = appRequire('./package.json');
|
||||
const msgpackResolved = appRequire.resolve('msgpackr');
|
||||
const canvasResolved = appRequire.resolve('@napi-rs/canvas');
|
||||
const playwrightPackageResolved = appRequire.resolve('@playwright/mcp/package.json');
|
||||
const playwrightCliResolved = join(dirname(playwrightPackageResolved), 'cli.js');
|
||||
const { pack, unpack } = appRequire('msgpackr');
|
||||
const { createCanvas } = appRequire('@napi-rs/canvas');
|
||||
const packedValue = unpack(pack({ ok: true, text: '\u5362\u6b22' }));
|
||||
@@ -164,6 +266,8 @@ console.log('NIANCODE_ARTIFACT_PROBE=' + JSON.stringify({
|
||||
},
|
||||
msgpackResolved,
|
||||
canvasResolved,
|
||||
playwrightPackageResolved,
|
||||
playwrightCliResolved,
|
||||
nativeModules,
|
||||
packedValue,
|
||||
canvas: [canvas.width, canvas.height],
|
||||
@@ -207,6 +311,16 @@ if (!isPathInside(appAsarPath, probe.msgpackResolved)) {
|
||||
if (!isPathInside(appAsarPath, probe.canvasResolved)) {
|
||||
throw new Error(`@napi-rs/canvas was not resolved from app.asar: ${probe.canvasResolved}`);
|
||||
}
|
||||
if (!isPathInside(appAsarPath, probe.playwrightPackageResolved)) {
|
||||
throw new Error(`@playwright/mcp was not resolved from app.asar: ${probe.playwrightPackageResolved}`);
|
||||
}
|
||||
if (!isPathInside(appAsarPath, probe.playwrightCliResolved)) {
|
||||
throw new Error(`@playwright/mcp CLI was not resolved from app.asar: ${probe.playwrightCliResolved}`);
|
||||
}
|
||||
run(appExecutable, [probe.playwrightCliResolved, '--help'], {
|
||||
cwd: dirname(appExecutable),
|
||||
env: { ...installLocalProbeEnv, ELECTRON_RUN_AS_NODE: '1' },
|
||||
});
|
||||
const msgpackNative = probe.nativeModules.find(({ modulePath, physicalPath, exists }) => (
|
||||
exists
|
||||
&& isPathInside(appAsarPath, modulePath)
|
||||
@@ -245,6 +359,19 @@ const evidence = {
|
||||
installerBytes,
|
||||
installerSha256,
|
||||
runtime: { electron: probe.electron, node: probe.node },
|
||||
installLocalRuntimes: {
|
||||
opencode: {
|
||||
executable: opencodeExecutable,
|
||||
version: opencodeVersion,
|
||||
sha256: await sha256(opencodeExecutable),
|
||||
},
|
||||
playwrightMcp: {
|
||||
packageJson: probe.playwrightPackageResolved,
|
||||
cli: probe.playwrightCliResolved,
|
||||
},
|
||||
python: pythonProbe,
|
||||
uv: { executable: uvExecutable, version: uvVersion },
|
||||
},
|
||||
nativeModules: {
|
||||
msgpackr: probe.msgpackResolved,
|
||||
canvas: probe.canvasResolved,
|
||||
|
||||
Reference in New Issue
Block a user