Files
MailUI4Agents/deploy/redeploy-plugin.sh
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

203 lines
8.3 KiB
Bash
Executable File
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 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 <pi|opencode|dsh> [--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/。
#
# 先在仓库里构建再拷贝会有两个问题:一是失败的构建也会 emittsc 默认
# 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