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