#!/usr/bin/env node /** * pack-harmony-project.js * * 把 stub-app-projects/mp-harmony 中「编译所需」的文件导出为一个 zip, * 供其他电脑解压后直接用 DevEco Studio 编译并上传产物。 * * 用法: * node scripts/pack-harmony-project.js # 默认: 含证书, 不含 node_modules * node scripts/pack-harmony-project.js --with-node-modules # 额外含 hvigor/node_modules (约 320MB), 供离线机器 * node scripts/pack-harmony-project.js --no-certs # 不含签名证书 * node scripts/pack-harmony-project.js --out # 自定义输出目录 (默认 unpackage/dist/harmony-export) * * 设计原则: * - **只读**源目录, 全程不修改 stub-app-projects 下任何文件 (产物先复制到暂存目录再压缩) * - 采用「全量复制 - 排除清单」策略, 而不是白名单, 避免漏掉新增的必需文件 * - 输出 zip 内含一层 mp-harmony/ 目录, 解压后不会散落一地 * * 为什么必须包含 cert-profile: * build-profile.json5 的 signingConfigs 引用了 ./cert-profile/qq/ 下的 * qq.p12 / qqDebug.p7b / qq.cer。证书缺失时 hvigor 无法签名, 产不出可上传的 .app。 * ⚠️ 默认包含意味着 zip 内含私钥, 仅在可信渠道传递。 * * 为什么包含 ascf/ascf_src: * 它是 uniapp 构建产物 (由 vue.config.js 的 _copyMpHarmonyBuild 从 * unpackage/dist/build/.mp-harmony 拷入), 装着真正的页面/组件代码。 * 带上它, 目标机就不需要本仓库和 HBuilderX, 解压即可编译。 * ⚠️ 它是导出时刻的快照; 若前端代码有更新, 需重新执行本脚本。 */ const fs = require('fs'); const fsp = fs.promises; const path = require('path'); const { exec } = require('child_process'); const { promisify } = require('util'); const execAsync = promisify(exec); const PROJECT_DIR_NAME = 'mp-harmony'; const DEFAULT_OUT_DIR = path.join('unpackage', 'dist', 'harmony-export'); /** * 目录名黑名单 —— 出现在任意层级都跳过 * * - node_modules / oh_modules: 依赖, 可由 DevEco 打开工程时自动拉取 * (hvigor/node_modules 约 320MB, 不可能进包; 需要离线机时用 --with-node-modules) * - build: 各 module 的编译产物 * - .preview: DevEco 预览缓存 (entry 下就占 22MB) * - .hvigor / .idea: IDE 本地状态 * - .cxx / .test: 原生编译缓存与测试产物 */ const EXCLUDE_DIRS = new Set([ 'node_modules', 'oh_modules', 'build', '.preview', '.hvigor', '.idea', '.cxx', '.test', '.git', '.appanalyzer', '.clangd', '.kotlin', ]); /** * 文件名黑名单 —— 出现在任意层级都跳过 * * - local.properties: DevEco 自动生成, 内含本机 SDK 路径 * - _1.clang-format / _1.gitignore: macOS Finder 产生的重名副本 (Windows 上是 _1, macOS 是 _副本) */ const EXCLUDE_FILES = new Set([ 'local.properties', '.DS_Store', 'Thumbs.db', '_1.clang-format', '_1.gitignore', ]); /** 含私钥的目录, --no-certs 时跳过 */ const CERT_DIR = 'cert-profile'; function parseArgs(argv) { const opts = { withNodeModules: false, withCerts: true, outDir: null, }; for (const arg of argv) { if (arg === '--with-node-modules') opts.withNodeModules = true; else if (arg === '--no-certs') opts.withCerts = false; else if (arg === '--out') { /* 由调用方给出下一项 */ } } const outIdx = argv.indexOf('--out'); if (outIdx !== -1 && outIdx + 1 < argv.length) { opts.outDir = path.resolve(argv[outIdx + 1]); } return opts; } /** 该目录是否需要跳过 */ function shouldSkipDir(name, opts, isRootLevel) { if (EXCLUDE_DIRS.has(name)) { // node_modules 只有根级 hvigor/node_modules 需要可选保留; // oh_modules / entry/node_modules 等一律跳过 (体积小, DevEco 会重建) if (opts.withNodeModules && name === 'node_modules' && isRootLevel) return false; return true; } if (!opts.withCerts && name === CERT_DIR) return true; return false; } function shouldSkipFile(name) { if (EXCLUDE_FILES.has(name)) return true; // 编译/日志类临时文件 return /\.(log|tmp|swp)$/.test(name); } const stats = { files: 0, bytes: 0, skipped: 0 }; async function copyDir(srcDir, destDir, opts, depth = 0, nameOfParent = '') { await fsp.mkdir(destDir, { recursive: true }); const entries = await fsp.readdir(srcDir, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(srcDir, entry.name); const destPath = path.join(destDir, entry.name); if (entry.isDirectory()) { if (shouldSkipDir(entry.name, opts, depth === 0)) { stats.skipped++; continue; } await copyDir(srcPath, destPath, opts, depth + 1, entry.name); } else if (entry.isFile()) { if (shouldSkipFile(entry.name)) { stats.skipped++; continue; } await fsp.copyFile(srcPath, destPath); const st = await fsp.stat(destPath); stats.files++; stats.bytes += st.size; } // 符号链接等其它类型直接跳过 } } /* ========================================================================== * ZIP 写入 (纯 Node, 零依赖) * * 为什么要自己写: * 本机两种系统方案都产出**不合规**的 zip, 且失败方式是静默的: * 1. PowerShell Compress-Archive / [ZipFile]::CreateFromDirectory * 写入的条目名用**反斜杠** (mp-harmony\AppScope\app.json5)。ZIP 规范要求 * 正斜杠; Windows 解压侥幸能过, macOS/Linux 会把 `\` 当字面量, 解出畸形路径。 * 2. 本机 tar 是 GNU tar 1.35, **不支持创建 zip** (`-a` 仅对 tar 归档生效), * 而且 exit 0, try/catch 拦不住, 会产出无法解压的 .zip。 * 另外 .NET 方案还把中文文件名写成 ANSI 编码 (导出说明.txt -> 乱码), 因为没置 * UTF-8 标志位 (general purpose bit 11)。 * * 自己写则能保证: 条目名统一正斜杠 + UTF-8 标志位恒置 + 跨平台字节一致。 * ========================================================================== */ const zlib = require('zlib'); /** CRC32 查表 (标准多项式 0xEDB88320) */ const CRC_TABLE = (() => { const table = new Int32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; table[n] = c; } return table; })(); function crc32(buf) { let c = -1; for (let i = 0; i < buf.length; i++) c = (c >>> 8) ^ CRC_TABLE[(c ^ buf[i]) & 0xff]; return (c ^ -1) >>> 0; } /** JS Date -> DOS 日期/时间 (ZIP 格式要求, 精度到 2 秒) */ function toDosDateTime(date) { return { time: ((date.getHours() & 0x1f) << 11) | ((date.getMinutes() & 0x3f) << 5) | ((date.getSeconds() / 2) & 0x1f), date: (((date.getFullYear() - 1980) & 0x7f) << 9) | (((date.getMonth() + 1) & 0xf) << 5) | (date.getDate() & 0x1f), }; } /** 文件头 4 字节 */ const SIG_LOCAL = 0x04034b50; const SIG_CENTRAL = 0x02014b50; const SIG_EOCD = 0x06054b50; /** 通用标志位: bit11 = 文件名/注释为 UTF-8 */ const FLAG_UTF8 = 0x0800; /** 单文件超过 4GB 需要 ZIP64, 本工程产物远小于此, 直接拒绝而不是产出坏包 */ const MAX_FILE_SIZE = 0xffffffff; /** * 把 dirToZip 整个目录压成 zipPath, 顶层保留目录名 * @returns {Promise<{files:number, bytes:number}>} */ async function zipDirectory(dirToZip, zipPath) { const rootName = path.basename(dirToZip); const rootAbs = path.resolve(dirToZip); // 收集文件与目录, 路径统一转正斜杠 const fileEntries = []; const dirEntries = []; // ⚠️ 必须 await: 不 await 的话 zip 会只写入部分条目就收尾, // 且递归 walk 会在暂存目录被清理后继续跑并抛 ENOENT await (async function walk(absDir, relBase) { const items = await fsp.readdir(absDir, { withFileTypes: true }); for (const item of items) { const abs = path.join(absDir, item.name); const rel = relBase ? `${relBase}/${item.name}` : item.name; if (item.isDirectory()) { dirEntries.push(rel); await walk(abs, rel); } else if (item.isFile()) { fileEntries.push({ abs, rel }); } } })(rootAbs, rootName); const { time, date } = toDosDateTime(new Date()); const chunks = []; const central = []; let offset = 0; const pushLocalHeader = (nameBuf, method, crc, compSize, uncompSize) => { // ⚠️ 必须在累加前取值: 中央目录记录的 localOffset 指向**本地文件头的起始**, // 不是紧随其后的数据区 const localOffset = offset; const h = Buffer.alloc(30); h.writeUInt32LE(SIG_LOCAL, 0); h.writeUInt16LE(20, 4); // version needed h.writeUInt16LE(FLAG_UTF8, 6); // 标志位: UTF-8 文件名 h.writeUInt16LE(method, 8); h.writeUInt16LE(time, 10); h.writeUInt16LE(date, 12); h.writeUInt32LE(crc, 14); h.writeUInt32LE(compSize, 18); h.writeUInt32LE(uncompSize, 22); h.writeUInt16LE(nameBuf.length, 26); h.writeUInt16LE(0, 28); // extra len chunks.push(h, nameBuf); offset += h.length + nameBuf.length; return localOffset; }; const pushCentralHeader = (nameBuf, method, crc, compSize, uncompSize, localOffset) => { const c = Buffer.alloc(46); c.writeUInt32LE(SIG_CENTRAL, 0); c.writeUInt16LE(20, 4); // version made by c.writeUInt16LE(20, 6); // version needed c.writeUInt16LE(FLAG_UTF8, 8); c.writeUInt16LE(method, 10); c.writeUInt16LE(time, 12); c.writeUInt16LE(date, 14); c.writeUInt32LE(crc, 16); c.writeUInt32LE(compSize, 20); c.writeUInt32LE(uncompSize, 24); c.writeUInt16LE(nameBuf.length, 28); c.writeUInt16LE(0, 30); // extra len c.writeUInt16LE(0, 32); // comment len c.writeUInt16LE(0, 34); // disk number c.writeUInt16LE(0, 36); // internal attrs c.writeUInt32LE(0, 38); // external attrs c.writeUInt32LE(localOffset, 42); central.push(c, nameBuf); }; // 目录条目 (以 / 结尾), 保证空目录也能保留 for (const rel of dirEntries) { const nameBuf = Buffer.from(`${rel}/`, 'utf8'); const localOffset = pushLocalHeader(nameBuf, 0, 0, 0, 0); pushCentralHeader(nameBuf, 0, 0, 0, 0, localOffset); } let totalBytes = 0; for (const f of fileEntries) { const data = await fsp.readFile(f.abs); if (data.length > MAX_FILE_SIZE) { throw new Error(`文件超过 4GB, 需要 ZIP64 支持: ${f.rel} (${data.length} 字节)`); } const crc = crc32(data); // 先试 deflate, 若压缩后反而更大则退回 stored const deflated = zlib.deflateRawSync(data, { level: 9 }); const useDeflate = deflated.length < data.length; const payload = useDeflate ? deflated : data; const method = useDeflate ? 8 : 0; const nameBuf = Buffer.from(f.rel, 'utf8'); const localOffset = pushLocalHeader(nameBuf, method, crc, payload.length, data.length); chunks.push(payload); // ⚠️ 必须把文件数据长度计入 offset, 否则 EOCD 里的中央目录偏移会偏小, // 解压工具会报 "N extra bytes at beginning or within zipfile" offset += payload.length; pushCentralHeader(nameBuf, method, crc, payload.length, data.length, localOffset); totalBytes += data.length; } // EOCD const centralBuf = Buffer.concat(central); const eocd = Buffer.alloc(22); eocd.writeUInt32LE(SIG_EOCD, 0); eocd.writeUInt16LE(0, 4); eocd.writeUInt16LE(0, 6); eocd.writeUInt16LE(fileEntries.length + dirEntries.length, 8); eocd.writeUInt16LE(fileEntries.length + dirEntries.length, 10); eocd.writeUInt32LE(centralBuf.length, 12); eocd.writeUInt32LE(offset, 16); eocd.writeUInt16LE(0, 20); await fsp.writeFile(zipPath, Buffer.concat([...chunks, centralBuf, eocd])); return { files: fileEntries.length, dirs: dirEntries.length, bytes: totalBytes }; } function humanSize(bytes) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`; return `${(bytes / 1024 / 1024).toFixed(2)} MiB`; } /* ========================================================================== * 产出后自检 * * 手写 ZIP 编码器最容易出的两类 bug 都是**静默**的: * - 偏移算错 -> 文件内容对但部分条目解不出来 (不校验就发现不了) * - 标志位/分隔符写错 -> Windows 能解开, macOS/Linux 解出畸形路径 * 所以每次导出后都把 zip 重新解析一遍, 任何一项不符就判失败, 不输出「完成」。 * * 刻意不复用写入端的逻辑: 这里用独立的解析实现, 避免自证其说。 * ========================================================================== */ /** * @param {string} zipPath * @param {{withCerts?: boolean, skipRequired?: boolean}} opts * skipRequired: 仅供单元测试用夹具目录时跳过"必需文件"断言 * @returns {Promise<{entries:number, files:number, dirs:number}>} * @throws {Error} 任一项校验不通过 */ async function verifyZipFile(zipPath, opts = {}) { const buf = await fsp.readFile(zipPath); const fail = (msg) => { throw new Error(`zip 自检失败: ${msg}`); }; // --- 定位并校验 EOCD (从尾部往前扫, 兼容可能存在的注释) --- const EOCD_MIN = 22; if (buf.length < EOCD_MIN) fail('文件小于 EOCD 最小长度'); let eocd = -1; for (let i = buf.length - EOCD_MIN; i >= 0; i--) { if (buf.readUInt32LE(i) === SIG_EOCD) { eocd = i; break; } } if (eocd === -1) fail('找不到 EOCD 结尾记录'); const totalEntries = buf.readUInt16LE(eocd + 10); const centralSize = buf.readUInt32LE(eocd + 12); const centralOffset = buf.readUInt32LE(eocd + 16); if (centralOffset + centralSize > buf.length) { fail(`中央目录越界 (offset=${centralOffset} size=${centralSize} 文件长=${buf.length})`); } // --- 逐条遍历中央目录 --- const names = []; let p = centralOffset; for (let idx = 0; idx < totalEntries; idx++) { if (p + 46 > buf.length) fail(`第 ${idx} 条中央目录记录越界`); if (buf.readUInt32LE(p) !== SIG_CENTRAL) fail(`第 ${idx} 条中央目录签名错误`); const flags = buf.readUInt16LE(p + 8); const method = buf.readUInt16LE(p + 10); const crc = buf.readUInt32LE(p + 16); const compSize = buf.readUInt32LE(p + 20); const uncompSize = buf.readUInt32LE(p + 24); const nameLen = buf.readUInt16LE(p + 28); const extraLen = buf.readUInt16LE(p + 30); const commentLen = buf.readUInt16LE(p + 32); const localOffset = buf.readUInt32LE(p + 42); const nameBuf = buf.subarray(p + 46, p + 46 + nameLen); // 名称合法性: 必须正斜杠 + 合法 UTF-8 + 与 UTF-8 标志位一致 if (!(flags & FLAG_UTF8)) fail(`第 ${idx} 条缺少 UTF-8 标志位: ${nameBuf.toString('latin1')}`); const name = nameBuf.toString('utf8'); if (name.includes('\\')) fail(`条目名含反斜杠 (非 ZIP 规范): ${name}`); if (!Buffer.from(name, 'utf8').equals(nameBuf)) fail(`条目名不是合法 UTF-8: ${nameBuf.toString('hex')}`); names.push(name); // 本地文件头: 偏移必须真的指向一个本地头, 且文件名与中央目录一致 if (localOffset + 30 > buf.length) fail(`第 ${idx} 条 localOffset 越界: ${localOffset}`); if (buf.readUInt32LE(localOffset) !== SIG_LOCAL) fail(`第 ${idx} 条 localOffset 未指向本地文件头: ${localOffset}`); const lNameLen = buf.readUInt16LE(localOffset + 26); const lExtraLen = buf.readUInt16LE(localOffset + 28); const localName = buf.subarray(localOffset + 30, localOffset + 30 + lNameLen); if (!localName.equals(nameBuf)) fail(`第 ${idx} 条本地头与中央目录文件名不一致`); // 数据区: 解压后长度与 CRC 必须对得上 (目录条目除外) if (!name.endsWith('/')) { const dataStart = localOffset + 30 + lNameLen + lExtraLen; const raw = buf.subarray(dataStart, dataStart + compSize); if (raw.length !== compSize) fail(`第 ${idx} 条数据区越界`); let data; if (method === 0) data = raw; else if (method === 8) data = zlib.inflateRawSync(raw); else fail(`第 ${idx} 条压缩方式不支持: ${method}`); if (data.length !== uncompSize) fail(`第 ${idx} 条解压后长度不符 (${data.length} != ${uncompSize})`); if (crc32(data) !== crc) fail(`第 ${idx} 条 CRC 校验失败`); } p += 46 + nameLen + extraLen + commentLen; } if (p - centralOffset !== centralSize) { fail(`中央目录实际长度 ${p - centralOffset} != EOCD 记录 ${centralSize}`); } // --- 内容断言 --- if (!opts.skipRequired) { const required = [ 'mp-harmony/AppScope/app.json5', 'mp-harmony/build-profile.json5', 'mp-harmony/oh-package.json5', 'mp-harmony/hvigor/hvigor-config.json5', 'mp-harmony/entry/src/main/module.json5', 'mp-harmony/entry/oh-package.json5', 'mp-harmony/ascf/ascf_src/app.js', ]; if (opts.withCerts !== false) { required.push('mp-harmony/cert-profile/qq/qq.p12', 'mp-harmony/cert-profile/qq/qqDebug.p7b'); } const missing = required.filter((r) => !names.includes(r)); if (missing.length) fail(`缺少构建必需文件: ${missing.join(', ')}`); } // 按**路径分段**判断, 不用子串 —— 否则 .test 会误伤 entry/src/ohosTest/ets/test/ // 这类合法源码文件 (.test 排除的是名为 .test 的目录, 不是任何含 .test 的路径) const dirSegmentsOf = (n) => n.split('/').slice(0, -1); const fileNameOf = (n) => n.split('/').pop(); const leaked = names.filter((n) => dirSegmentsOf(n).some((s) => EXCLUDE_DIRS.has(s))); if (leaked.length) { fail(`混入了应排除的目录: ${leaked.slice(0, 3).join(', ')}${leaked.length > 3 ? ` ...共 ${leaked.length} 条` : ''}`); } const leakedFiles = names.filter((n) => !n.endsWith('/') && EXCLUDE_FILES.has(fileNameOf(n))); if (leakedFiles.length) { fail(`混入了应排除的文件: ${leakedFiles.slice(0, 3).join(', ')}`); } if (opts.withCerts === false && names.some((n) => n.includes(CERT_DIR))) { fail('--no-certs 生效异常: zip 内仍存在 cert-profile'); } const fileCount = names.filter((n) => !n.endsWith('/')).length; return { entries: totalEntries, files: fileCount, dirs: totalEntries - fileCount, }; } /** 校验暂存目录里构建必需的骨架文件都在 */ async function verifySkeleton(dir) { const required = [ 'AppScope/app.json5', 'build-profile.json5', 'oh-package.json5', 'hvigor/hvigor-config.json5', 'entry/src/main/module.json5', 'entry/oh-package.json5', 'ascf/ascf_src/app.js', ]; const missing = []; for (const rel of required) { if (!fs.existsSync(path.join(dir, rel))) missing.push(rel); } return missing; } async function main() { const opts = parseArgs(process.argv.slice(2)); const repoRoot = path.join(__dirname, '..'); const srcRoot = path.join(repoRoot, 'stub-app-projects', PROJECT_DIR_NAME); const outDir = opts.outDir || path.join(repoRoot, DEFAULT_OUT_DIR); if (!fs.existsSync(srcRoot)) { console.error(`❌ 源目录不存在: ${srcRoot}`); process.exitCode = 1; return; } console.log('📦 打包鸿蒙工程 (仅读取源目录, 不做任何修改)'); console.log(` 源目录: ${path.relative(repoRoot, srcRoot)}`); console.log(` 含证书: ${opts.withCerts ? '是 ⚠️ 包含私钥' : '否'}`); console.log(` 含 node_modules: ${opts.withNodeModules ? '是 (体积大)' : '否 (由 DevEco 自动拉取)'}`); // 暂存目录: 与源目录物理隔离, 保证源目录零改动 const stageParent = path.join(outDir, '.stage'); const stageDir = path.join(stageParent, PROJECT_DIR_NAME); await fsp.rm(stageDir, { recursive: true, force: true }); await fsp.mkdir(stageDir, { recursive: true }); const t0 = Date.now(); // stats 是模块级累加器, 同一进程内重复调用 main 时需先清零, 否则体积/数量会翻倍 stats.files = 0; stats.bytes = 0; stats.skipped = 0; await copyDir(srcRoot, stageDir, opts); console.log(`\n 复制完成: ${stats.files} 个文件 / ${humanSize(stats.bytes)} (跳过 ${stats.skipped} 项, ${Date.now() - t0}ms)`); // 写入说明文件 const readme = [ '# 鸿蒙元服务工程包', '', `导出时间: ${new Date().toLocaleString('zh-CN')}`, `包含签名证书: ${opts.withCerts ? '是 (cert-profile/, 含私钥, 请妥善保管)' : '否'}`, '', '## 在目标电脑上编译', '', '1. 安装 DevEco Studio 5.0.x (本工程 compatibleSdkVersion 5.0.0(12) / targetSdkVersion 5.0.0(12))', '2. 解压本 zip, 得到 mp-harmony/ 目录', '3. 用 DevEco Studio 「打开」该 mp-harmony 目录 (不是其父目录)', '4. 首次打开会自动执行依赖同步 (ohpm install 拉取 hvigor/node_modules), 需联网', ` ${opts.withNodeModules ? '' : ' 若目标机离线, 请在源机器用 --with-node-modules 重新导出\n'}`, '5. 菜单「Build」→「Build App(s)/Project(s)」, 等价于:', ' hvigorw --mode project -p product=default assembleApp', '6. 产物: build/outputs/default/mp-harmony-default-signed.app', '7. 上传 .app 到 AppGallery Connect (元服务上传的是 .app, 不是 .hap)', '', '## 注意事项', '', '- **ascf/ascf_src 是导出时刻的快照**, 内含 uniapp 编译产物 (页面/组件代码)。', ' 若前端代码有更新, 需回到源机器重新执行本脚本导出, 否则编译出的包内容是旧的。', opts.withCerts ? '- **本包含签名私钥** (cert-profile/qq/qq.p12)。请仅在可信渠道传递, 用完及时销毁或归档。' : '- **本包不含签名证书**。缺证书 hvigor 无法签名, 产不出可上传的 .app。\n 请把源机器的 cert-profile/ 目录放到 mp-harmony/ 下再编译。', '- hvigor/node_modules 未包含时由 DevEco 自动拉取; 联网失败会导致依赖同步失败。', '', ].join('\n'); // 文件名用 ASCII: 虽然本脚本写入的条目名是标准 UTF-8 且已置 bit11 标志位, // 但个别解压工具 (如 Windows 上的 MSYS unzip) 不认该标志位, 中文名会落盘成乱码。 // 说明文档正文保持中文, 文件名用 README.txt 兼容所有工具。 await fsp.writeFile(path.join(stageDir, 'README.txt'), readme, 'utf8'); // 校验骨架完整性 const missing = await verifySkeleton(stageDir); if (missing.length > 0) { console.error('\n❌ 导出包缺少构建必需文件:'); missing.forEach((m) => console.error(` - ${m}`)); console.error(' 请检查源目录是否完整 (是否执行过 git clean?)'); process.exitCode = 1; return; } if (!opts.withCerts && fs.existsSync(path.join(stageDir, CERT_DIR))) { console.error('❌ --no-certs 生效异常: 暂存目录仍存在 cert-profile'); process.exitCode = 1; return; } const certNote = opts.withCerts ? ' / 证书' : ''; console.log(` 骨架校验通过 (AppScope / build-profile / entry / ascf_src${certNote})`); // 压缩 await fsp.mkdir(outDir, { recursive: true }); const now = new Date(); const ts = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}`; let commitId = 'unknown'; try { const { stdout } = await execAsync('git rev-parse --short HEAD', { cwd: repoRoot }); commitId = stdout.trim(); } catch (e) { /* 非 git 环境忽略 */ } const zipPath = path.join(outDir, `${PROJECT_DIR_NAME}-project-${opts.withCerts ? '' : 'nocert-'}v${ts}-${commitId}.zip`); await zipDirectory(stageDir, zipPath); // 自检: 解析回 zip 逐项校验, 不通过就抛错 (绝不打印「完成」) const vres = await verifyZipFile(zipPath, { withCerts: opts.withCerts }); const zipSize = (await fsp.stat(zipPath)).size; // 输出目录可能在仓库外 (如 --out /tmp/xxx), 这时相对路径会变成 ../.. 很难读 const relZip = path.relative(repoRoot, zipPath); const shownZip = relZip.startsWith('..') ? zipPath : relZip; console.log(`\n✅ 完成`); console.log(` ZIP: ${shownZip}`); console.log(` 大小: ${humanSize(zipSize)} (原始 ${humanSize(stats.bytes)})`); console.log(` 条目: ${vres.files} 个文件 + ${vres.entries - vres.files} 个目录 (自检通过)`); console.log(` 自检: CRC 逐条校验 ✅ / 偏移一致 ✅ / 正斜杠 ✅ / UTF-8 ✅ / 骨架齐全 ✅`); // 清暂存目录 await fsp.rm(stageParent, { recursive: true, force: true }); if (opts.withCerts) { console.log('\n⚠️ 本包含签名私钥 (cert-profile/qq/qq.p12), 请仅在可信渠道传递。'); } } // 导出内部实现供单元测试使用; 作为命令行执行时才跑 main() module.exports = { crc32, toDosDateTime, zipDirectory, verifyZipFile, shouldSkipDir, shouldSkipFile, copyDir, EXCLUDE_DIRS, EXCLUDE_FILES, }; if (require.main === module) { main().catch((err) => { console.error('❌ 打包失败:', err.message); process.exitCode = 1; }); }