#!/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();