diff --git a/deploy/check-plugin-snapshot.mjs b/deploy/check-plugin-snapshot.mjs new file mode 100644 index 0000000..7245313 --- /dev/null +++ b/deploy/check-plugin-snapshot.mjs @@ -0,0 +1,154 @@ +#!/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); diff --git a/deploy/redeploy-plugin.sh b/deploy/redeploy-plugin.sh new file mode 100755 index 0000000..b50101e --- /dev/null +++ b/deploy/redeploy-plugin.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +# +# 把 JS 桥插件部署成**仓库外的快照**,并原子切换 —— 替代「让生产直接跑仓库工作区」。 +# +# # 为什么必须有这个脚本 +# +# 在此之前的实际形态(实测): +# +# pi systemd: ExecStart=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 桥。 +# +# # 纪律(与 redeploy-gateway.sh 同一套) +# +# 1 前置断言 → 2 staging 拷贝 → 3 门禁(语法/构建)→ 4 原子切换 +# → 5 重启 → 6 后置验证(服务 active **且** 桥日志出现「已接入」) +# → 任一步失败即切回 .prev 并重启 +# +# 退出码: 0=成功 1=失败或验证不过(已尝试回滚) 2=参数/环境问题 +# +set -uo pipefail + +REPO=${REPO:-/home/program/agentmail} +DEST_ROOT=${DEST_ROOT:-/opt/agentmail/plugins} +STAGE_ONLY=0 +PLUGIN="" + +usage() { sed -n '2,40p' "$0"; } + +for arg in "$@"; do + case "$arg" in + -h|--help) usage; exit 0 ;; + --stage-only) STAGE_ONLY=1 ;; + pi|opencode|dsh) PLUGIN="$arg" ;; + *) echo "未知参数: $arg" >&2; exit 2 ;; + esac +done +[ -n "$PLUGIN" ] || { echo "用法: $0 [--stage-only]" >&2; exit 2; } + +say() { printf '\n=== %s\n' "$*"; } +ok() { printf ' [ OK ] %s\n' "$*"; } +bad() { printf ' [FAIL] %s\n' "$*"; } +warn() { printf ' [WARN] %s\n' "$*"; } +info() { printf ' %s\n' "$*"; } + +TS=$(date +%Y%m%d-%H%M%S) +SRC="$REPO/plugins/$PLUGIN-mail-bridge" +DEST="$DEST_ROOT/$PLUGIN-mail-bridge" +SNAP="$DEST/$TS" +STAGING="$DEST/.$TS.staging" + +# 每个桥的差异集中在这张表里:入口、systemd 单元(opencode/dsh 不是独立 unit)。 +case "$PLUGIN" in + pi) ENTRY="src/index.mjs"; UNIT="pi-mail-bridge"; NEEDS_BUILD=0 ;; + opencode) ENTRY="index.js"; UNIT="opencode-serve"; NEEDS_BUILD=0 ;; + dsh) ENTRY="dist/index.js"; UNIT="dsh"; NEEDS_BUILD=1 ;; +esac + +say "部署 $PLUGIN-mail-bridge → $SNAP" + +# ── 1. 前置断言 ──────────────────────────────────────────────── +[ -d "$SRC" ] || { bad "源目录不存在: $SRC"; exit 2; } +[ -f "$SRC/package.json" ] || { bad "缺少 package.json: $SRC"; exit 2; } +command -v systemctl >/dev/null 2>&1 || { bad "缺少 systemctl"; exit 2; } +if ! systemctl cat "$UNIT" >/dev/null 2>&1; then + bad "找不到 systemd 单元 $UNIT(插件要先有一个运行宿主才能谈部署)"; exit 2 +fi + +if [ "$NEEDS_BUILD" = 1 ]; then + [ -f "$SRC/tsconfig.json" ] || { bad "dsh 需要 tsconfig.json 才能构建"; exit 2; } + command -v npx >/dev/null 2>&1 || { bad "缺少 npx,无法构建 dsh 插件"; exit 2; } +fi + +# ── 3. staging 拷贝(仓库外的快照)────────────────────────────── +mkdir -p "$DEST" +rm -rf "$STAGING" +mkdir -p "$STAGING" + +# 拷运行时需要的东西。测试与构建脚本不进生产快照 —— +# 快照的意义就是「冻结成一份能跑的东西」,不是把仓库复制一遍。 +for item in package.json lib src index.js; do + [ -e "$SRC/$item" ] && cp -a "$SRC/$item" "$STAGING/" +done +# 依赖必须进快照:仓库外没有 node_modules 可借,缺了它入口根本起不来。 +if [ -d "$SRC/node_modules" ]; then + cp -a "$SRC/node_modules" "$STAGING/" + ok "已拷入 node_modules(生产不借用仓库的依赖)" +else + warn "源目录没有 node_modules —— 若入口依赖外部包,启动时会失败" +fi + +# 需要编译的插件:**产出到 staging**,不写仓库里的 dist/。 +# +# 先在仓库里构建再拷贝会有两个问题:一是失败的构建也会 emit(tsc 默认 +# noEmitOnError=false),于是仓库的 dist/ 被半成品覆盖;二是生产产物与 +# 工作区之间多了一条看不见的耦合。 +if [ "$NEEDS_BUILD" = 1 ]; then + if ( cd "$SRC" && TMPDIR=${TMPDIR:-/tmp} npx tsc -p tsconfig.json --outDir "$STAGING/dist" >/tmp/"$PLUGIN"-tsc.log 2>&1 ); then + ok "tsc 构建完成(产出到 staging)" + else + bad "tsc 构建失败(见 /tmp/$PLUGIN-tsc.log):" + sed 's/^/ /' /tmp/"$PLUGIN"-tsc.log | head -8 >&2 + rm -rf "$STAGING"; exit 1 + fi +fi + +[ -f "$STAGING/$ENTRY" ] || { bad "staging 里没有入口 $ENTRY"; rm -rf "$STAGING"; exit 1; } +ok "staging 就绪: $STAGING" + +# ── 4. 门禁:语法检查(不启动服务,因此不会碰生产)──────────── +if node --check "$STAGING/$ENTRY" 2>/tmp/"$PLUGIN"-check.log; then + ok "入口语法检查通过($ENTRY)" +else + bad "入口语法检查失败:"; sed 's/^/ /' /tmp/"$PLUGIN"-check.log >&2 + rm -rf "$STAGING"; exit 1 +fi + +# 逐个解析入口的 import 图,确认快照自足。 +# +# **不能只比对 package.json 的 dependencies**:pi 的 dependencies 是 `{}`, +# 而它 import 了 `@earendil-works/pi-coding-agent` —— 声明是假的。只查声明 +# 等于空跑,而漏掉的代价出现在最糟的时刻(current 已切、服务重启、插件起不来, +# 旧版本已被换掉)。 +if ! node "$REPO/deploy/check-plugin-snapshot.mjs" "$STAGING" "$ENTRY" 2>&1 | sed 's/^/ /'; then + bad "快照不自足(缺依赖),销毁 staging 且不切换" + rm -rf "$STAGING" + exit 1 +fi +ok "快照自足(入口的 import 图全部可解析)" + +if [ "$STAGE_ONLY" = 1 ]; then + # 干跑:把快照留在 .staging,**不切 current、不重启**。 + say "干跑结束(--stage-only)" + info "快照留在: $STAGING" + info "未做: 原子切换 / 重启 $UNIT / 后置验证" + info "要真部署:$0 $PLUGIN" + exit 0 +fi + +# ── 5. 原子切换 ──────────────────────────────────────────────── +PREV="" +[ -L "$DEST/current" ] && PREV=$(basename "$(readlink -f "$DEST/current")") + +mv "$STAGING" "$SNAP" || { bad "staging → 快照 移动失败"; exit 1; } +ln -sfn "$TS" "$DEST/current" +ok "current → $TS${PREV:+(上一版 $PREV)}" + +rollback() { + if [ -n "$PREV" ] && [ -d "$DEST/$PREV" ]; then + ln -sfn "$PREV" "$DEST/current" + systemctl restart "$UNIT" >/dev/null 2>&1 + warn "已回滚到 $PREV 并重启 $UNIT" + else + warn "无上一版可回滚(这是首次部署)—— 请手工处理" + fi +} + +# ── 6. 重启 + 后置验证 ───────────────────────────────────────── +systemctl restart "$UNIT" || { bad "重启 $UNIT 失败"; rollback; exit 1; } + +# 只看 is-active 不够:桥可能进程活着却没连上 Gateway(密钥过期、 +# Gateway 未起、依赖缺失在惰加载时才暴露)。必须以**日志出现「已接入」**为准。 +DEADLINE=$(( $(date +%s) + 45 )) +CONNECTED=0 +while [ "$(date +%s)" -lt "$DEADLINE" ]; do + if journalctl -u "$UNIT" --since "-50 seconds" --no-pager 2>/dev/null | grep -q '已接入'; then + CONNECTED=1; break + fi + sleep 3 +done + +ACTIVE=$(systemctl is-active "$UNIT" 2>/dev/null) + +say "后置验证" +[ "$ACTIVE" = active ] && ok "$UNIT 处于 active" || bad "$UNIT 状态为 $ACTIVE" +if [ "$CONNECTED" = 1 ]; then + ok "桥日志出现「已接入」(连上 Gateway)" +else + bad "45 秒内未见「已接入」—— 插件可能起不来或连不上 Gateway" +fi + +if [ "$ACTIVE" != active ] || [ "$CONNECTED" != 1 ]; then + say "结论: 验证不过 —— 回滚,不要「先上着再修」" + rollback + exit 1 +fi + +say "结论: 部署成功" +info "运行路径: $DEST/current/$ENTRY(仓库不再是生产代码)" +info "回滚命令: ln -sfn '${PREV:-$TS}' '$DEST/current' && systemctl restart $UNIT" +exit 0