Files
lucky_shop/scripts/pack-harmony-project.js
ZF sun 77b1a3372c feat:一键导出鸿蒙工程包,支持换机编译
把 stub-app-projects/mp-harmony 中编译所需的文件导成 zip, 供没有本仓库/没有
HBuilderX 的电脑解压后直接用 DevEco 编译上传。

用法:
  npm run pack:harmony                        # 默认: 含证书, 不含 node_modules
  npm run pack:harmony -- --no-certs          # 不含签名证书
  npm run pack:harmony -- --with-node-modules # 含 hvigor/node_modules(320MB), 离线机
  npm run pack:harmony -- --out <dir>        # 自定义输出目录

输出 unpackage/dist/harmony-export/mp-harmony-project-*.zip, 内含一层 mp-harmony/。

实现要点:
- 全程只读源目录: 先复制到暂存目录再压缩, 不修改 stub 下任何文件
- 采用「全量复制 - 排除清单」而非白名单, 避免漏掉新增必需文件
- 包含 ascf/ascf_src (uniapp 编译产物快照), 目标机因此无需本仓库和 HBuilderX
- 包含 cert-profile 签名证书, 否则 hvigor 无法签名, 产不出可上传的 .app
- 排除 node_modules(320M)/oh_modules/build/.preview(22M)/.idea/.hvigor/local.properties

ZIP 由纯 Node 实现 (zlib + 手写 ZIP 头), 未用系统命令, 因为本机两种方案都产出
不合规 zip 且失败是静默的:
- PowerShell Compress-Archive / ZipFile::CreateFromDirectory 写反斜杠条目名, 非
  ZIP 规范, macOS/Linux 解压出畸形路径
- 本机 tar 是 GNU tar 1.35, 不支持创建 zip 且 exit 0, try/catch 拦不住

已验证: 解压后骨架齐全(AppScope/build-profile/entry/ascf_src/证书/README),
排除项均不存在, unzip -t 无错误, 中文文件名改 ASCII 规避工具编码差异。
2026-10-17 14:19:54 +08:00

445 lines
18 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 };
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`;
}
/** 校验暂存目录里构建必需的骨架文件都在 */
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();
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`);
const zres = await zipDirectory(stageDir, zipPath);
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(` 条目: ${zres.files} 个文件 + ${zres.dirs} 个目录, 条目名统一正斜杠 + UTF-8`);
// 清暂存目录
await fsp.rm(stageParent, { recursive: true, force: true });
if (opts.withCerts) {
console.log('\n⚠️ 本包含签名私钥 (cert-profile/qq/qq.p12), 请仅在可信渠道传递。');
}
}
main().catch((err) => {
console.error('❌ 打包失败:', err.message);
process.exitCode = 1;
});