Files
nodeagent/scripts/pack.mjs
T
jkooe cb468df7da feat(agent): 看门狗自动恢复 LKG —— 启动连续失败 N 次从 LKG 恢复(决策 R-1)
scripts/startup-guard.mjs + scripts/lib/startup-guard-core.mjs(新增):
- 被控端启动连续失败 N 次(默认 3)自动从 LKG 指纹匹配的备份恢复入口脚本
- 30s 启动宽限期 + 端口探活,避免计划内重启与 supervisor 重启撞车被误计数
- 无 LKG / 无指纹匹配只记日志放行,绝不擦除 install-dir
- 恢复成功或服务已就绪均归零失败计数;被控端启动成功后 resetStartupGuardState 归零
  (apps/agent/src/index.ts 为本任务对 agent 的唯一改动点)

三端 supervisor 接入:
- Windows install.ps1:计划任务包一层 guard,RestartCount 3→10(保证阈值可达)
- Linux install-linux.sh:systemd unit 的 ExecStart 包一层 guard
- macOS launchd:无安装器,docs/DEVELOPMENT.md §7.4 给出完整接法

docs:DELIVERY-PIPELINE §6 由「不做自动回滚」收窄为
「手动 rollback + 看门狗自动恢复 + 边界明示」,附录 C 同步;
DEVELOPMENT.md 新增 §7.4 三端 supervisor 接入说明。
2026-10-26 00:09:56 +08:00

268 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(含虚拟化平台的 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'));
// 看门狗(v2.1.0,决策 R-1):install.ps1 把计划任务的启动动作包一层它 ——
// 被控端「启动连续失败 3 次」时自动从 LKG 恢复入口文件。
// ⚠️ 必须保持 scripts/lib/ 的相对位置:startup-guard.mjs 以 './lib/startup-guard-core.mjs'
// 相对导入其核心纯函数(裸环境零依赖,见该文件头)。
mkdirSync(join(PKG_DIR, 'scripts', 'lib'), { recursive: true });
copyFileSync(join(root, 'scripts', 'startup-guard.mjs'), join(PKG_DIR, 'scripts', 'startup-guard.mjs'));
copyFileSync(
join(root, 'scripts', 'lib', 'startup-guard-core.mjs'),
join(PKG_DIR, 'scripts', 'lib', 'startup-guard-core.mjs'),
);
// 一键入口:.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));
}