diff --git a/package.json b/package.json
index 3db959f..c1d6d00 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,7 @@
{
"scripts": {
"mp-weixin": "node scripts/mp-weixin.patch.js",
+ "pack:harmony": "node scripts/pack-harmony-project.js",
"i18n:check": "node scripts/i18n-check.js",
"test": "node scripts/test-gate.js",
"test:util": "node --test tests/util.test.js",
diff --git a/readme.md b/readme.md
index fd4ed1b..b45dda7 100644
--- a/readme.md
+++ b/readme.md
@@ -142,7 +142,54 @@ hvigorw --mode project -p product=default assembleApp
> 若只想单模块调试,可在 DevEco 中用「Build Hap(s)/Module(s)」(即 `assembleHap`),只产出分包、不打 APP。
-#### 4.3.3 【常见错误】
+#### 4.3.3 【工程包导出(换机编译)】
+
+需要在一台**没有本仓库 / 没有 HBuilderX** 的电脑上编译鸿蒙工程时,用脚本把 `stub-app-projects/mp-harmony` 里编译所需的文件导成一个 zip:
+
+```bash
+npm run pack:harmony
+```
+
+输出到 `unpackage/dist/harmony-export/mp-harmony-project-v<日期>-<提交ID>.zip`,解压后即得 `mp-harmony/` 目录。
+
+| 参数 | 用途 |
+|------|------|
+| (无参数) | 默认行为:含签名证书,不含 `node_modules` |
+| `--no-certs` | 不含 `cert-profile/`(见下方安全提示) |
+| `--with-node-modules` | 额外含 `hvigor/node_modules`(约 320 MB),供**离线**目标机 |
+| `--out <目录>` | 自定义输出目录 |
+
+**脚本全程只读源目录**,产物先复制到暂存目录再压缩,不修改 `stub-app-projects` 下任何文件。
+
+**导出内容**
+
+| 类别 | 说明 |
+|------|------|
+| 工程源码 | `AppScope/`、`entry/src/`、`ascf/ascf_build/*/src/`、各类 `*.json5` / `hvigorfile.ts` |
+| `ascf/ascf_src/` | uniapp 编译产物(页面/组件代码),**带上它目标机就不需要本仓库和 HBuilderX** |
+| `cert-profile/` | 签名证书(`--no-certs` 可排除) |
+
+**已排除**(可由目标机 DevEco 重新生成)
+
+`node_modules/`(约 320 MB)、`oh_modules/`、`**/build/`、`.preview/`(约 22 MB)、`.idea/`、`.hvigor/`、`local.properties`、`.cxx/`、`.test/`
+
+> ⚠️ **证书安全**:默认导出**包含签名私钥** `cert-profile/qq/qq.p12`,且 p12 密码在 `build-profile.json5` 中本就是明文。请仅在可信渠道传递,用完及时销毁或归档。对外发给不可信对象时务必加 `--no-certs`。
+>
+> 📌 缺证书 hvigor 无法签名,产不出可上传的 `.app`。用 `--no-certs` 时需自行把 `cert-profile/` 放到目标工程对应位置。
+
+**目标机操作**
+
+1. 安装 DevEco Studio 5.0.x(本工程 `compatibleSdkVersion` 5.0.0(12))
+2. 解压 zip,得到 `mp-harmony/`
+3. DevEco Studio「打开」`mp-harmony` 目录本身
+4. 首次打开自动执行依赖同步(`ohpm install` 拉取 `hvigor/node_modules`),**需联网**;离线机请用 `--with-node-modules` 导出
+5. 「Build」→「Build App(s)/Project(s)」,产物 `build/outputs/default/mp-harmony-default-signed.app`
+
+> ⚠️ **`ascf/ascf_src/` 是导出时刻的快照**。若前端代码有更新,必须回源机器重新执行导出,否则编译出的包内容是旧的。
+
+📌 zip 由脚本内纯 Node 实现(`zlib` + 手写 ZIP 头)生成,条目名统一正斜杠并置 UTF-8 标志位,可跨 Windows/macOS/Linux 使用。
+
+#### 4.3.4 【常见错误】
**❌ `hvigor ERROR: 00303218 Configuration Error`**
```
@@ -216,17 +263,17 @@ onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
📌 用户拒绝后的降级:不影响浏览/下单/卡片等其他功能,仅语音输入不可用;[ai-chat-voice-panel.vue](pages_tool/ai-chat/components/ai-chat-voice-panel.vue) 会 catch 到 recorder 错误并 `showToast` 提示用户。
-#### 4.3.4 【待完善】⚠️
+#### 4.3.5 【待完善】⚠️
- **release 签名仍指向 debug 证书**:`build-profile.json5` 中 `release` 签名的 `profile` 仍是 `./cert-profile/qq/qqDebug.p7b`,与 `default`(debug)共用同一份证书。本地调试无碍,但提交 AppGallery 前必须换成**发布证书 + release 类型 provisioning profile**,否则 AGC 校验会直接拒绝。
-#### 4.3.5 【交付与最终发布】🔍
+#### 4.3.6 【交付与最终发布】🔍
1. 将生成的 `.zip` 文件发送给运维人员
2. **运维人员操作流程**:
- 解压压缩包
- 打开鸿蒙开发者工具,导入解压后的代码目录
- 验证代码无报错后,执行上传发布操作
-#### 4.3.6 【本地设置】
+#### 4.3.7 【本地设置】

---
diff --git a/scripts/pack-harmony-project.js b/scripts/pack-harmony-project.js
new file mode 100644
index 0000000..d3434ed
--- /dev/null
+++ b/scripts/pack-harmony-project.js
@@ -0,0 +1,444 @@
+#!/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`;
+}
+
+/** 校验暂存目录里构建必需的骨架文件都在 */
+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;
+});