Files
MailUI4Agents/deploy/check-plugin-snapshot.mjs
JianFeeeee 0549975555 feat(deploy): 插件规范化部署 —— 生产跑仓库外快照,可回滚
# 问题(实测的加载形态)

    pi        systemd: node /home/program/agentmail/plugins/pi-mail-bridge/src/index.mjs
    opencode  opencode.jsonc: "file:///home/program/agentmail/plugins/opencode-mail-bridge"
    dsh       profiles/web/package.json: "dsh-mail-bridge": "link:/home/program/.../dsh-mail-bridge"

三个桥跑的都是**仓库工作区**。于是:一次编辑 + 重启就是上线(没有构建、没有评审、
没有版本);**没有回滚目标**(网关有 `.bak-<时间戳>`,三个桥一个都没有);
`git checkout` / `git stash` / 半成品编辑会静默改变线上行为;仓库同时兼作构建目录
(`dist/`、`node_modules/` 都在里面)。

对照:homeagent 插件本来就是这个规范形态(跑 `/home/newqqagent/plugins/
homeagent-mail-bridge/plugin.bin` 部署副本)—— 所以这里不是发明新办法,
而是把已有的那个形态推广到三个 JS 桥。

# 本提交只交付工具与门禁,**未切换生产**

`deploy/redeploy-plugin.sh <pi|opencode|dsh> [--stage-only]`:

  1 前置断言 → 2 staging(仓库外)→ 3 门禁 → 4 原子切换 `ln -sfn <ts> current`
  → 5 重启 → 6 后置验证 → 任一步失败即切回 `.prev` 并重启

后置验证不只看 `systemctl is-active`:桥可能进程活着却没连上 Gateway
(密钥失效、Gateway 未起、依赖在惰加载时才暴露),所以**必须以日志出现
「已接入」为准**,45 秒轮询。

需要编译的插件(dsh)**产出到 staging**,不写仓库的 `dist/`:先在仓库构建再拷贝
会有两个问题 —— 失败的构建也会 emit(tsc 默认 `noEmitOnError=false`)导致仓库产物
被半成品覆盖;以及生产产物与工作区之间多一条看不见的耦合。

# 依赖门禁:不能用 require.resolve

`deploy/check-plugin-snapshot.mjs` 从入口递归收集静态 import/export/动态
import/require 的说明符并逐个解析。

**判据用 ESM 而非 CJS 解析**——这是实测教训:`@earendil-works/pi-coding-agent`
的 `exports` 只声明 `"import"`,`require.resolve` 抛 ERR_PACKAGE_PATH_NOT_EXPORTED,
而插件是 `import` 它的、实际毫无问题。用错 API 会让门禁**报假缺陷**,
而假缺陷比不检查更糟(会让人去修一个没坏的东西)。

也不能只比对 `package.json` 的 dependencies:pi 的 dependencies 是 `{}`,
而它 import 了 `@earendil-works/pi-coding-agent` —— 声明是假的,只查声明等于空跑。

# 已验证(干跑,未切换、未重启)

  pi       20/20 说明符可解析          opencode 23/23          dsh 26/26
  dsh 经 tsc 构建到 staging 后通过

反向验证:拿掉一个依赖 → 门禁 rc=1 并点名该说明符(不是空转)。
2026-09-12 11:36:25 +08:00

155 lines
5.7 KiB
JavaScript
Raw Permalink 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
/**
* 校验一份插件快照能否独立启动 —— 逐个解析入口的静态 import。
*
* # 为什么不能只看 package.json 的 dependencies
*
* 实测:`plugins/pi-mail-bridge/package.json` 的 `dependencies` 是 `{}`
* 而它的入口 `import` 了 `@earendil-works/pi-coding-agent`。也就是说**声明是假的**
* (依赖实际靠 node_modules 里的存在,而不是靠声明)。只比对声明项,这个检查
* 就是空跑。
*
* 漏掉的代价发生在最糟的时刻:拷贝完成、`current` 已切、服务重启,
* 然后插件起不来 —— 而那时旧版本已经被换掉了。
*
* # 做法
*
* 从入口出发按相对路径递归,收集所有静态 `import` / `export ... from` 的
* 裸说明符bare specifier再用 Node 的解析器逐个解析。解析失败即为缺失。
*
* 只做**解析**不做执行:这个脚本不会启动插件(启动会连 Gateway、起 SSE 循环,
* 那不是一次门禁该干的事)。
*
* 用法: node check-plugin-snapshot.mjs <快照目录> <入口相对路径>
* 退出码: 0=全部可解析 1=有缺失 2=用法错误
*/
import { readFileSync, existsSync, statSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
const [snapshot, entry] = process.argv.slice(2);
if (!snapshot || !entry) {
console.error('用法: node check-plugin-snapshot.mjs <快照目录> <入口相对路径>');
process.exit(2);
}
if (!existsSync(snapshot) || !statSync(snapshot).isDirectory()) {
console.error(`快照目录不存在: ${snapshot}`);
process.exit(2);
}
const entryPath = join(snapshot, entry);
if (!existsSync(entryPath)) {
console.error(`入口不存在: ${entryPath}`);
process.exit(2);
}
// 静态 import 的三种写法。动态 import() 与 require() 也一并认,
// 它们同样会在启动时炸;宁可多查一个说明符,也不要漏掉一个。
const SPECIFIER_PATTERNS = [
/\bimport\s+[^'"()]*from\s*['"]([^'"]+)['"]/g,
/\bimport\s*['"]([^'"]+)['"]/g,
/\bexport\s+[^'"()]*from\s*['"]([^'"]+)['"]/g,
/\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
/\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g
];
const seenFiles = new Set();
const specifiers = new Map(); // specifier -> 首次出现它的文件
function scan(file) {
if (seenFiles.has(file)) return;
seenFiles.add(file);
let src;
try {
src = readFileSync(file, 'utf8');
} catch {
return;
}
for (const re of SPECIFIER_PATTERNS) {
for (const m of src.matchAll(re)) {
const spec = m[1];
if (!specifiers.has(spec)) specifiers.set(spec, file);
// 相对/绝对路径继续往下走(只跟本地文件,不跟 node_modules
if (spec.startsWith('.')) {
const base = resolve(dirname(file), spec);
for (const cand of [base, `${base}.js`, `${base}.mjs`, `${base}.cjs`, join(base, 'index.js')]) {
if (existsSync(cand) && statSync(cand).isFile()) {
scan(cand);
break;
}
}
}
}
}
}
scan(entryPath);
/*
* 解析必须走 **ESM**,不能用 require.resolve。
*
* 实测教训:`@earendil-works/pi-coding-agent` 的 package.json 里
* `exports` 只声明了 `"import"`(没有 `"require"`),于是
* `require.resolve` 抛 ERR_PACKAGE_PATH_NOT_EXPORTED —— 而插件是
* `import` 它的,实际解析毫无问题。用错 API 会让这个门禁**报假缺陷**
* 而假缺陷比不检查更糟:它会让人去"修"一个根本没坏的东西。
*
* 所以在子进程里按快照目录做 ESM 解析,一次批量传所有说明符。
*/
const probe = `
// 子进程是独立上下文:这里必须自己 import漏了就是 ReferenceError
// 而那个错误看起来像「探针坏了」,容易被当成环境问题绕过去)。
import { createRequire } from 'node:module';
const require_ = createRequire(process.cwd() + '/package.json');
const specs = JSON.parse(process.env.SNAPSHOT_SPECS || '[]');
const missing = [];
for (const spec of specs) {
let ok = false;
// 先试 ESM插件真正走的路再退到 CJS少数插件用 require
try { import.meta.resolve(spec); ok = true; } catch {}
if (!ok) {
try { require_.resolve(spec); ok = true; } catch {}
}
if (!ok) missing.push(spec);
}
process.stdout.write(JSON.stringify(missing));
`;
const { spawnSync } = await import('node:child_process');
const proc = spawnSync(
process.execPath,
['--input-type=module', '-e', probe],
{
cwd: snapshot,
encoding: 'utf8',
env: { ...process.env, SNAPSHOT_SPECS: JSON.stringify([...specifiers.keys()]) }
}
);
if (proc.status !== 0) {
console.error(' 解析探针自身失败,无法判定(不要当成通过):');
console.error(` ${(proc.stderr || '').trim().split('\n').slice(-3).join('\n ')}`);
process.exit(2);
}
const missingSpecs = JSON.parse(proc.stdout || '[]');
const missing = missingSpecs.map(spec => ({
spec,
from: (specifiers.get(spec) || '').replace(`${snapshot}/`, '')
}));
const okList = [...specifiers.keys()].filter(s => !missingSpecs.includes(s));
console.log(` 扫描 ${seenFiles.size} 个本地模块,${specifiers.size} 个导入说明符`);
if (missing.length === 0) {
console.log(` 全部 ${okList.length} 个说明符可在快照内解析`);
process.exit(0);
}
console.error(`${missing.length} 个说明符**无法**在快照内解析 —— 启动时必然失败:`);
for (const m of missing) {
console.error(` ${m.spec} (首次出现于 ${m.from})`);
}
console.error(' 提示:这类缺失通常是因为 package.json 没声明该依赖,');
console.error(' 于是拷贝时也没人可以依据 —— 只靠「node_modules 里碰巧有」是不够的。');
process.exit(1);