Files
qiming/qimingclaw/crates/agent-electron-client/scripts/prepare/prepare-qimingcode.js

772 lines
30 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* qimingcode 多平台集成:准备 resources/qimingcode/{platform}/bin/
*
* 三种模式:
* 1) 本地 dist 复制(设置 QIMINGCODE_DIST_DIR 环境变量,开发调试用)
* QIMINGCODE_DIST_DIR=~/workspace/qimingcode/packages/opencode/dist npm run prepare:qimingcode
* 2) 本地源码构建(默认开发模式):自动查找同级 qimingcode 仓库,缺 dist 时用 Bun 构建当前平台
* 3) GitHub Release 下载CI/正式构建兜底)
* QIMINGCODE_ALLOW_REMOTE=1 npm run prepare:qimingcode
*
* 打包时 electron-builder extraResources 将 resources/qimingcode 打包到应用内
* 运行时 getQimingCodeBundledBinPath() 解析对应平台二进制
*
* 用法:
* node scripts/prepare/prepare-qimingcode.js # 当前平台
* node scripts/prepare/prepare-qimingcode.js --all # 全平台
*
* 环境变量:
* QIMINGCODE_DIST_DIR — qimingcode 本地构建产物目录(设置后走本地复制模式)
* QIMINGCODE_SOURCE_DIR — qimingcode 本地源码仓库目录(默认查找 ../../../qimingcode
* QIMINGCODE_ALLOW_REMOTE — 允许本地源码不可用时从 GitHub Release 下载
* QIMINGCODE_REPO — GitHub 仓库(默认 qiming-ai/qimingcode
* GITHUB_TOKEN — GitHub token私有仓库或提高速率限制用
*/
const path = require('path');
const fs = require('fs');
const https = require('https');
const { URL } = require('url');
const { execSync, execFileSync } = require('child_process');
const { getProjectRoot } = require('../utils/project-paths');
const QIMINGCODE_VERSION = '1.2.1';
const QIMINGCODE_REPO = process.env.QIMINGCODE_REPO || 'qiming-ai/qimingcode';
const projectRoot = getProjectRoot();
const resDir = path.join(projectRoot, 'resources', 'qimingcode');
const cacheDir = path.join(projectRoot, 'scripts', 'resources', 'qimingcode-cache');
// Node platform-arch → dist 文件夹名 / Release asset 名
const PLATFORM_MAP = {
'darwin-arm64': 'qimingcode-darwin-arm64',
'darwin-x64': 'qimingcode-darwin-x64',
'linux-arm64': 'qimingcode-linux-arm64',
'linux-arm64-musl': 'qimingcode-linux-arm64-musl',
'linux-x64': 'qimingcode-linux-x64',
'linux-x64-musl': 'qimingcode-linux-x64-musl',
'win32-x64': 'qimingcode-windows-x64',
};
// 资源目录名需与运行时 getQimingCodeBundledBinPath() 一致
const RESOURCE_PLATFORM_KEY_MAP = {
'win32-x64': 'windows-x64',
};
function getPlatformKey() {
const a = process.env.TARGET_ARCH || process.arch;
return `${process.platform}-${a}`;
}
function getResourcePlatformKey(key) {
return RESOURCE_PLATFORM_KEY_MAP[key] || key;
}
function isWindows(key) {
return key.startsWith('win32');
}
function getBinaryName(key) {
return isWindows(key) ? 'qimingcode.exe' : 'qimingcode';
}
/**
* 兼容 release 二进制命名差异:
* - 历史格式: qimingcode / qimingcode.exe
* - 新格式: opencode / opencode.exe
*
* 说明:
* 1) 运行时入口仍统一为 resources/.../bin/qimingcode(.exe)
* 2) 这里仅放宽“解压后查找源二进制”的候选名,不改变运行时对外约定
*/
function getBinaryCandidates(key) {
const preferred = getBinaryName(key);
const fallback = isWindows(key) ? 'opencode.exe' : 'opencode';
return [preferred, fallback];
}
/**
* 清理目标 bin 目录,避免旧版本资源文件残留。
*
* 背景:
* - 历史上这里是“增量覆盖复制”,当新包删除了某些文件(例如 assets/models.json
* 而旧包中仍存在时,旧文件会继续留在目标目录,造成“看似升级成功但实际混入旧资源”。
*
* 规则:
* - 每次准备二进制前先删后建,保证目标目录仅包含当前包内容。
* - 使用 force:true确保目录不存在时也不会抛错。
*/
function resetDestBinDir(destDir) {
fs.rmSync(destDir, { recursive: true, force: true });
fs.mkdirSync(destDir, { recursive: true });
}
/**
* 确保目标目录存在 assets/model.json。
*
* 背景:
* - 新版 release 有时只包含单二进制,不再附带 assets/model.json。
* - 业务侧仍有路径会读取该文件,因此这里统一兜底创建最小占位文件。
*
* 约束:
* - 若上游已提供 model.json则保持原样不覆盖。
* - 仅在缺失时创建,内容保持最小且可 JSON.parse。
*/
function ensureModelJson(destDir, version) {
const assetsDir = path.join(destDir, 'assets');
const modelJsonPath = path.join(assetsDir, 'model.json');
if (fs.existsSync(modelJsonPath)) return;
fs.mkdirSync(assetsDir, { recursive: true });
const fallback = {
models: [],
source: 'generated-fallback',
version,
};
fs.writeFileSync(modelJsonPath, `${JSON.stringify(fallback, null, 2)}\n`, 'utf-8');
}
/**
* 即便命中版本与 SHA也执行一次“目录重铺”。
*
* 原因:
* - 过去采用增量覆盖复制,可能在目标目录留下旧版本 assets 残留。
* - 仅靠二进制 SHA 命中无法发现“额外残留文件”。
*
* 处理:
* - 命中后不提前 return而是继续走解压/复制流程(优先使用本地缓存包),
* 通过 resetDestBinDir() 达到“先清理后落盘”的确定性结果。
*/
const FORCE_REFRESH_ON_MATCH = true;
// ==================== 模式 1: 本地 dist 复制 ====================
function copyFromDist(key) {
const qimingcodeDist = getLocalDistDir();
const distName = PLATFORM_MAP[key];
if (!distName) {
console.error(`[prepare-qimingcode] 不支持的平台: ${key}`);
return false;
}
const resourceKey = getResourcePlatformKey(key);
const binary = getBinaryName(key);
const destDir = path.join(resDir, resourceKey, 'bin');
const destPath = path.join(destDir, binary);
const srcBinDir = path.join(qimingcodeDist, distName, 'bin');
const srcBinary = getBinaryCandidates(key)
.map((candidate) => path.join(srcBinDir, candidate))
.find((candidatePath) => fs.existsSync(candidatePath));
if (!srcBinary) {
console.warn(
`[prepare-qimingcode] ${key}: 构建产物不存在,已检查 ${getBinaryCandidates(key)
.map((candidate) => path.join(srcBinDir, candidate))
.join(', ')}`,
);
return false;
}
// 检查是否已是最新SHA256 一致 + 版本匹配)
// 注意codesign 会修改二进制,所以用保存的 .sha256 记录比对 dest签名后
// 而非比对 src未签名vs dest已签名
if (fs.existsSync(destPath)) {
const versionFile = path.join(resDir, '.version');
if (fs.existsSync(versionFile) && fs.readFileSync(versionFile, 'utf-8').trim() === QIMINGCODE_VERSION) {
const shaFile = path.join(resDir, `.sha256-${resourceKey}`);
if (fs.existsSync(shaFile)) {
const expectedHash = fs.readFileSync(shaFile, 'utf-8').trim();
const currentHash = sha256File(destPath);
if (currentHash === expectedHash) {
// 额外校验:二进制内部版本号可能与 .version 不一致(曾发生过标记更新但二进制未更新)
const innerVersion = verifyBinaryVersion(destPath, QIMINGCODE_VERSION, key, currentHash);
if (innerVersion && innerVersion !== QIMINGCODE_VERSION) {
console.warn(
`[prepare-qimingcode] ${key}: 版本标记为 ${QIMINGCODE_VERSION} 但二进制为 ${innerVersion},将重新复制覆盖`,
);
} else {
console.log(
`[prepare-qimingcode] ${key} ✓ (已是最新, SHA256=${currentHash.slice(0, 16)}...)`
+ (FORCE_REFRESH_ON_MATCH ? ',将执行目录重铺以清理残留文件' : ''),
);
if (!FORCE_REFRESH_ON_MATCH) return true;
}
}
console.warn(`[prepare-qimingcode] ${key}: SHA256 不匹配,需重新复制 (saved=${expectedHash.slice(0, 16)}... current=${currentHash.slice(0, 16)}...)`);
}
}
}
// 复制前先清理目标目录,确保不会夹带旧版本 assets 残留文件。
resetDestBinDir(destDir);
// 复制整个 bin 目录(包含二进制 + assets 等)
fs.cpSync(srcBinDir, destDir, { recursive: true });
const copiedBinaryPath = path.join(destDir, path.basename(srcBinary));
if (copiedBinaryPath !== destPath) {
fs.renameSync(copiedBinaryPath, destPath);
}
ensureModelJson(destDir, QIMINGCODE_VERSION);
fs.chmodSync(destPath, 0o755);
const sizeMB = (fs.statSync(destPath).size / 1024 / 1024).toFixed(1);
console.log(`[prepare-qimingcode] ${key} ✓ 从本地 dist 复制 (${sizeMB} MB)`);
// macOS ad-hoc 签名
codesign(destPath, key);
// 计算 SHA256签名后用于打印 + 保存
const hash = sha256File(destPath);
// 验证二进制内部版本号 + 打印 SHA256
verifyBinaryVersion(destPath, QIMINGCODE_VERSION, key, hash);
// 保存 SHA256 记录,下次可精确跳过
fs.writeFileSync(path.join(resDir, `.sha256-${resourceKey}`), hash, 'utf-8');
return true;
}
function getLocalDistDir() {
const candidates = [
process.env.QIMINGCODE_DIST_DIR,
getLocalSourceDir() ? path.join(getLocalSourceDir(), 'packages', 'opencode', 'dist') : null,
path.join(process.env.HOME || '/root', 'workspace/qimingcode/packages/opencode/dist'),
].filter(Boolean);
return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
}
function getLocalSourceDir() {
const candidates = [
process.env.QIMINGCODE_SOURCE_DIR,
path.resolve(projectRoot, '..', '..', '..', 'qimingcode'),
path.join(process.env.HOME || '/root', 'workspace/qimingcode'),
].filter(Boolean);
for (const candidate of candidates) {
if (fs.existsSync(path.join(candidate, 'packages', 'opencode', 'package.json'))) {
return candidate;
}
}
return null;
}
function getBunCommand() {
if (process.env.BUN_BIN) return process.env.BUN_BIN;
try {
return execFileSync('which', ['bun'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
} catch {
return null;
}
}
function getLocalBuildEnv(sourceDir, opencodeDir, bun) {
const bunDir = path.dirname(bun);
const localModelsJson = path.join(opencodeDir, 'assets', 'models.json');
const env = {
...process.env,
OPENCODE_VERSION: QIMINGCODE_VERSION,
PATH: `${bunDir}${path.delimiter}${process.env.PATH || ''}`,
...getLocalRuntimeEnv(sourceDir),
};
if (!process.env.MODELS_DEV_API_JSON && fs.existsSync(localModelsJson)) {
env.MODELS_DEV_API_JSON = localModelsJson;
console.log(`[prepare-qimingcode] 使用本地模型快照: ${localModelsJson}`);
}
return env;
}
function getLocalRuntimeEnv(sourceDir = getLocalSourceDir() || projectRoot) {
const localRuntimeDir = path.join(sourceDir, '.local-runtime');
return {
XDG_CACHE_HOME: process.env.XDG_CACHE_HOME || path.join(localRuntimeDir, 'cache'),
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME || path.join(localRuntimeDir, 'config'),
XDG_DATA_HOME: process.env.XDG_DATA_HOME || path.join(localRuntimeDir, 'data'),
XDG_STATE_HOME: process.env.XDG_STATE_HOME || path.join(localRuntimeDir, 'state'),
OPENCODE_DATA_DIR: process.env.OPENCODE_DATA_DIR || path.join(localRuntimeDir, 'data', 'opencode'),
};
}
function ensureLocalDist(key) {
const distDir = getLocalDistDir();
const distName = PLATFORM_MAP[key];
if (!distName) return false;
const binDir = path.join(distDir, distName, 'bin');
const existing = getBinaryCandidates(key)
.map((candidate) => path.join(binDir, candidate))
.find((candidatePath) => fs.existsSync(candidatePath));
if (existing) return true;
const sourceDir = getLocalSourceDir();
if (!sourceDir) return false;
const bun = getBunCommand();
if (!bun) {
console.error('[prepare-qimingcode] 找到本地 qimingcode 源码,但未找到 Bun。');
console.error('[prepare-qimingcode] 请先安装 Bun或设置 BUN_BIN=/path/to/bun 后重试。');
return false;
}
console.log(`[prepare-qimingcode] 本地 dist 缺失,使用源码构建当前平台: ${sourceDir}`);
const opencodeDir = path.join(sourceDir, 'packages', 'opencode');
const hasRootNodeModules = fs.existsSync(path.join(sourceDir, 'node_modules'));
const hasOpencodeNodeModules = fs.existsSync(path.join(opencodeDir, 'node_modules'));
if (!hasRootNodeModules || !hasOpencodeNodeModules) {
console.log('[prepare-qimingcode] 安装 qimingcode 依赖 (bun install --ignore-scripts)...');
execFileSync(bun, ['install', '--ignore-scripts'], { cwd: sourceDir, stdio: 'inherit' });
}
const buildEnv = getLocalBuildEnv(sourceDir, opencodeDir, bun);
try {
console.log('[prepare-qimingcode] 修复 node-pty helper 权限...');
execFileSync(bun, ['run', 'fix-node-pty'], { cwd: opencodeDir, stdio: 'inherit', env: buildEnv });
} catch (err) {
console.warn(`[prepare-qimingcode] node-pty 权限修复失败,将继续构建: ${err.message}`);
}
console.log('[prepare-qimingcode] 构建 qimingcode 当前平台二进制...');
execFileSync(
bun,
['run', 'script/build.ts', '--single', '--skip-embed-web-ui', '--skip-install'],
{
cwd: opencodeDir,
stdio: 'inherit',
env: buildEnv,
},
);
return getBinaryCandidates(key)
.map((candidate) => path.join(binDir, candidate))
.some((candidatePath) => fs.existsSync(candidatePath));
}
// ==================== 模式 2: GitHub Release 下载(显式兜底) ====================
/**
* 下载文件到缓存目录
* @param {{ force?: boolean }} [options] force=true 时忽略已有缓存(解压失败/缓存截断时用)
*/
function download(url, preferredFilename, options = {}) {
const force = !!options.force;
return new Promise((resolve, reject) => {
const filename = preferredFilename || path.basename(url.split('?')[0]) || 'download';
const file = path.join(cacheDir, filename);
// 缓存检查(仅看大小,不校验 gzip损坏时需 force 重下)
if (!force && fs.existsSync(file)) {
try {
const stats = fs.statSync(file);
if (stats.size > 100 * 1024) {
console.log(`[prepare-qimingcode] 使用缓存: ${filename} (${Math.round(stats.size / 1024 / 1024)} MB)`);
resolve(file);
return;
}
} catch (_) {}
try { fs.unlinkSync(file); } catch (_) {}
}
if (force && fs.existsSync(file)) {
try { fs.unlinkSync(file); } catch (_) {}
}
const headers = { 'User-Agent': 'QimingClaw-Build' };
if (process.env.GITHUB_TOKEN) {
headers['Authorization'] = `Bearer ${process.env.GITHUB_TOKEN}`;
}
fs.mkdirSync(cacheDir, { recursive: true });
const doRequest = (reqUrl, redirects) => {
if (redirects > 10) return reject(new Error('Too many redirects'));
https.get(reqUrl, { headers }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
const loc = res.headers.location;
const nextUrl = loc.startsWith('http') ? loc : new URL(loc, reqUrl).href;
doRequest(nextUrl, redirects + 1);
return;
}
if (res.statusCode !== 200) {
res.resume();
try { fs.unlinkSync(file); } catch (_) {}
return reject(new Error(`HTTP ${res.statusCode} for ${reqUrl}`));
}
const stream = fs.createWriteStream(file);
res.pipe(stream);
stream.on('finish', () => { stream.close(); resolve(file); });
stream.on('error', (e) => {
stream.close();
try { fs.unlinkSync(file); } catch (_) {}
reject(e);
});
}).on('error', reject);
};
doRequest(url, 0);
});
}
/**
* 从 GitHub Release 下载并解压
*/
async function downloadFromRelease(key) {
const distName = PLATFORM_MAP[key];
if (!distName) {
console.error(`[prepare-qimingcode] 不支持的平台: ${key}`);
return false;
}
const resourceKey = getResourcePlatformKey(key);
const binary = getBinaryName(key);
const destDir = path.join(resDir, resourceKey, 'bin');
const destPath = path.join(destDir, binary);
// 检查是否已是最新(版本匹配 + SHA256 一致)
const versionFile = path.join(resDir, '.version');
if (fs.existsSync(destPath) && fs.existsSync(versionFile)) {
if (fs.readFileSync(versionFile, 'utf-8').trim() === QIMINGCODE_VERSION) {
// 额外校验:比对已缓存 tar.gz 与当前 dest 的 SHA256 是否记录一致
// 防止 release 被 force-push 后 .version 匹配但二进制已过期
const shaFile = path.join(resDir, `.sha256-${getResourcePlatformKey(key)}`);
if (fs.existsSync(shaFile)) {
const expectedHash = fs.readFileSync(shaFile, 'utf-8').trim();
const currentHash = sha256File(destPath);
if (currentHash === expectedHash) {
// 关键:即使命中 SHA256 + .version也必须校验二进制内部版本号
// 否则一旦资源目录里的二进制未更新但标记文件被更新,会导致永远跳过下载。
const innerVersion = verifyBinaryVersion(destPath, QIMINGCODE_VERSION, key, currentHash);
if (innerVersion && innerVersion !== QIMINGCODE_VERSION) {
console.warn(
`[prepare-qimingcode] ${key}: 版本标记为 ${QIMINGCODE_VERSION} 但二进制为 ${innerVersion},将强制重新下载覆盖`,
);
} else {
const sizeMB = (fs.statSync(destPath).size / 1024 / 1024).toFixed(1);
console.log(
`[prepare-qimingcode] ${key} ✓ (已是最新 ${sizeMB} MB, SHA256=${currentHash.slice(0, 16)}...)`
+ (FORCE_REFRESH_ON_MATCH ? ',将执行目录重铺以清理残留文件' : ''),
);
if (!FORCE_REFRESH_ON_MATCH) return true;
}
}
console.warn(`[prepare-qimingcode] ${key}: SHA256 不匹配,需重新下载 (expected=${expectedHash.slice(0, 16)}... current=${currentHash.slice(0, 16)}...)`);
} else {
// 无 SHA256 记录时也做一次内部版本校验:避免“标记已更新、二进制未更新”被掩盖
const innerVersion = verifyBinaryVersion(destPath, QIMINGCODE_VERSION, key, null);
if (innerVersion && innerVersion !== QIMINGCODE_VERSION) {
console.warn(
`[prepare-qimingcode] ${key}: 版本标记为 ${QIMINGCODE_VERSION} 但二进制为 ${innerVersion},将重新下载覆盖`,
);
} else {
const sizeMB = (fs.statSync(destPath).size / 1024 / 1024).toFixed(1);
console.log(
`[prepare-qimingcode] ${key} ✓ (版本匹配 ${sizeMB} MB无 SHA256 记录)`
+ (FORCE_REFRESH_ON_MATCH ? ',将执行目录重铺以清理残留文件' : ',跳过下载'),
);
if (!FORCE_REFRESH_ON_MATCH) return true;
}
}
}
}
// Release 资产命名兼容策略:
// 1) 新命名带版本后缀qimingcode-xxx-v1.1.96.tar.gz
// 2) 旧命名不带版本后缀qimingcode-xxx.tar.gz
// 先尝试新命名404 再回退旧命名,兼容历史 release 与新 CI 命名规则。
const assetCandidates = [`${distName}-v${QIMINGCODE_VERSION}.tar.gz`, `${distName}.tar.gz`];
// WindowsPATH 里常见的是 System32 的 bsdtar它不认 MSYS 的 /d/a/... 路径,
// 只认盘符路径D:\... 或 D:/...。Git for Windows 的 GNU tar 也接受 D:/...。
const toTarPath = (p) => {
if (process.platform !== 'win32') return p;
const match = /^([A-Za-z]):[\\/](.*)$/.exec(p);
if (!match) return p.replace(/\\/g, '/');
const drive = match[1];
const rest = match[2].replace(/\\/g, '/');
return `${drive}:/${rest}`;
};
let lastErr = null;
for (const assetName of assetCandidates) {
const downloadUrl = `https://github.com/${QIMINGCODE_REPO}/releases/download/v${QIMINGCODE_VERSION}/${assetName}`;
console.log(`[prepare-qimingcode] ${key}: 尝试下载 ${assetName} ...`);
try {
for (let attempt = 0; attempt < 2; attempt++) {
const force = attempt > 0;
if (force) {
console.warn(
`[prepare-qimingcode] ${key}: 归档解压失败(常见于缓存被截断),将删除缓存并重新下载 ${assetName}`,
);
}
const archivePath = await download(downloadUrl, assetName, { force });
// 解压到临时目录
const extractDir = path.join(cacheDir, `extract-${key}`);
if (fs.existsSync(extractDir)) {
try { fs.rmSync(extractDir, { recursive: true }); } catch (_) {}
}
fs.mkdirSync(extractDir, { recursive: true });
const tarArchivePath = toTarPath(archivePath);
const tarExtractDir = toTarPath(extractDir);
// 使用参数数组调用 tar避免在 Windows/MSYS 下对 C:\ 路径的错误解析。
// --force-local 仅在 win32 且 tar 支持时使用macOS BSD tar 不支持该选项)。
const tarArgs = ['-xzf', tarArchivePath, '-C', tarExtractDir];
if (process.platform === 'win32') {
try {
const tarHelp = execFileSync('tar', ['--help'], { encoding: 'utf-8', stdio: 'pipe' });
if (typeof tarHelp === 'string' && tarHelp.includes('--force-local')) {
tarArgs.unshift('--force-local');
}
} catch (_) {}
}
try {
execFileSync('tar', tarArgs, { stdio: 'pipe' });
} catch (tarErr) {
if (attempt === 1) throw tarErr;
continue;
}
// 查找二进制文件:优先新名称 qimingcode其次兼容旧名称 opencode。
// 背景:部分历史 release 资产仍产出 opencode 可执行文件。
const binaryCandidates = getBinaryCandidates(key);
const binaryPath = findBinary(extractDir, binaryCandidates);
if (!binaryPath) {
if (attempt === 1) {
throw new Error(`解压后未找到可执行文件(候选: ${binaryCandidates.join(', ')}`);
}
continue;
}
// 复制前先清理目标目录,避免旧版本 assets 文件被“增量复制”保留下来。
resetDestBinDir(destDir);
const extractedBinDir = path.dirname(binaryPath);
const extractedBaseName = path.basename(binaryPath);
// 复制策略:
// A. 命中标准名qimingcode复制整个 bin 目录,尽量保留同目录 assets
// B. 命中别名opencode按目标标准名落盘保证后续路径稳定
if (extractedBaseName === binary) {
fs.cpSync(extractedBinDir, destDir, { recursive: true });
} else {
fs.copyFileSync(binaryPath, destPath);
// 复制同目录 assets如 models.json
const assetsDir = path.join(extractedBinDir, 'assets');
if (fs.existsSync(assetsDir)) {
const destAssetsDir = path.join(destDir, 'assets');
fs.mkdirSync(destAssetsDir, { recursive: true });
fs.cpSync(assetsDir, destAssetsDir, { recursive: true });
}
}
ensureModelJson(destDir, QIMINGCODE_VERSION);
fs.chmodSync(destPath, 0o755);
const sizeMB = (fs.statSync(destPath).size / 1024 / 1024).toFixed(1);
console.log(`[prepare-qimingcode] ${key} ✓ 从 GitHub Release 下载 (${sizeMB} MB)`);
// macOS ad-hoc 签名
codesign(destPath, key);
// 计算 SHA256签名后用于打印 + 保存
const hash = sha256File(destPath);
// 验证二进制内部版本号 + 打印 SHA256
const innerVersion = verifyBinaryVersion(destPath, QIMINGCODE_VERSION, key, hash);
if (innerVersion && innerVersion !== QIMINGCODE_VERSION) {
// 常见于:本地缓存的 tar.gz 仍是旧内容(例如同名资产被替换、或缓存命中导致一直用旧包)
// 第一次发现不一致时,删除缓存并强制重新下载再试一次。
if (attempt === 0) {
console.warn(
`[prepare-qimingcode] ${key}: 检测到二进制版本不一致,将删除缓存并强制重新下载 ${assetName} 再验证一次`,
);
continue;
}
}
// 保存 SHA256 记录,下次可精确跳过
fs.writeFileSync(path.join(resDir, `.sha256-${resourceKey}`), hash, 'utf-8');
return true;
}
} catch (err) {
lastErr = err;
// 如果新命名不存在404自动回退旧命名继续尝试其他错误也继续尝试下一候选。
console.warn(`[prepare-qimingcode] ${key}: 资产 ${assetName} 失败 (${err.message}),尝试下一个命名...`);
}
}
console.error(`[prepare-qimingcode] ${key}: 下载失败: ${lastErr ? lastErr.message : 'unknown error'}`);
console.error(`[prepare-qimingcode] 请确认 GitHub Release 存在: https://github.com/${QIMINGCODE_REPO}/releases/tag/v${QIMINGCODE_VERSION}`);
return false;
}
/**
* 在解压目录中递归查找二进制文件
*/
function findBinary(dir, binaryNames) {
const names = Array.isArray(binaryNames) ? binaryNames : [binaryNames];
// 先走最常见目录bin/ 与 package/bin/
for (const name of names) {
const direct = path.join(dir, 'bin', name);
if (fs.existsSync(direct)) return direct;
const pkgBin = path.join(dir, 'package', 'bin', name);
if (fs.existsSync(pkgBin)) return pkgBin;
}
// 新 release 可能是“根目录单文件”
for (const name of names) {
const rootFile = path.join(dir, name);
if (fs.existsSync(rootFile)) return rootFile;
}
// 最后兜底递归搜索(最多 3 层)
for (const name of names) {
const found = _findRecursive(dir, name, 3);
if (found) return found;
}
return null;
}
function _findRecursive(dir, binaryName, maxDepth) {
if (maxDepth <= 0) return null;
try {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isFile() && entry.name === binaryName) return fullPath;
if (entry.isDirectory()) {
const found = _findRecursive(fullPath, binaryName, maxDepth - 1);
if (found) return found;
}
}
} catch (_) {}
return null;
}
// ==================== 通用 ====================
/**
* 计算文件 SHA256
*/
function sha256File(filePath) {
try {
return execFileSync('shasum', ['-a', '256', filePath], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim().split(/\s+/)[0];
} catch {
// Windows 或无 shasum 时,用 Node.js crypto
const crypto = require('crypto');
const data = fs.readFileSync(filePath);
return crypto.createHash('sha256').update(data).digest('hex');
}
}
/**
* 验证二进制内部版本号是否与期望版本匹配
* qimingcode 的 release tag 可能与二进制内部版本不一致,
* 需要检测以避免 .version 标记与实际二进制不符。
*/
function verifyBinaryVersion(binaryPath, expectedVersion, key, hash) {
// 打印 SHA256由调用方传入避免重复计算
if (hash) {
console.log(`[prepare-qimingcode] ${key}: SHA256=${hash}`);
}
try {
const output = execFileSync(binaryPath, ['-v'], {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, ...getLocalRuntimeEnv() },
}).trim();
if (output !== expectedVersion) {
console.warn(
`[prepare-qimingcode] ${key}: ⚠️ 二进制内部版本 ${output} 与期望版本 ${expectedVersion} 不一致release tag 版本与二进制版本不同步)`,
);
}
return output;
} catch (e) {
// 二进制可能不支持 -v 或无法在本平台执行(交叉编译场景),跳过校验
console.warn(`[prepare-qimingcode] ${key}: 无法验证二进制版本(${e.message}),跳过校验`);
return null;
}
}
function codesign(binaryPath, key) {
if (process.platform === 'darwin') {
try {
execSync(`codesign --force --sign - "${binaryPath}"`, { stdio: 'pipe' });
} catch {
console.warn(`[prepare-qimingcode] ${key} 签名失败(不影响功能)`);
}
}
}
async function main() {
const allPlatforms = process.argv.includes('--all') || process.argv.includes('--all-platforms');
const allowRemote = process.env.QIMINGCODE_ALLOW_REMOTE === '1';
const localSourceDir = getLocalSourceDir();
const mode = localSourceDir || process.env.QIMINGCODE_DIST_DIR
? '本地优先dist/源码构建)'
: allowRemote
? 'GitHub Release 下载'
: '本地优先(未找到本地源码)';
fs.mkdirSync(resDir, { recursive: true });
const keys = allPlatforms ? Object.keys(PLATFORM_MAP) : [getPlatformKey()];
console.log(`[prepare-qimingcode] 模式: ${mode}`);
console.log(`[prepare-qimingcode] 版本: v${QIMINGCODE_VERSION}`);
console.log(`[prepare-qimingcode] 平台: ${keys.join(', ')}`);
if (!allPlatforms && !PLATFORM_MAP[keys[0]]) {
console.error(`[prepare-qimingcode] 不支持的平台: ${keys[0]}`);
console.error(`[prepare-qimingcode] 支持的平台: ${Object.keys(PLATFORM_MAP).join(', ')}`);
process.exit(1);
}
let ok = 0;
let fail = 0;
for (const key of keys) {
let success = false;
if (ensureLocalDist(key)) {
success = copyFromDist(key);
} else if (allowRemote) {
console.warn('[prepare-qimingcode] 本地 qimingcode 产物不可用,按 QIMINGCODE_ALLOW_REMOTE=1 使用远端兜底');
success = await downloadFromRelease(key);
} else {
console.error('[prepare-qimingcode] 本地 qimingcode 产物不可用,且未允许远端下载。');
console.error('[prepare-qimingcode] 请确保同级存在 qimingcode 仓库,或设置 QIMINGCODE_SOURCE_DIR / QIMINGCODE_DIST_DIR。');
}
if (success) {
ok++;
} else {
fail++;
}
}
if (ok > 0) {
// 写入版本标记
fs.writeFileSync(path.join(resDir, '.version'), QIMINGCODE_VERSION, 'utf-8');
console.log(`[prepare-qimingcode] ✓ 版本: ${QIMINGCODE_VERSION}`);
}
console.log(`[prepare-qimingcode] 完成: ${ok} 成功, ${fail} 失败`);
if (fail > 0 && !allPlatforms) {
process.exit(1);
}
}
main();