Files
lucky_shop/scripts/pack-harmony-project.js
ZF sun 7909cf2b22 test:导出脚本增加零漏拷/零多拷硬保证
质疑"某个目录有没有被打进 zip"时, 原先只能人工逐目录核对。现在把这个检查做进
脚本, 每次导出都跑:

copyDir 记录每个被复制文件在 zip 中应有的条目名, verifyZipFile 把它与实际 zip
条目做**逐个双向比对** —— 少一个报"漏拷"并列出文件名, 多一个报"多拷"。这样
漏拷会当场失败, 而不是等在别的电脑上解压缩时才发现。

自检输出新增一行, 例如:
  自检: 零漏拷 ✅(1381/1381) / CRC 逐条 ✅ / 偏移一致 ✅ / ...

同时把 copyDir 的条目名拼接由"从 nameOfParent 反推"改为显式传 relBase, 去掉
原先那段绕的 split/map 逻辑。

新增 3 条测试(漏拷拒绝 / 多拷拒绝 / 清单吻合时放行且 README.txt 豁免), 本
测试文件 19/19 通过, npm test 全量 17 个测试文件通过。
2026-10-17 16:13:09 +08:00

640 lines
27 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <dir> # 自定义输出目录 (默认 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, copied: [] };
/**
* 复制源目录到暂存目录 (应用排除规则), 同时记录每个被复制文件在 zip 中应有的条目名
* @param {string} srcDir
* @param {string} destDir
* @param {object} opts
* @param {string} relBase 该目录在 zip 中的条目名前缀 (首层传 PROJECT_DIR_NAME)
* @param {number} depth
*/
async function copyDir(srcDir, destDir, opts, relBase = PROJECT_DIR_NAME, depth = 0) {
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, `${relBase}/${entry.name}`, depth + 1);
} 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;
stats.copied.push(`${relBase}/${entry.name}`);
}
// 符号链接等其它类型直接跳过
}
}
/* ==========================================================================
* 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, expectFiles?: string[], rootName?: string}} opts
* skipRequired: 仅供单元测试用夹具目录时跳过"必需文件"断言
* expectFiles: 实际复制到暂存目录的文件清单 (zip 内条目名), 用于零漏拷/零多拷比对
* rootName: zip 内顶层目录名 (默认 mp-harmony)
* @returns {Promise<{entries:number, files:number, dirs:number}>}
* @throws {Error} 任一项校验不通过
*/
async function verifyZipFile(zipPath, opts = {}) {
const rootName = opts.rootName || PROJECT_DIR_NAME;
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;
// --- 零漏拷 / 零多拷 比对 ---
// expectFiles 是 copyDir 实际复制到暂存目录的文件清单 (已应用排除规则)。
// 逐个核对 zip 内是否都有, 且 zip 内没有清单之外的文件 (README.txt 是后补的, 除外)。
// 这样"某个目录没被打进去"这类问题会被脚本当场拦下, 不必人工逐目录核对。
if (Array.isArray(opts.expectFiles)) {
const nameSet = new Set(names);
const missingFiles = opts.expectFiles.filter((f) => !nameSet.has(f));
if (missingFiles.length) {
fail(`漏拷: ${missingFiles.length} 个已复制的文件不在 zip 内, 例如 ${missingFiles.slice(0, 5).join(', ')}`);
}
const expected = new Set(opts.expectFiles);
expected.add(`${rootName}/README.txt`);
const unexpected = names.filter((n) => !n.endsWith('/') && !expected.has(n));
if (unexpected.length) {
fail(`多拷: ${unexpected.length} 个 zip 内文件不在复制清单中, 例如 ${unexpected.slice(0, 5).join(', ')}`);
}
}
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;
stats.copied = [];
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 逐项校验, 不通过就抛错 (绝不打印「完成」)
// expectFiles 传入实际复制清单, 让脚本自己证明"零漏拷", 无需人工逐目录核对
const vres = await verifyZipFile(zipPath, {
withCerts: opts.withCerts,
expectFiles: stats.copied,
rootName: PROJECT_DIR_NAME,
});
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(` 自检: 零漏拷 ✅(${stats.copied.length}/${stats.copied.length}) / 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;
});
}