Files
MailUI4Agents/deploy/redeploy-plugin.sh
JianFeeeee 6a7356ebe7 fix(zcode): 软链部署下入口静默不执行 + 部署脚本支持 zcode 快照
## 缺陷:入口判断不解析软链 → 生产形态下 main() 从不执行

`mcp/server.mjs` 与 `src/index.mjs` 都这么判断是否被直接执行:

    import.meta.url === `file://${process.argv[1]}`

而 ESM 的 `import.meta.url` 是**解析过软链的真实路径**,argv 是命令行里写的那个。
生产布局是软链(`/opt/agentmail/plugins/<name>/current` → 时间戳目录),
于是两者不等、`main()` 从不执行:**没有输出、没有报错、退出码 0**。

它是被部署脚本的后置验证抓到的:第一次从快照起 MCP 服务器时
「握手 0 个工具、stderr 一个字都没有」,而同一个文件从仓库路径跑完全正常
(仓库路径没有软链)。这类缺陷只在部署形态下出现,本地怎么试都对;
表现(静默成功)又与「功能没被调用」一模一样。

修法:新增 `lib/is-main.mjs`,**两侧都 realpath** 后比较(只归一 != 「同一文件」)。
单测含目录软链、文件软链、文件不存在(保守判否,避免被 import 时误跑一遍)。

## 部署脚本:引入 HOST 概念,四个插件一条部署路径

pi/opencode/dsh 的宿主是 systemd 单元,zcode 的宿主是 ZCode 应用本身 ——
没有我们的单元可重启。于是:

- `HOST=systemd`:重启单元 + 看网关库里有没有新心跳(原有判据)
- `HOST=zcode`:**从快照起一次 MCP 服务器并走完握手**,这与 ZCode 加载插件
  走的是同一份入口代码;另查 `plugins list` 报告的路径是不是 current

后置验证带**判据自检**:握手函数对坏路径必须返回 0,否则判据本身失效就拒绝通过。
计数用 `grep -o | wc -l` 而不是 `grep -c` —— 每条响应只占一行,tools/list 的
11 个工具名全在同一行,用 -c 会得到 2,把好快照判失败(实测踩过)。

## 生产已切到快照

`/opt/agentmail/plugins/zcode-mail-bridge/current → 20260912-150547`,
`~/.zcode/cli/config.json` 的 `plugins.dirs` 已指向 current,
`zcode plugins list` 报的路径就是快照路径。仓库不再是生产代码。

验证:单测 325/325;快照握手 12 个 name 字段;官方 __zcode-plugin-host 从快照
启动正常;钩子从快照跑通 409 → block;坏路径能被握手判据发现(反向对照)。
2026-09-12 15:06:49 +08:00

306 lines
14 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}
GATEWAY_DB=${GATEWAY_DB:-/opt/agentmail/data/agentmail.db}
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|zcode) PLUGIN="$arg" ;;
*) echo "未知参数: $arg" >&2; exit 2 ;;
esac
done
[ -n "$PLUGIN" ] || { echo "用法: $0 <pi|opencode|dsh|zcode> [--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"
# 每个桥的差异集中在这张表里:入口、宿主、构建需求。
#
# HOST 决定「切换之后拿什么判活」,两者完全不同:
# systemd → 重启单元 + 看网关库里有没有新心跳
# zcode → **没有我们的单元可重启**ZCode 是宿主,它在会话开始时读
# `plugins.dirs`。于是判活改成「从快照起 MCP 服务器并走一遍握手」——
# 这与「宿主加载插件」走的是同一份入口代码。
case "$PLUGIN" in
pi) ENTRY="src/index.mjs"; UNIT="pi-mail-bridge"; HOST="systemd"; NEEDS_BUILD=0 ;;
opencode) ENTRY="index.js"; UNIT="opencode-serve"; HOST="systemd"; NEEDS_BUILD=0 ;;
dsh) ENTRY="dist/index.js"; UNIT="dsh"; HOST="systemd"; NEEDS_BUILD=1 ;;
zcode) ENTRY="src/index.mjs"; UNIT=""; HOST="zcode"; NEEDS_BUILD=0 ;;
esac
say "部署 $PLUGIN-mail-bridge → $SNAP"
# ── 1. 前置断言 ────────────────────────────────────────────────
[ -d "$SRC" ] || { bad "源目录不存在: $SRC"; exit 2; }
[ -f "$SRC/package.json" ] || { bad "缺少 package.json: $SRC"; exit 2; }
if [ "$HOST" = "systemd" ]; then
command -v systemctl >/dev/null 2>&1 || { bad "缺少 systemctl"; exit 2; }
[ -f "$GATEWAY_DB" ] || { bad "找不到网关库 $GATEWAY_DB(无法验证桥是否连上)"; exit 2; }
if ! systemctl cat "$UNIT" >/dev/null 2>&1; then
bad "找不到 systemd 单元 $UNIT(插件要先有一个运行宿主才能谈部署)"; exit 2
fi
else
# zcode 的宿主是应用本身:要能跑 CLI 才能自查「插件被发现了吗」。
ZCODE_CLI=${ZCODE_CLI:-/opt/ZCode/resources/glm/zcode.cjs}
[ -f "$ZCODE_CLI" ] || { bad "找不到 ZCode CLI: $ZCODE_CLI"; exit 2; }
command -v node >/dev/null 2>&1 || { bad "缺少 node"; 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"
# 整包复制,再剔除开发用目录。
#
# **不能白名单列出要拷什么。** 第一版白名单是 `package.json lib src index.js dist`
# 漏掉了 dsh 的 `cordis.patch.yml`dsh 读它做 overlay 配置)。后果不是"少个文件"
# 而是**服务起不来**,而且只在重启那一刻才暴露:
#
# Error: dsh: failed to read overlay .../dsh-mail-bridge/cordis.patch.yml: ENOENT
#
# 白名单的失败模式天生如此:漏一个就等着启动时炸,而启动时旧版本已经被换掉了。
# 黑名单反过来 —— 默认带走,只排除明确不需要的。
cp -a "$SRC/." "$STAGING/"
rm -rf "$STAGING/test" "$STAGING/.git" "$STAGING/node_modules/.cache" \
"$STAGING/.DS_Store" "$STAGING/dist.old" 2>/dev/null
find "$STAGING" -maxdepth 1 -name '*.log' -delete 2>/dev/null
# 依赖必须进快照:仓库外没有 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:+ / 重启 $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"
[ -n "$UNIT" ] && systemctl restart "$UNIT" >/dev/null 2>&1
warn "已回滚到 $PREV${UNIT:+ 并重启 $UNIT}"
else
warn "无上一版可回滚(这是首次部署)—— 请手工处理"
fi
}
# ── 5b. zcode宿主不是我们的 unit判活方式不同 ──────────────
# 判据:**从快照里起一次 MCP 服务器并走完握手**。
# 这与 ZCode 加载插件走的是同一份入口代码,所以能发现「快照缺文件」
# 「相对导入断了」「清单被改坏」这类只有加载时才暴露的问题。
# 检查系统 MCP 握手stdin 进、stdout 出),返回响应里的 name 字段个数(失败回声 0
#
# 计数必须用 `grep -o | wc -l`(数**次数**),不能用 `grep -c`(数**行**
# 每一条响应只占一行tools/list 的 11 个工具名全在同一行里,
# 用 -c 会得到 2就那么两行于是好快照也会被判失败实测踩过
# 期望值11 个工具 + initialize 里的 serverInfo.name = 12。
mcp_handshake() {
local root="$1"
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
| AGENTMAIL_AGENT_NAME=probe timeout 30 node "$root/mcp/server.mjs" 2>/dev/null \
| grep -o '"name":"' | wc -l | tr -d ' ' || true
}
MIN_NAMES=11
if [ "$HOST" = "zcode" ]; then
say "后置验证zcode宿主是应用不能重启它"
TOOLS=$(mcp_handshake "$DEST/current")
if [ "${TOOLS:-0}" -ge "$MIN_NAMES" ]; then
ok "从快照起的 MCP 服务器握手成功,报出 $TOOLS 个 name 字段"
else
bad "快照里的 MCP 服务器握手失败(只报出 ${TOOLS:-0} 个 name 字段,期望 ≥ $MIN_NAMES"
rollback
exit 1
fi
# 反向对照:握手判据必须能发现坏快照,否则「全绿」没有意义。
if [ "$(mcp_handshake "$DEST/__definitely_missing__")" -ge "$MIN_NAMES" ]; then
bad "握手判据无法发现坏路径 —— 判据本身失效,拒绝通过"
rollback
exit 1
fi
ok "握手判据自检通过(坏路径能被发现)"
# ZCode 从 plugins.dirs 发现插件;没有指向 current 就还没真的切换。
LIST=$(timeout 60 node "$ZCODE_CLI" plugins list 2>/dev/null || true)
if printf '%s' "$LIST" | grep -q "agentmail@inline"; then
WHERE=$(printf '%s' "$LIST" | grep -A 1 'agentmail@inline' | grep -o '/opt/[^ ]*' | head -1)
if [ "$WHERE" = "$DEST/current" ]; then
ok "ZCode 已从快照发现插件($WHERE"
else
warn "ZCode 已发现 agentmail但路径是 $WHERE(不是 $DEST/current"
info "改这一处即可:~/.zcode/cli/config.json 的 plugins.dirs"
info " \"dirs\": [\"$DEST/current\"]"
info "改完要让 ZCode 重新读取(它会话开始时读;重启应用最保险)"
fi
else
warn "ZCode 还没发现 agentmail 插件plugins.dirs 尚未指向 current"
info " \"dirs\": [\"$DEST/current\"]"
fi
say "结论: 快照已切换"
info "运行路径: $DEST/current/$ENTRY"
info "回滚命令: ln -sfn '${PREV:-$TS}' '$DEST/current'"
exit 0
fi
# ── 6. 重启 + 后置验证 ─────────────────────────────────────────
# 记下重启时刻,后面用「网关库里的 last_seen 是否比它新」判断桥真的连上了。
# **必须用 UTC。** `agents.last_seen` 是 UTCSQLite 的 CURRENT_TIMESTAMP 语义),
# 而 `date` 默认给本地时间。拿本地时间(如 11:44去比 UTC 值03:44会**永远为假**
# 于是每一次部署都被判成"桥没连上"并回滚 —— 判据写错会让门禁主动破坏生产。
RESTART_AT=$(date -u '+%Y-%m-%d %H:%M:%S')
systemctl restart "$UNIT" || { bad "重启 $UNIT 失败"; rollback; exit 1; }
# 存活判据分两层:
#
# 1. `systemctl is-active` —— 进程在不在。**不够**:桥可能进程活着却没连上
# Gateway密钥过期、Gateway 未起、依赖在惰加载时才暴露)。
# 2. **网关库里 `last_seen` 是否比重启时刻新** —— 平台无关,且是真的端到端
# (桥 → 心跳 → 服务端落库)。这一条替代了第一版的「日志出现『已接入』」:
# 那句话只有 **pi 与 opencode** 会打印dsh 启动时只输出
# `dsh web: http://127.0.0.1:3080` —— 照那个判据,**一次成功的 dsh 部署
# 会被判成失败并回滚**(实测就是这么把 dsh 弄进崩溃循环的:回滚到缺
# cordis.patch.yml 的坏快照)。
#
# 教训:判据里不要写某一个平台特有的措辞。
command -v sqlite3 >/dev/null 2>&1 || { bad "缺少 sqlite3无法验证桥是否连上网关"; rollback; exit 1; }
DEADLINE=$(( $(date +%s) + 90 ))
HEARTBEAT=0
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
SEEN=$(sqlite3 "$GATEWAY_DB" \
"SELECT count(*) FROM agents WHERE agent_name='$PLUGIN' AND last_seen > '$RESTART_AT';" 2>/dev/null)
if [ "${SEEN:-0}" != "0" ]; then HEARTBEAT=1; break; fi
sleep 3
done
ACTIVE=$(systemctl is-active "$UNIT" 2>/dev/null)
say "后置验证"
[ "$ACTIVE" = active ] && ok "$UNIT 处于 active" || bad "$UNIT 状态为 $ACTIVE"
if [ "$HEARTBEAT" = 1 ]; then
ok "网关收到 $PLUGIN 的新心跳last_seen 晚于重启时刻)—— 桥真的连上了"
else
bad "90 秒内网关未收到 $PLUGIN 的新心跳 —— 桥没连上(进程活着不等于连上了)"
info "诊断journalctl -u $UNIT --since '-3 minutes' | tail -30"
fi
if [ "$ACTIVE" != active ] || [ "$HEARTBEAT" != 1 ]; then
say "结论: 验证不过 —— 回滚,不要「先上着再修」"
rollback
exit 1
fi
say "结论: 部署成功"
info "运行路径: $DEST/current/$ENTRY(仓库不再是生产代码)"
info "回滚命令: ln -sfn '${PREV:-$TS}' '$DEST/current' && systemctl restart $UNIT"
exit 0