Files
nodeagent/scripts/pack.mjs
T
jkooe a0be43d76c refactor(pack): 被控端 esbuild 配置抽为单一实现
`agent.mjs` 现在有两个消费方:
  1. scripts/pack.mjs            → Windows 便携包 / 自更新载荷(Release 资产)
  2. scripts/prepare-console.mjs → 桌面控制台的随包资源(控制台要能托起本机被控端)

若各写一份 esbuild 配置就是「两套真相」——改了一处漏另一处时构建过、单测过,
只在真机产物里暴露(本项目已多次吃过这类亏)。故配置只此一份。

bundle-agent.mjs 导出 repoRoot / repoVersion / gitShortCommit / bundleAgent,
沿用原有全部约束:JS API(pnpm 隔离布局下 .bin/esbuild 在 Windows 不存在)、
不注入构建时间(否则每次打包字节不同,指纹比对失真)、
define 用 JSON.stringify 得纯双引号。

本次对 pack.mjs 的输出无语义变化(同一份 options 原样迁移)。
2026-10-11 11:41:38 +08:00

257 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 构建 Windows 便携安装包。
*
* 用法: node scripts/pack.mjs [--node v22.20.0] [--out release]
*
* 产物: <out>/nodeagent-win-<arch>.zip(arch 默认 x64,见 --arch)
* ├── node.exe Node.js 运行时(免装 Node.js)
* ├── agent.mjs esbuild 打包的被控端(含全部依赖)
* ├── install.cmd 一键安装(双击即用,自提权 + 自动生成 PSK)
* ├── control.cmd 控制台菜单(启动/停止/状态/日志/卸载)
* ├── install.ps1 底层安装脚本(由 install.cmd 调用)
* ├── control.ps1 底层控制脚本(由 control.cmd 调用)
* └── uninstall.ps1 底层卸载脚本
*
* 注意:PSK.txt **不打进 zip**(zip 会作为公开 Release 资产发布,含密钥=公开密钥);
* 它在被控端首次运行 install.cmd 时生成,只留在本机安装目录。
*/
import { execFileSync, execSync } from 'node:child_process';
import {
copyFileSync, createWriteStream, existsSync, mkdirSync, readdirSync,
readFileSync, rmSync, statSync, writeFileSync,
} from 'node:fs';
import { get } from 'node:https';
import { randomBytes } from 'node:crypto';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { bundleAgent } from './lib/bundle-agent.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const args = process.argv.slice(2);
const getArg = (k, d) => {
const i = args.indexOf(k);
return i >= 0 && args[i + 1] ? args[i + 1] : d;
};
const NODE_VERSION = getArg('--node', 'v22.20.0');
// 2026-10-11:Windows 也分架构。x64 包在 **ARM Windows(含 Parallels 的 Win11 ARM)** 上
// 能启动、能 listen,但 **TLS 握手不完成**(Prism 模拟下协议层异常)→ 必须按架构出包。
// 用法:node scripts/pack.mjs --arch arm64 → nodeagent-win-arm64.zip
const NODE_ARCH = getArg('--arch', 'x64');
const OUT_DIR = getArg('--out', join(root, 'release'));
const PKG_DIR = join(OUT_DIR, `nodeagent-win-${NODE_ARCH}`);
const MIRROR = process.env.NODEAGENT_NODE_MIRROR ?? 'https://npmmirror.com/mirrors/node';
const NODE_ZIP_URL = `${MIRROR}/${NODE_VERSION}/node-${NODE_VERSION}-win-${NODE_ARCH}.zip`;
const log = (msg) => console.log(msg);
const fail = (msg) => {
console.error(`✗ ${msg}`);
process.exit(1);
};
async function download(url, dest) {
log(` 下载 ${url}`);
return new Promise((resolve, reject) => {
const follow = (u, redirects = 0) => {
get(u, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
if (redirects > 5) return reject(new Error('重定向过多'));
return follow(res.headers.location, redirects + 1);
}
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
const file = createWriteStream(dest);
res.pipe(file);
file.on('finish', () => file.close(() => resolve()));
file.on('error', reject);
}).on('error', reject);
};
follow(url);
});
}
async function main() {
log('=== ① 构建 workspace ===');
execSync('pnpm -r build', { cwd: root, stdio: 'inherit' });
log('\n=== ② esbuild 打包被控端 ===');
// 版本源(根 package.json 的 version + git sha)与 esbuild 配置都在
// `scripts/lib/bundle-agent.mjs` 里**单一实现** —— 控制台打包(prepare-console.mjs)
// 用的是同一个函数,避免两处配置各自漂移。
mkdirSync(OUT_DIR, { recursive: true });
const bundleOut = join(OUT_DIR, 'agent.mjs');
const bundled = await bundleAgent({ outfile: bundleOut });
log(` 版本 ${bundled.version} @ ${bundled.commit}(${(bundled.bytes / 1024 / 1024).toFixed(1)}MB)`);
log(`\n=== ③ 下载 Node.js Windows 运行时(${NODE_ARCH})===`);
// 缓存文件名**必须含架构**。曾用固定名 node-runtime.zip:release 先打 x64(下载并缓存),
// 再打 arm64 时误判「已缓存」→ 跳过下载 → 把 x64 的 node.exe 装进了 arm64 包。
// (真例:v2.0.1 的 nodeagent-win-arm64.zip 内 node.exe 实为 x86-64。)
const tmpZip = join(OUT_DIR, `node-runtime-${NODE_ARCH}.zip`);
if (existsSync(tmpZip)) {
log(' 已缓存,跳过下载');
} else {
await download(NODE_ZIP_URL, tmpZip);
}
log('\n=== ④ 组装便携包 ===');
rmSync(PKG_DIR, { recursive: true, force: true });
mkdirSync(PKG_DIR, { recursive: true });
execSync(`unzip -o -j "${tmpZip}" "*/node.exe" -d "${PKG_DIR}"`, { stdio: 'inherit' });
// 防呆 C:产物 node.exe 的 PE 架构必须与 --arch 一致。
// 这是**根治性守卫**:缓存串包、镜像发错、手工塞错文件,都会在此当场红,
// 而不是拖到「被控端在 ARM Windows 上 TLS 握手不完成」才由真机暴露。
// PE 头:偏移 0x3c 处是 PE 签名偏移,+4 是 Machine 字段(0x8664=x86-64,0xaa64=ARM64)。
const PE_MACHINE = { x64: 0x8664, arm64: 0xaa64 };
const PE_NAME = { 0x8664: 'x86-64', 0xaa64: 'ARM64', 0x14c: 'i386' };
const nodeExe = join(PKG_DIR, 'node.exe');
{
const head = readFileSync(nodeExe).subarray(0, 4096);
const machine = head.readUInt16LE(head.readUInt32LE(0x3c) + 4);
if (machine !== PE_MACHINE[NODE_ARCH]) {
fail(`node.exe 架构不符:--arch=${NODE_ARCH},实际 ${PE_NAME[machine] ?? `0x${machine.toString(16)}`}。` +
`多半是缓存串包 —— 删掉 ${OUT_DIR}/node-runtime-*.zip 后重试。`);
}
log(` node.exe 架构校验通过(${NODE_ARCH} / ${PE_NAME[machine]})`);
}
copyFileSync(bundleOut, join(PKG_DIR, 'agent.mjs'));
copyFileSync(join(root, 'scripts', 'install.ps1'), join(PKG_DIR, 'install.ps1'));
copyFileSync(join(root, 'scripts', 'control.ps1'), join(PKG_DIR, 'control.ps1'));
copyFileSync(join(root, 'scripts', 'uninstall.ps1'), join(PKG_DIR, 'uninstall.ps1'));
// 一键入口:.cmd 必须**纯 ASCII**。CMD 的代码页(中文 Windows = 936/GBK)
// 无法可靠往返 UTF-8,中文提示一律交给带 BOM 的 .ps1 去输出。
for (const cmd of ['install.cmd', 'control.cmd', 'run-portable.cmd']) {
copyFileSync(join(root, 'scripts', cmd), join(PKG_DIR, cmd));
}
// 防呆 A:产物里的 .ps1 必须带 UTF-8 BOM,否则被控端的 **Windows PowerShell 5.1**
// 会按 ANSI/GBK 解析无 BOM 脚本,中文注释破坏字符串边界 → 整份脚本 ParseError。
// 真机踩过(2026-10-04):CI 全绿(冒烟用 pwsh/PS7),5.1 真机直接报
// 「","后面缺少表达式」。此处宁可在打包阶段失败,也不要把坏包发出去。
for (const ps1 of ['install.ps1', 'control.ps1', 'uninstall.ps1']) {
const p = join(PKG_DIR, ps1);
const head = readFileSync(p).subarray(0, 3);
if (!(head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf)) {
fail(`${ps1} 缺少 UTF-8 BOM —— Windows PowerShell 5.1 会解析失败。` +
`请先给 scripts/${ps1} 加上 BOM 再打包。`);
}
}
log(' .ps1 BOM 检查通过(兼容 Windows PowerShell 5.1)');
// 防呆 B:.cmd 里出现非 ASCII 字节 = 中文乱码/命令截断的隐患。
for (const cmd of ['install.cmd', 'control.cmd', 'run-portable.cmd']) {
const buf = readFileSync(join(PKG_DIR, cmd));
const bad = [...buf].findIndex((b) => b > 0x7f);
if (bad >= 0) {
fail(`${cmd} 含非 ASCII 字节(偏移 ${bad})—— CMD 代码页无法可靠处理,` +
`请把该文件改为纯 ASCII,中文提示交给 .ps1。`);
}
// 行尾必须是 CRLF:LF-only 的 .cmd 在部分 Windows 环境会解析异常
const lf = buf.filter((b) => b === 0x0a).length;
const crlf = buf.filter((b, i) => b === 0x0a && buf[i - 1] === 0x0d).length;
if (lf !== crlf) {
fail(`${cmd} 含 ${lf - crlf} 处 LF-only 行尾 —— 打包前须统一为 CRLF。`);
}
}
log(' .cmd 纯 ASCII 检查通过(CMD 代码页安全)');
// PSK.txt:预置一把随机密钥,让 install.cmd 真正「零输入」。
// 用 --psk 传入指定密钥;不传则随机生成 32 字节 hex。
// 文件是纯 hex 文本,无需 BOM(install.cmd 用 `set /p` 读)。
const pskArg = getArg('--psk', '');
const psk = pskArg || randomBytes(32).toString('hex');
if (!/^[0-9a-fA-F]{32,128}$/.test(psk)) {
fail(`--psk 格式非法(需 32-128 位十六进制): ${psk}`);
}
writeFileSync(join(PKG_DIR, 'PSK.txt'), `${psk}\n`, 'utf8');
log(` PSK.txt 已生成(${psk.length} 位${pskArg ? ',来自 --psk' : ',随机'})`);
// README:给第一次上手的人看一眼该双击哪个文件
writeFileSync(join(PKG_DIR, 'README.txt'), [
'nodeagent - Windows 被控端',
'==========================',
'',
'【怎么装】双击 install.cmd',
' 1) 会弹一次「用户账户控制」→ 点「是」(必须管理员,装计划任务要用)',
' 2) 等它跑完,最后会打印连接信息',
' 3) 把那行 nodeagent connect ... 拿到 Mac 上执行',
'',
'【日常管理】双击 control.cmd',
' 1 状态 / 2 启动 / 3 停止 / 4 重启 / 5 日志 / 6 卸载 / 7 彻底卸载',
'',
'【免安装试用,不装也行】双击 run-portable.cmd',
' * 不需要管理员、不写计划任务、Ctrl+C 即停',
' * 首次运行自动生成配置与密钥(agent.json),并在控制台打印连接命令',
' * run-portable.cmd background = 最小化后台启动;stop = 结束;status = 看状态',
' * 适合临时测一下;长期用请走 install.cmd(有开机自启与进程守护)',
'',
'【密钥】本包**不含** PSK.txt(避免公开渠道泄露密钥)。密钥来源按序为:',
' 1) 若本目录已有 PSK.txt -> 直接用它',
' 2) 若本机已有 %USERPROFILE%\\.nodeagent\\agent.json -> 复用其中的 key',
' 3) 都没有 -> 自动生成一把随机密钥',
' 装完 / 免安装启动后,密钥会打印在屏幕上;也可用 run-portable.cmd status 查看',
' 这就是 Mac 端 --key 要填的值',
'',
'【注意】',
' * 整个文件夹可以固定放在 D:\\nodeagent,但**装完不要移动或改名**',
' * agent 每次开机登录后自动启动,无需再双击任何东西',
' * 首次运行若被杀软拦截,把本文件夹加入信任区后重试',
'',
].join('\n'), 'utf8');
const entries = readdirSync(PKG_DIR, { withFileTypes: true })
.filter((e) => e.isFile())
.map((e) => {
const st = statSync(join(PKG_DIR, e.name));
return `${e.name.padEnd(22)} ${(st.size / 1024).toFixed(0).padStart(8)} KB`;
});
log(entries.join('\n'));
log('\n=== ⑤ 打 zip ===');
const zipOut = join(OUT_DIR, `nodeagent-win-${NODE_ARCH}.zip`);
rmSync(zipOut, { force: true });
// ⚠️ 必须排除 PSK.txt:本 zip 会作为**公开的 GitHub Release 资产**发布,
// 把预共享密钥打进包里等于公开密钥。install.cmd 在文件缺失时会自动生成新密钥
// (生成在安装目录,只留在被控端本机),所以排除它不影响安装。
if (process.platform === 'win32') {
// Windows:GitHub runner / 多数 Windows 没有 Info-ZIP。
// 先删掉 PSK.txt(若存在)再压 —— 打包器无法按需排除单个文件。
const psk = join(PKG_DIR, 'PSK.txt');
if (existsSync(psk)) rmSync(psk, { force: true });
// ⚠️ 刻意不用 `Compress-Archive -Path '<dir>\*'`:它会把**目录名当前缀**写进条目
// (产物变成 `<dir>/node.exe`),而 macOS 的 `cd <dir> && zip -r .` 是**裸名** `node.exe`
// —— 两平台产物**结构不一致**。看着是小事,代价却不小:
// ① 按条目名取文件的校验会失配(2026-10-11 真例:CI 在 Windows 上报
// 「zip 内未找到条目 node.exe」,而 macOS 本地全绿 → 方向恰好相反);
// ② CI 校验的产物与发布产物**不同构**,校验价值打折。
// 改用 .NET 的 ZipFile::CreateFromDirectory —— 它打包**目录内容**,条目同样是裸名。
execFileSync(
'powershell',
[
'-NoProfile', '-Command',
`Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
`[IO.Compression.ZipFile]::CreateFromDirectory('${PKG_DIR}', '${zipOut}')`,
],
{ stdio: 'inherit' },
);
} else {
execSync(`cd "${PKG_DIR}" && zip -q -r "${zipOut}" . -x "PSK.txt"`, { stdio: 'inherit' });
}
const kb = statSync(zipOut).size / 1024;
const size = kb > 1024 ? `${(kb / 1024).toFixed(0)}M` : `${kb.toFixed(0)}K`;
log(`\n✓ 完成: ${zipOut}(${size})`);
log(' 分发方式:解压到固定目录 → 双击 install.cmd(自提权,零命令行知识)');
log(' 日后管理:双击 control.cmd');
}
try {
await main();
} catch (err) {
fail(err instanceof Error ? err.message : String(err));
}