# 问题(实测的加载形态)
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 并点名该说明符(不是空转)。
203 lines
8.3 KiB
Bash
Executable File
203 lines
8.3 KiB
Bash
Executable File
#!/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/。
|
||
#
|
||
# 先在仓库里构建再拷贝会有两个问题:一是失败的构建也会 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
|