640 lines
25 KiB
JavaScript
640 lines
25 KiB
JavaScript
#!/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) GitHub Release 下载(默认,CI/正式构建用)
|
||
* 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_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,
|
||
path.resolve(projectRoot, '..', '..', '..', 'qimingcode', 'packages', 'opencode', 'dist'),
|
||
path.join(process.env.HOME || '/root', 'workspace/qimingcode/packages/opencode/dist'),
|
||
].filter(Boolean);
|
||
|
||
return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
|
||
}
|
||
|
||
// ==================== 模式 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`];
|
||
|
||
// Windows:PATH 里常见的是 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'] }).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 useLocalDist = !!process.env.QIMINGCODE_DIST_DIR || fs.existsSync(getLocalDistDir());
|
||
const mode = useLocalDist ? '本地 dist 复制' : '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) {
|
||
const success = useLocalDist ? copyFromDist(key) : await downloadFromRelease(key);
|
||
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();
|