# 问题(实测的加载形态)
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 并点名该说明符(不是空转)。
155 lines
5.7 KiB
JavaScript
155 lines
5.7 KiB
JavaScript
#!/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);
|