★ 背景: 我上封把"调用者既不 export 表、又忘了调 report"记为**边界**,理由是"闭合它要本文件
知道调用者控制流 —— 做不到"。pi 指出**前提可以换掉**(我复核采纳):
病根不是"控制流不可知",而是"**声明**(赋值)与**检查**(调用)是两个可分离的动作"。
⇒ 把声明做成**动作**: `agentmail_require <命令…>` = 赋值 + 立刻检查 ⇒ 该状态**不存在**。
★ 改动:
· env-defaults.sh 加 `agentmail_require()`(内部就是赋值 + agentmail_env_check_require)
· 三个调用者由 `AGENTMAIL_REQUIRE="…"` 改为 `agentmail_require …`(各 1 行)
也顺带把"重新导出为环境变量"的写法去掉了 —— 表只在本进程内被 ③b 读,不需要 export
· 新判据 `deploy/check-require-declaration.sh`(755): deploy/ 下的**调用者**不得写裸赋值
· install.sh 接线,走 `CHECK_GATE_RC` **累积**通道(照 check-file-modes.sh 的既有做法:
直接调会在 set -e 下中止,把后面的收尾诊断全吃掉)
★★ 判据自己的两个 bug,都是**实测**抓出来的(不是审出来的):
① 假红: 我第一版按"`deploy/**/*.sh` 全扫"划范围 ⇒ 立刻把 `env-defaults.sh:449`
(`agentmail_require()` 函数体里的 `AGENTMAIL_REQUIRE="$*"` —— **动作自己的实现**)
判成违规。⇒ 改为按**性质**划: "调用者"= **真的 source 了本库**的文件,
减去定义动作的那个库自己("调用者"这个身份由 source 这个动作定义,不由目录猜)。
② ★ 假阴且**不稳定**: `strip_comments "$f" | grep -q …` 在 `set -o pipefail` 下,
`grep -q` 一命中就退出 ⇒ `sed` 收 SIGPIPE(rc=141)⇒ **管道整体 141** ⇒ if 判假 ⇒ 漏文件。
实测同一个脚本两次分别找到 **2** 个与 **1** 个调用者(实际 3 个),
而 rc=141 被 if 静默吞掉 ⇒ 集合**偏少且不稳** ⇒ "裸赋值 0 处"会是**假绿**。
⇒ 改为先收集到变量、再 `grep <<<"$body"`(无管道)。修后重复 10 次**稳定 3 个**。
★ 验证(全部真跑):
· 基线 rc=0(3 个调用者,裸赋值 0 处);重复 10 次稳定 3
· 变异①: 把 redeploy-plugin.sh 改回裸赋值 ⇒ rc=**1** ✓
· 变异②: 注释掉一个调用者的 source(仍剩 2)⇒ rc=0 ✓ 非空转
· 变异③: 三个 source 全注释 ⇒ **集合空** ⇒ rc=**1** ✓(防空转,不是恒绿)
· 每个变异后**还原**并复测 rc=0;`git diff --numstat` 确认只有预期行
· criteria-hygiene **7/7** 绿(新 check-*.sh 已被 install.sh 真调用)
· pi 的 env-guard **17/17** 绿;五个脚本 bash -n 全过;生产未动
★ 残留(判据头已如实写): 盖"字面裸赋值",**不盖间接赋值**(经 eval/read/env 注入)——
补它需 shell 语义分析,超出"一条 grep 型判据"的射程。
801 lines
50 KiB
Bash
Executable File
801 lines
50 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
#
|
||
# 把 AgentMail Gateway 与各平台的 mail-bridge 安装为 systemd 服务。
|
||
#
|
||
# sudo ./deploy/install.sh
|
||
#
|
||
# 幂等:重复执行等价于「重新构建 + 重启」。已存在的 env 文件不会被覆盖,
|
||
# 因为里面有管理员密码与 Agent secret,重装不该把它们冲掉。
|
||
set -euo pipefail
|
||
|
||
# ⚠️⚠️ **PATH 归一化必须排在文件最前**(pi 2026-09-25 实测报回):
|
||
# 规则与 `lib/env-defaults.sh` 的 ④ 逐字相同(那里注明"**必须排在最前**",此处提前到 `dirname` 之前)。
|
||
# 为什么非在这里不可:下面这行 `REPO=…dirname…` 是**本文件第一条外部命令**,
|
||
# PATH 缺 `/usr/bin` 时它先失败 ⇒ `REPO` 退化成 `/` ⇒ 后面即使找到 git 也会
|
||
# `fatal: not in a git directory`(退出码 **128**,不在本仓"环境问题=2+人话"的词汇表里)。
|
||
# 幂等:已含 `/usr/bin` 时一个字节都不改。
|
||
case ":${PATH:-}:" in
|
||
*:/usr/bin:*) : ;;
|
||
*) PATH="${PATH:+$PATH:}/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; export PATH ;;
|
||
esac
|
||
|
||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||
|
||
# 用法块 = 文件头注释。**锚定在 `set -euo pipefail` 上,不数行号** ——
|
||
# 原来写 `sed -n '2,20p'`,任何人往文件头插几行都会让 `--help` 开始印**实现代码**
|
||
# (本文件 2026-09-25 插 PATH 归一化时就会多印 5 行 `case … esac`)。锚定后插多少行都不漏。
|
||
usage() { sed -n '2,/^set -euo pipefail/p' "$0" | sed '$d'; }
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# ./deploy/install.sh --check # 干跑:只跑所有门禁与前置检查,**不写系统目录**
|
||
#
|
||
# 为什么要有 --check(pi 2026-09-14):发布路径是**最少被跑的门** ——
|
||
# `check-shared-libs.sh` 一直红着没人发现,正是因为它只挂在 install.sh 里。
|
||
# 而"最后一个能跑这一步的人"(有 root 的那个人)每次都在**改完一堆东西之后**才第一次跑它,
|
||
# 于是第一次失败就发生在最贵的时候。干跑把"门自己跑得动吗"提前暴露出来。
|
||
#
|
||
# ⚠️ ★★ **参数解析必须在 `source env-defaults.sh` 之前**(pi 2026-09-15 的相位标记要它):
|
||
# 那条相位标记(`AGENTMAIL_ENV_DEFER`)是给**source 期间**跑的预检看的,
|
||
# 而 `env-defaults.sh` 在 source 时就把预检跑完了 ⇒ 标记必须在 source **之前**就位。
|
||
# 我第一版把解析留在原处、只在 source 之后设标记 ⇒ **标记永远没生效**,
|
||
# 而输出看不出区别(`--check` 照样一条诊断都不跑)—— 靠实测才抓到。
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
CHECK_ONLY=0
|
||
ALLOW_DIRTY=0
|
||
ALLOW_STALE=0
|
||
GIT_HOOKS=0
|
||
for a in "$@"; do
|
||
case "$a" in
|
||
--check) CHECK_ONLY=1 ;;
|
||
--allow-dirty) ALLOW_DIRTY=1 ;;
|
||
--allow-stale) ALLOW_STALE=1 ;;
|
||
--git-hooks) GIT_HOOKS=1 ;;
|
||
-h|--help) usage; exit 0 ;;
|
||
*) echo "未知参数:$a(支持 --check / --allow-dirty / --allow-stale / --git-hooks)" >&2; exit 2 ;;
|
||
esac
|
||
done
|
||
if [[ $CHECK_ONLY -eq 1 ]]; then
|
||
AGENTMAIL_ENV_DEFER=1
|
||
export AGENTMAIL_ENV_DEFER
|
||
fi
|
||
|
||
# ── 版本库钩子:把 `core.hooksPath` 指向仓库里的 `.githooks/` ──
|
||
#
|
||
# ⚠️⚠️ **这一块必须在 `source env-defaults.sh` 之前**(pi 2026-09-25 的破坏性测试逼出来的):
|
||
# 它只用 `git` 与 `echo`,**不碰 Go/Node**,却曾经被 GOCACHE 预检挡在门外 ⇒
|
||
# `--git-hooks` 在新机器上 exit 2 ⇒ **到不了这里** ⇒ 钩子永远装不上。
|
||
# 而"新机器"**正是**钩子落盘唯一要解决的场景(本仓库当前的 hooksPath 只是本地配置,
|
||
# 不随仓库走 —— 新 clone 里 hook 文件在,`git` 却不会调用它)。
|
||
# ⇒ 旧位置(在 source 之后)等于:**能装的机器不需要装,需要装的机器装不上**。
|
||
#
|
||
# 为什么要它、而不只靠判据(pi 2026-09-15):
|
||
# `criteria-hygiene` 里那条 AGC 判据读的是 **index**(`git ls-files`)——
|
||
# 它守的是"不会再被加回来",**不是**"不会被推出去"。而 blob 此刻正躺在
|
||
# **未推送的提交**里:`git rm --cached` 之后它从 index 消失了,历史里却还在,
|
||
# 下一次 `git push` 会连它一起发出去,**而没有任何东西会红**。
|
||
# ⇒ 判据是**泄露之后**响的闹钟;这个钩子是**不让它泄露**。两件都要有。
|
||
#
|
||
# 钩子**跟着仓库走**(`.githooks/` 进版本库),所以换台机器 clone 下来装一次即可;
|
||
# 这也是它比"写进 `.git/hooks/`"强的地方 —— 后者不进版本库、别人 clone 不到。
|
||
#
|
||
# ★ 判据要问的是"**git 会不会调用它**",不是"文件在不在"(pi 2026-09-25 §三):
|
||
# 用 `git rev-parse --git-path hooks/<name>` 把**配置解析成真实路径**再查可执行位。
|
||
# 只查 `.githooks/pre-commit` 存在,会在 hooksPath 为空时**照样判绿** ——
|
||
# 那正是我们反复记的"存在 ≠ 生效"。
|
||
git_hook_active() {
|
||
# $1 = 钩子文件名。输出:可执行的真实路径(成功)或空(失败)。
|
||
# --path-format=absolute:不加它 rev-parse 给的是**相对**路径,直接 test -x 会按 cwd 解析 ⇒ 误判。
|
||
local p
|
||
p="$(git -C "$REPO" rev-parse --path-format=absolute --git-path "hooks/$1" 2>/dev/null)" || return 1
|
||
[[ -n "$p" && -x "$p" ]] || return 1
|
||
printf '%s' "$p"
|
||
}
|
||
if [[ $GIT_HOOKS -eq 1 ]]; then
|
||
# ⚠️ 本块在 `source env-defaults.sh` 之前 ⇒ 拿不到它那条 `AGENTMAIL_REQUIRE` 检查,
|
||
# 所以这里**自己**判一次 `git` 在不在。否则缺 git 时得到的是 bash 的
|
||
# `git: command not found`(退出码 127)—— 而本仓约定"环境问题=2、且要给人话"。
|
||
# (这是我把本块前移时实测撞出来的:127 不在本仓的退出码词汇表里。)
|
||
#
|
||
# ⚠️⚠️ 但只补 ① 不够 —— 前移同时**丢掉了第二条**保证(pi 2026-09-25 实测报回:活回归):
|
||
# **env-defaults ④ 的 PATH 归一化**。本条现已在**文件最前**统一补回(见该处注释),
|
||
# 故这里不再重复。★ 两条的失败方向相反:① 缺了是**漏**(少一条检查),
|
||
# ② 缺了是**误**(git 装在 /usr/bin 却报"找不到 git",凭空假红)。
|
||
if ! command -v git >/dev/null 2>&1; then
|
||
echo " [FAIL] 环境不足:找不到 git(--git-hooks 只用 git,但它不在 PATH 里)" >&2
|
||
echo " 药方:装 git,或把它的目录加进 PATH 后重跑。" >&2
|
||
exit 2
|
||
fi
|
||
git -C "$REPO" config core.hooksPath .githooks
|
||
echo "==> 已接 git 钩子:core.hooksPath=.githooks"
|
||
# 自证:接上之后**真的要能拦**。只设配置不验证 = "门是好的 ≠ 门接着"。
|
||
for h in pre-push pre-commit; do
|
||
if p="$(git_hook_active "$h")"; then
|
||
echo " [ OK ] git 会调用 $h($p)"
|
||
else
|
||
# pre-push 是硬门(推送泄露);pre-commit 只是围栏 gate,旧 clone 可能还没拉到 ⇒ 不 fail
|
||
if [[ "$h" == "pre-push" ]]; then
|
||
echo " [FAIL] git 不会调用 $h:配置指过去了也没有东西跑(核 core.hooksPath 与 .githooks/$h 的可执行位)" >&2
|
||
exit 1
|
||
fi
|
||
echo " [WARN] git 不会调用 $h —— 围栏 gate 未生效(提交不会被拦)" >&2
|
||
fi
|
||
done
|
||
if [[ $CHECK_ONLY -eq 0 ]]; then
|
||
exit 0 # --git-hooks 是独立动作,不连带装服务
|
||
fi
|
||
fi
|
||
|
||
# 环境自足:一处给全(HOME/TMPDIR/GOMODCACHE/PATH),不在每条命令旁各写预检。
|
||
# 理由与四次历史见该文件的头注释 —— pi 的原话是"否则第五次一定会来"。
|
||
# shellcheck source=./lib/env-defaults.sh
|
||
. "$REPO/deploy/lib/env-defaults.sh"
|
||
# 本脚本依赖的外部命令:缺一个就 exit 2 + 人话(见 env-defaults.sh 的 ③b 一节)。
|
||
# 为什么必须显式声明:**"命令不在"与"命令在但输出为空"必须分开** ——
|
||
# 下游把前者读成后者时就会产出假绿(journalctl 那两处就是:工具缺失被读成"无 panic")。
|
||
agentmail_require git go npm npx node curl systemctl flock
|
||
agentmail_env_report
|
||
|
||
# 与 `redeploy-gateway.sh` / `reset-demo.sh` 同源(pi 评审 2026-09-14 对出来的):
|
||
# 那两处都写 `${AGENTMAIL_PREFIX:-/opt/agentmail}`,只有这里写死 ——
|
||
# 谁设了那个变量,install 装到 A、redeploy 和 drift-check 看 B,三个工具各说各话。
|
||
PREFIX="${AGENTMAIL_PREFIX:-/opt/agentmail}"
|
||
|
||
ETC=/etc/agentmail
|
||
|
||
|
||
# ★ **部署锁**(pi 评审 2026-09-14):环境前提里原先缺的第五列 —— **同时性**。
|
||
# 这台机器的工作区是多 agent 共用的(docs/DEV-TOOLING.md 自己记过),并发部署会互相踩:
|
||
# 两次写同一个目录/文件、两次后置验证互相把对方的"验证不过"当自己的结论、谁回滚不确定。
|
||
# 用 flock("检查文件存在"本身有竞态);锁随进程退出自动释放(fd 9 关闭)。
|
||
#
|
||
# ⚠️ 两个只有在这个脚本里才成立的前提(都是我自己实测撞出来的):
|
||
# · **本脚本没有 `bad` 这类输出函数**(它用裸 `echo >&2`)——照抄别的脚本的写法会得到
|
||
# `bad: command not found`(退出码 127),把"锁没拿到"报成"脚本坏了"。
|
||
# · **`--check` 阶段不建锁**:干跑是只读的、不需要互斥,而它又刻意允许在**没有写权限**时
|
||
# 运行(`/opt/agentmail/.deploy.lock: Permission denied` ⇒ 又变成 127)。
|
||
# 干跑的价值恰恰是"还没权限的人也能把门跑一遍",所以锁必须在写分支里。
|
||
if [[ $CHECK_ONLY -eq 0 ]]; then
|
||
_LOCK="$PREFIX/.deploy.lock"
|
||
if ! exec 9>"$_LOCK"; then
|
||
echo " [FAIL] 环境不足:无法创建部署锁 $_LOCK" >&2
|
||
exit 2
|
||
fi
|
||
if ! flock -n 9; then
|
||
echo " [FAIL] 环境不足:另一个部署正在跑($_LOCK 被占用)—— 等它结束再跑" >&2
|
||
exit 2
|
||
fi
|
||
fi
|
||
|
||
if [[ $CHECK_ONLY -eq 0 ]]; then
|
||
# ★ 退出码 **2**(环境/权限),不是 1(判据失败)—— 与另外两个部署脚本、
|
||
# 以及 env-defaults.sh 里"环境不足"的口径统一。原来这里是 1:
|
||
# "没有权限"与"门禁没过"在退出码上不可区分,调用者没法据此决定"该重跑还是该修代码"。
|
||
[[ $EUID -eq 0 ]] || { echo " [FAIL] 环境不足:需要 root:sudo $0(只验证不安装用 ./deploy/install.sh --check)" >&2; exit 2; }
|
||
else
|
||
echo "==> 干跑模式(--check):不会写 $PREFIX 或 $ETC"
|
||
# 写权限是这一步唯一的硬门槛,先说清楚 —— 下一个拿到权限的人要一眼看到需要什么
|
||
for d in "$PREFIX" "$ETC"; do
|
||
if [[ -e "$d" && ! -w "$d" ]]; then
|
||
echo " 注意:$d 存在但当前用户不可写(正式安装需要 root/sudo 或先放行该目录)"
|
||
fi
|
||
done
|
||
fi
|
||
|
||
echo "==> 构建前端"
|
||
# 干跑不装依赖(装依赖要写 node_modules/缓存,干跑的意义是"门能不能跑",不是"装得上装不上")。
|
||
if [[ $CHECK_ONLY -eq 1 ]]; then
|
||
[[ -d "$REPO/client/electron/node_modules" ]] || {
|
||
echo " 干跑需要一个已装好依赖的工作树(client/electron/node_modules 不存在)" >&2; exit 1; }
|
||
echo " 跳过 npm ci/install(干跑不写依赖);用的是当前 node_modules"
|
||
else
|
||
( cd "$REPO/client/electron" && npm ci --no-audit --no-fund 2>/dev/null || npm install --no-audit --no-fund )
|
||
fi
|
||
# 相位(pi 2026-09-14 裁定):部署门禁问的是"**别人已经产出的产物**能不能装到这台机器上",
|
||
# 不是"产物是不是从当前源码新鲜构建的"。后者属于构建相位(`packaging`/`build-stamp`),
|
||
# 挂在这里会**恒红**(部署路径不重新打包)—— 门挂错了相位。所以这里显式声明相位。
|
||
#
|
||
# ★★ `--check` 模式下**不因这一门失败就中止**(由 pi 2026-09-15 的发现引出):
|
||
# 本脚本是 `set -e` 的,而这一行是**第一个会红的门禁** ⇒ 它一红,脚本立刻退出,
|
||
# **后面所有诊断一行都不打**。实测(探针插在下面 264 行那块之前,命中 **0 次**):
|
||
#
|
||
# $ bash deploy/install.sh --check # 套件红(3 条不是我的)
|
||
# …(判据汇总打完就结束)——**264 行之后整块从未执行**
|
||
#
|
||
# 受害者不只是我新加的那段,**包括既有的版本库钩子检查** ——
|
||
# 即 `install.sh --check` 的 `[ OK ] git 钩子已接` / `[WARN] git 钩子没接`
|
||
# **只在判据全绿时才会出现**。⇒ **一个只在"没出问题"时才说话的诊断**,
|
||
# 与"读数器替一件事作证"同族。
|
||
# (pi 上一轮核过 `--check` 确实会报钩子 —— 那次套件是绿的,报的是真的;
|
||
# 但**这个机制比它看起来脆**:它依赖"门禁恰好全过"。)
|
||
#
|
||
# 修法:`--check` 下把这一门的失败**记下来**、继续跑完所有诊断,
|
||
# 最后用记下的码 `exit` 出去(**不是在文件末尾 —— 见下面"两个洞"**)。
|
||
# **真装路径行为完全不变**(仍 `set -e` 立刻中止 —— 装机器时不该带着红往下走)。
|
||
#
|
||
# ★★ 这一版修的是 pi 2026-09-15 抓到的**两个反向的洞**(我上一版自己造的):
|
||
#
|
||
# ① **`CHECK_GATE_RC` 只被打印、从来没被 `exit` 用**。
|
||
# 我上一版的注释写着"见文件末尾的 `CHECK_GATE_RC`",而**文件末尾根本没有那一行**
|
||
# (文件末尾是正式安装路径的 systemctl/curl,干跑走不到)—— 我把它从末尾挪走时,
|
||
# **挪了打印、丢了退出**。后果是**我上一个 commit 修的病反过来**:
|
||
# 修之前:第一道红中止一切,但命令**失败**;
|
||
# 修之后:诊断全跑完,命令**通过**(干跑分支无条件 `exit 0`)。
|
||
# 而丢掉的这一半**正好是会被引用的那一半** —— 这条链上引用过 `install.sh --check` 的
|
||
# `exit 0` 当绿读数。**"说法(注释)与实现不一致"第三次出现在我手上。**
|
||
# ⇒ 现在干跑分支的终点是 `exit "${CHECK_GATE_RC:-0}"`。
|
||
#
|
||
# ② **"干跑"并不只读:它会重写 `client/electron/dist`。**
|
||
# 同一个脚本在别处很小心地不写 `node_modules`("干跑不写依赖"),却**照跑 `npm run build`**。
|
||
# 两个后果:
|
||
# · **"干跑"改共享状态**(而它自己那句"干跑没有执行"的单子只列了**工作区外**的路径,
|
||
# `dist` 悄悄在射程内);
|
||
# · **它能把 `build-stamp` 从红刷成绿** —— 那条比的是 `dist/BUILD_INFO.json` 的
|
||
# `gitRev`/`srcHash` 与当前源码,而它的红**恰恰就是"产物是在旧提交上构建的"**。
|
||
# 跑一次干跑 ⇒ 重构建 ⇒ 不匹配消失 ⇒ **一条红没了,而没有人决定过"重构建"**。
|
||
# ⇒ 干跑改成:`gen:bg`(幂等生成,本来就在 build 里)+ **`vite build --outDir <临时目录>`**
|
||
# + `build-info.mjs`(`BUILD_INFO_OUT` 指到那个临时目录)。
|
||
# **真装的 build 一字不改**(仍然写真正的 `dist`)。
|
||
# ⚠️ 残余:`gen:bg` 会**原地重写** `src/background-takeover.generated.css`(已跟踪)。
|
||
# 实测它内容幂等(`git status` 干净),所以**不改内容**;但"写文件这个动作"仍在,
|
||
# 所以"干跑只读"这句话**准确的说法是"不改任何被跟踪文件的内容、不碰 dist"**,
|
||
# 不是"一个字节都不写"。我看不出的部分不写死。
|
||
#
|
||
# ★ 而 pi 2026-09-15 指出:上面那句"不改任何被跟踪文件的内容"**当时是假的** ——
|
||
# `npm run gen:bg` 会**原地重写** `src/background-takeover.generated.css`(**已跟踪**)。
|
||
# 他说得更准的一点是:**"幂等"只在一个方向上被保证** —— 守它的判据只判
|
||
# "源码里用到的类必须都在生成文件里",**反向(生成物里多出来的过期规则)没有判据**。
|
||
# 于是:有人删掉最后一处用法、忘了跑生成器 ⇒ 生成物留下一条过期规则 ⇒ 那条判据**仍绿**
|
||
# ⇒ 干跑一跑 `gen:bg` **原地删掉它** ⇒ 一个自称干跑的命令改了被跟踪文件,
|
||
# 还**擦掉了"有人忘了跑生成器"的证据**。(与 `dist` 那条形状完全相同。)
|
||
# ⇒ 现在:干跑**不再原地生成**,而是生成到临时路径,并**把漂移报出来**(不替人修 ——
|
||
# 干跑去修就等于把证据擦掉);判据那一侧已在 `background.test.mjs` 补了**双向**的
|
||
# "生成物与源码逐字节一致"。
|
||
if [[ $CHECK_ONLY -eq 1 ]]; then
|
||
npm_rc=0
|
||
# 产物写到临时目录:干跑不该动 dist(否则会静默刷掉 build-stamp 的红)
|
||
CHECK_DIST="$(mktemp -d -t agentmail-check-dist-XXXXXX)"
|
||
(
|
||
cd "$REPO/client/electron" \
|
||
&& npm run typecheck \
|
||
&& AGENTMAIL_CRITERIA_PHASE=install npm test \
|
||
&& GEN_BG_OUT="$CHECK_DIST/generated.css" node scripts/gen-background-takeover.mjs \
|
||
&& npx vite build --outDir "$CHECK_DIST" --emptyOutDir \
|
||
&& BUILD_INFO_OUT="$CHECK_DIST/BUILD_INFO.json" node scripts/build-info.mjs
|
||
) || npm_rc=$?
|
||
# ★ 生成物与真身不一致 ⇒ **报告,不修**(干跑替人修就把"有人忘了跑生成器"擦掉了)
|
||
if [[ -f "$CHECK_DIST/generated.css" ]] \
|
||
&& ! cmp -s "$CHECK_DIST/generated.css" "$REPO/client/electron/src/background-takeover.generated.css"; then
|
||
echo " [WARN] background-takeover.generated.css 与**当前源码**不一致(干跑**没有**替你改):"
|
||
echo " 修法:cd client/electron && npm run gen:bg,然后**把改动提交**。"
|
||
echo " (判据 test/background.test.mjs 会就这件事判红 —— 干跑这里只提示,不替你做。)"
|
||
fi
|
||
rm -rf "$CHECK_DIST"
|
||
if [[ $npm_rc -ne 0 ]]; then
|
||
echo " [FAIL] 前端门禁(typecheck / 判据 / build)退出码 ${npm_rc} —— 详见上面的红" >&2
|
||
CHECK_GATE_RC="$npm_rc"
|
||
else
|
||
echo " [ OK ] 前端门禁通过(构建产物与生成物都落在临时目录,**没有动 dist、也没动被跟踪的生成物**)"
|
||
fi
|
||
else
|
||
( cd "$REPO/client/electron" && npm run typecheck && AGENTMAIL_CRITERIA_PHASE=install npm test && npm run build )
|
||
fi
|
||
|
||
# ── 安装相位真正能判的那一半:**读产物自证**(不是重算 dist)──
|
||
# 同一个不变量("这份产物是谁")在两个相位要两种判据:构建相位重新计算,安装相位读它自己说的。
|
||
if [[ -f "$REPO/client/electron/dist/BUILD_INFO.json" ]]; then
|
||
echo "==> 产物自证(安装相位)"
|
||
read -r A_REV A_DIRTY A_RC < <(node -e '
|
||
const d = require("'"$REPO"'/client/electron/dist/BUILD_INFO.json");
|
||
console.log([d.gitRev ?? "?", d.gitDirty ? "dirty" : "clean", d.releaseCandidate === undefined ? "?" : String(d.releaseCandidate)].join(" "));
|
||
')
|
||
# git 命令在 PATH 里已由 env-defaults.sh 的 AGENTMAIL_REQUIRE 兜住(缺了提前 exit 2);
|
||
# 但**另一种失败**与它无关:仓库根不是 git 仓库(从 tarball 解出来部署的机器)。
|
||
# 原写法 `HEAD_REV="$(git … rev-parse --short HEAD)"` 在本脚本的 `set -e` 下,
|
||
# 失败会**直接中止**(退出码 127,无任何翻译);就算侥幸往下走,
|
||
# HEAD_REV 为空会让下面那句比较报出"这个包比源码旧:产物 gitRev=… ≠ HEAD="
|
||
# —— **把"这里不是 git 仓库"说成"产物过期"**,又是一个把环境问题说成代码问题的形状。
|
||
if HEAD_REV="$(git -C "$REPO" rev-parse --short HEAD 2>/dev/null)"; then
|
||
:
|
||
else
|
||
HEAD_REV=""
|
||
echo " ⚠ 读不到当前 HEAD($REPO 不是 git 仓库,或 git 不可用)—— 跳过产物↔源码的新旧比对" >&2
|
||
fi
|
||
echo " 产物:gitRev=$A_REV 树=$A_DIRTY releaseCandidate=$A_RC | 当前 HEAD=$HEAD_REV"
|
||
refuse=0
|
||
if [[ "$A_RC" == "false" ]]; then
|
||
echo " ✗ 这个包**不是发布候选**(构建时工作树是脏的)—— 它可能含着别人未提交的半成品。" >&2
|
||
refuse=1
|
||
fi
|
||
if [[ "$A_REV" != "$HEAD_REV" ]]; then
|
||
echo " ✗ 这个包比源码旧:产物 gitRev=$A_REV ≠ HEAD=$HEAD_REV。" >&2
|
||
refuse=1
|
||
fi
|
||
if [[ $refuse -eq 1 ]]; then
|
||
# 原先这行末尾挂着一个 `|| true` ⇒ 整行退出码恒 0 ⇒ 它作为 `if` 的条件**永远为真**
|
||
# ("判据的形式在、区分力不在")。它本意是"已经报过错,这里只是决定要不要继续",
|
||
# 但写成条件表达式就把结论定死了。改成显式计算,去掉 `|| true`。
|
||
_ok=0
|
||
[[ $A_RC == "false" && $ALLOW_DIRTY -eq 1 ]] && _ok=1
|
||
[[ $A_REV != "$HEAD_REV" && $ALLOW_STALE -eq 1 ]] && _ok=1
|
||
unset _ok
|
||
if [[ $CHECK_ONLY -eq 1 ]]; then
|
||
echo " (干跑:正式安装会被拒绝。要放行就显式说清:--allow-dirty / --allow-stale)" >&2
|
||
elif { [[ "$A_RC" != "false" || $ALLOW_DIRTY -eq 1 ]] && [[ "$A_REV" == "$HEAD_REV" || $ALLOW_STALE -eq 1 ]]; }; then
|
||
echo " (你显式放行了:--allow-dirty/--allow-stale —— 风险由你承担)" >&2
|
||
else
|
||
echo " 拒绝安装。放行要显式:sudo $0 --allow-dirty --allow-stale" >&2
|
||
exit 1
|
||
fi
|
||
fi
|
||
else
|
||
echo "==> 产物自证:dist/BUILD_INFO.json 不存在(还没构建过)—— 本相位只能报"不知道",不能报"没问题"" >&2
|
||
fi
|
||
|
||
echo "==> 校验插件共用模块同源"
|
||
# lib/ 下的纯函数模块在三个插件里逐字节相同(见 docs/PLUGIN-CONTRACT.md 第六节)。
|
||
# 一侧改了另一侧没改,几个平台的行为就会悄悄分叉。
|
||
"$REPO/deploy/check-shared-libs.sh"
|
||
|
||
# ★★ 源文件权限政策(dsh 2026-09-19 接线)。
|
||
#
|
||
# 为什么单独一条、为什么**必须挂在这里**:这一类**只有它一个判据**,而且
|
||
# `git` 根本看不见 —— `git ls-files -s` 只记 **100644 / 100755**(可执行位),
|
||
# **组/其他读位不进版本库**。实测:把受跟踪文件 `chmod 600 ↔ 644`,
|
||
# `git status` **两次都空**、`git diff` 也不动 ⇒ 没有任何"顺带"的通道会发现它。
|
||
#
|
||
# 后果不是"本地难看":这些是**源码**,会被构建/测试/部署**以不同身份**读
|
||
# (`install.sh` 自己会切身份做可写性判定;`client/electron/test` 里还有
|
||
# **以 nobody 降权跑**的判据)。0600 在"跑的人恰好是属主"时不炸,换个身份就是
|
||
# EACCES —— 而那串报错**看起来像代码问题**。
|
||
#
|
||
# ⚠️ 这条判据**写出来很久了却从没被任何东西调用过**(`grep -rn` 全仓:唯一的
|
||
# 非注释提及是 `mutants/summary.py` 里一句 `print(...)` 的文案),
|
||
# 而它**当时正红着**(3 条:2 个 600 的文件 + `jobs/` 缺 x 位)——
|
||
# 正是本仓反复消的那个形状:"**判据在,但走不到**"。
|
||
# 它自己那份 `[ -x ]` 恒真的 root 陷阱也修过一版(`b7dc9e9`),修得对,
|
||
# 但**没有任何人会跑到那一行**,所以修与不修没有可观察差别。
|
||
#
|
||
# ⚠️⚠️ 接线时必须走 `--check` 的**累积**通道(`CHECK_GATE_RC`),不能直接调:
|
||
# `check-file-modes.sh` 红时 `exit 1`,而本脚本是 `set -e`
|
||
# ⇒ 它会在**这里**中止,后面 `build-stamp`/钩子/`--check` 的收尾诊断**一行都不打**。
|
||
# 我第一版就是直接调的,**实测**:接线后 `构建 Gateway`/`启用并启动` 等在日志里
|
||
# **命中 0 次**(接线前 `构建 Gateway` 命中 2 次)—— 那正是本文件 141-157 行
|
||
# 刚修过的同一个毛病("第一道红吃掉后面所有诊断"),我自己又造了一遍。
|
||
# ⇒ 照 `npm_rc` 的既有做法:真装路径仍 `set -e` 立刻中止(装机器时不该带着红往下走),
|
||
# 干跑路径**记下来、跑完、最后 `exit "${CHECK_GATE_RC:-0}"`**。
|
||
if [[ $CHECK_ONLY -eq 1 ]]; then
|
||
_fm_rc=0
|
||
"$REPO/deploy/check-file-modes.sh" || _fm_rc=$?
|
||
if [[ $_fm_rc -ne 0 ]]; then
|
||
echo " [FAIL] 权限政策没过(退出码 ${_fm_rc})—— 上面标了 [FAIL] 的那几处就是" >&2
|
||
# 只记**第一个**失败码(既有做法):`CHECK_GATE_RC` 是"干跑退出码"的单一来源
|
||
[[ -n "${CHECK_GATE_RC:-}" ]] || CHECK_GATE_RC="$_fm_rc"
|
||
fi
|
||
else
|
||
"$REPO/deploy/check-file-modes.sh"
|
||
fi
|
||
|
||
# 调用者声明政策:`deploy/` 下的调用者必须用 `agentmail_require` 动作声明依赖的命令,
|
||
# 不许写裸赋值 `AGENTMAIL_REQUIRE="…"`(裸赋值与那次检查**可分离** ⇒ 检查会静默失效)。
|
||
# 理由与射程见 `deploy/check-require-declaration.sh` 头注释。
|
||
# ★ 与上面 `check-file-modes.sh` **同样走 `CHECK_GATE_RC` 累积通道**(理由同上,不再重复):
|
||
# 直接调会在 `set -e` 下中止,把后面的收尾诊断全部吃掉。
|
||
if [[ $CHECK_ONLY -eq 1 ]]; then
|
||
_rd_rc=0
|
||
"$REPO/deploy/check-require-declaration.sh" || _rd_rc=$?
|
||
if [[ $_rd_rc -ne 0 ]]; then
|
||
echo " [FAIL] 调用者声明政策没过(退出码 ${_rd_rc})—— 上面标了 [FAIL] 的那几处就是" >&2
|
||
[[ -n "${CHECK_GATE_RC:-}" ]] || CHECK_GATE_RC="$_rd_rc"
|
||
fi
|
||
else
|
||
"$REPO/deploy/check-require-declaration.sh"
|
||
fi
|
||
|
||
# 插件的纯函数测试(自动转发去重等)。
|
||
# 插件不参与构建产物,但它的逆行为会直接变成用户收件箱里的重复邮件,
|
||
# 因此也纳入部署前的门禁。zod 已在 node_modules 里就不重装。
|
||
if [[ -d "$REPO/plugins/opencode-mail-bridge/node_modules/zod" ]]; then
|
||
( cd "$REPO/plugins/opencode-mail-bridge" && npm test )
|
||
else
|
||
( cd "$REPO/plugins/opencode-mail-bridge" && npm install --no-audit --no-fund && npm test )
|
||
fi
|
||
# DSH 插件:先构建再跑测试。
|
||
#
|
||
# 它是 TypeScript 写的,package.json 的 main 指向 dist/index.js,而 dist/ 不进版本库
|
||
# (与 client/electron/dist 同理)—— 新克隆里不先 tsc,DSH 加载插件时会直接找不到入口。
|
||
# 测试本身只碰 lib/ 下的纯函数(不依赖 dist),但先构建能把类型错误也当成门禁。
|
||
#
|
||
# 盖住的三类约定都是「错了不当场报错、只在深处炸一个无关错误」:
|
||
# followup 消息形状、工作目录解析、会话快照的 subagent 过滤。
|
||
if [[ -d "$REPO/plugins/dsh-mail-bridge/node_modules/typescript" ]]; then
|
||
( cd "$REPO/plugins/dsh-mail-bridge" && npx tsc && npm test )
|
||
else
|
||
( cd "$REPO/plugins/dsh-mail-bridge" \
|
||
&& npm install --no-audit --no-fund && npx tsc && npm test )
|
||
fi
|
||
# pi 插件:纯 ESM,无构建步骤,唯一的外部依赖是全局装的 pi SDK。
|
||
#
|
||
# 它不 npm install:@earendil-works/pi-coding-agent 是全局包(peerDependency),
|
||
# 装在项目里会得到第二份 SDK,两份各自维护 ~/.pi/agent 的会话索引缓存。
|
||
# 因此这里只软链一次;SDK 不在时跳过测试(turn/naming 是纯函数,
|
||
# 但 lib 的测试也一起跑,没必要为缺 SDK 的机器留半套门禁)。
|
||
PI_SDK=/usr/lib/node_modules/@earendil-works/pi-coding-agent
|
||
if [[ -d "$PI_SDK" ]]; then
|
||
install -d "$REPO/plugins/pi-mail-bridge/node_modules/@earendil-works"
|
||
ln -sfn "$PI_SDK" "$REPO/plugins/pi-mail-bridge/node_modules/@earendil-works/pi-coding-agent"
|
||
( cd "$REPO/plugins/pi-mail-bridge" && npm test )
|
||
else
|
||
echo " 未找到 pi SDK($PI_SDK),跳过 pi 插件测试"
|
||
fi
|
||
|
||
# pi 邮件工具扩展:给**交互式**会话装上 send_mail / read_inbox。
|
||
#
|
||
# 守护进程(pi-mail-bridge.service)用 noExtensions:true 起会话,它的邮件工具
|
||
# 只给模型在邮件会话里用;人在 TUI 里敲的 pi 拿不到。结果是平台的建设者自己
|
||
# 收不到邮件,只能绕到 curl + 密钥直连 Gateway。这个扩展补上那一侧 ——
|
||
# TUI 与邮箱是同一条 AgentMail 身份(agent pi)的两个入口。
|
||
#
|
||
# 为什么不会重复注册:noExtensions:true 只加载 CLI `-e` 传的扩展,
|
||
# settings.json 的 extensions 数组会被排除(见 SDK resource-loader.js),
|
||
# 因此守护进程的 worker 里零扩展、本扩展只作用于交互式会话。
|
||
if [[ -d "$PI_SDK" ]]; then
|
||
# `set -u` 下 `$HOME` 未设会直接杀掉部署(实测:`HOME: unbound variable`,
|
||
# 而它发生在所有门禁跑完之后 —— 最贵的位置)。cron / env -i / 某些 sudo 配置下
|
||
# HOME 就是没有的,所以给一个兜底。同一形状我这轮在 homeagent 的 build.sh 里
|
||
# 也修过一次(cron 里 HOME 未设 → Go 拒绝工作)——**这是这轮第三次**,
|
||
# 说明"环境变量在部署路径上不存在"这条要当成常态而不是意外。
|
||
PI_HOME="${SUDO_USER:+$(getent passwd "$SUDO_USER" | cut -d: -f6)}"
|
||
PI_HOME="${PI_HOME:-${HOME:-/root}}"
|
||
PI_SETTINGS="$PI_HOME/.pi/agent/settings.json"
|
||
EXT_PATH="$REPO/plugins/pi-mail-bridge/extension/index.ts"
|
||
if [[ $CHECK_ONLY -eq 1 ]]; then
|
||
echo " [干跑] 跳过:把邮件工具扩展注册进 $PI_SETTINGS(要写工作区外的 home 目录)"
|
||
elif [[ -d "$PI_HOME/.pi/agent" || -f "$PI_SETTINGS" ]]; then
|
||
install -d "$(dirname "$PI_SETTINGS")"
|
||
node -e '
|
||
const fs = require("fs");
|
||
const [path, target] = process.argv.slice(1);
|
||
let cfg = {};
|
||
try { cfg = JSON.parse(fs.readFileSync(path, "utf8")); } catch { /* 新建 */ }
|
||
const list = Array.isArray(cfg.extensions) ? cfg.extensions : [];
|
||
if (list.includes(target)) { console.log(" 已注册,幂等跳过"); process.exit(0); }
|
||
list.push(target);
|
||
cfg.extensions = list;
|
||
fs.writeFileSync(path, JSON.stringify(cfg, null, 2) + "\n");
|
||
console.log(" 已注册 " + target);
|
||
' "$PI_SETTINGS" "$EXT_PATH"
|
||
else
|
||
echo " 未找到 $PI_HOME/.pi/agent,跳过(手动在 settings.json 加 extensions: [\"$EXT_PATH\"])"
|
||
fi
|
||
fi
|
||
|
||
if [[ $CHECK_ONLY -eq 1 ]]; then
|
||
# ★ 版本库钩子装上了吗 —— **门自己的接线**也要验。
|
||
#
|
||
# 为什么放在这一相位(pi 2026-09-15 指出"判据只盖索引、盖不住 push"):
|
||
# `criteria-hygiene` 里那条 AGC 判据读的是 **index**,它只证明"不会再被加回来";
|
||
# 而 blob 还躺在**未推送的提交**里,下一次 `git push` 会连它一起发出去。
|
||
# 拦得住这一步的只有 `.githooks/pre-push` —— 而**没装上的钩子等于没有钩子**。
|
||
# 这与 `--check` 存在的理由是同一条:**门是好的 ≠ 门接着**。
|
||
hooks_path="$(git -C "$REPO" config --get core.hooksPath || true)"
|
||
if [[ "$hooks_path" == ".githooks" ]]; then
|
||
echo " [ OK ] git 钩子已接(core.hooksPath=.githooks)"
|
||
# 逐钩自证:接上 ≠ 存在 ≠ 可执行。
|
||
# ★ 用 `git_hook_active` 而不是写死 `$REPO/.githooks/<h>`(pi 2026-09-25 §三 逼出来的):
|
||
# 写死路径只证明"**那个文件**在",不证明"**git 会调用它**"——
|
||
# 配置指向别处、或 hook 少了可执行位时,前者照样绿。这正是"存在 ≠ 生效"。
|
||
if p="$(git_hook_active pre-push)"; then
|
||
echo " [ OK ] git 会调用 pre-push($p)—— 拦 AGC 真身进远端"
|
||
else
|
||
echo " [WARN] git 不会调用 pre-push —— pre-push 形同虚设(核 core.hooksPath 与可执行位)" >&2
|
||
fi
|
||
if p="$(git_hook_active pre-commit)"; then
|
||
echo " [ OK ] git 会调用 pre-commit($p)—— 拦 docs/API.md 围栏奇数/未配对"
|
||
else
|
||
echo " [WARN] git 不会调用 pre-commit —— 围栏 gate 未落盘(提交不会被拦)" >&2
|
||
fi
|
||
else
|
||
echo " [WARN] git 钩子**没接**:core.hooksPath=${hooks_path:-(未设 → 用 .git/hooks,里面只有 sample)}"
|
||
echo " 后果:推送前**没有任何东西**拦 AGC 真身进远端历史(判据只盖 index,盖不住 push)。"
|
||
echo " ★ 以及:提交前也**不会**拦 docs/API.md 的围栏 gate —— 文件在仓库里,但 git 不调用它。"
|
||
echo " 接上:./deploy/install.sh --git-hooks(只写本地 git 配置,不需要 root)"
|
||
fi
|
||
|
||
# ── `origin` URL:**打印出来**(不判具体值)────────────────────────────────
|
||
#
|
||
# ★ 为什么只报不判(pi 2026-09-15):`.git/config` 是**共享、无版本控制**的状态,
|
||
# 而 `origin` 的 **URL 字符串没有可对的外部真值** —— 外部只能证明它的**效果**
|
||
# (能到达同一个仓、tip 相同),**证明不了它的值**。
|
||
# 我自己就栽过:把 `origin` 改坏后"对着远端真值恢复",`ls-remote` 只能告诉我
|
||
# "这个 URL 能到那个仓",于是我把 `http://192.168.2.106:3000/...`(原来的值)
|
||
# 换成了 `https://gitea.jianfgit.xyz/...` —— **同仓、不同值**,而我还说"已恢复"。
|
||
# ⇒ "对着远端真值恢复 URL"是个**类别错误**(这句是 pi 的原话,我认)。
|
||
#
|
||
# ★ 所以这里做两件**能做**的事:
|
||
# 1) 把**当前值打出来** —— 下次再有人改它,至少有一处"改前/改后"能被看见;
|
||
# 2) **判它可达**(`ls-remote` 能拿到 refs)—— 这才是真有外部真值的那部分性质。
|
||
# 不可达时判 WARN(不是 FAIL):局域网 URL 在离线/换网时本来就不可达,
|
||
# 而 `--check` 不该因为网络问题红。
|
||
#
|
||
# ⚠️ 我**没有**断言它等于某个常量:本仓并没有登记"规范 URL",
|
||
# 而编一个常量去比对 = **猜数字的判据**(真值无处可得,写死就成了我的记忆)。
|
||
# 要强判,先把规范值登记进版本库(`docs/` 或本文件顶部),再断言。
|
||
origin_url="$(git -C "$REPO" remote get-url origin 2>/dev/null || true)"
|
||
if [[ -z "$origin_url" ]]; then
|
||
echo " [WARN] 远端 \`origin\` **没有配** —— 推送/取远端都无从谈起"
|
||
else
|
||
echo " [ OK ] 远端 origin = ${origin_url}(下面是*效果*检查,值本身无外部真值可比)"
|
||
# ★ 必须**限时**:实测把 origin 指向一个不可达地址(`http://192.0.2.1:3999/…`)时,
|
||
# `git ls-remote` **挂住不动**(60s 都没返回)—— 而"干跑卡死"比"干跑报 WARN"坏得多
|
||
# (卡死的门会被当成"这脚本跑不了"而整个跳过)。所以给 15s 上限,
|
||
# 超时 = 不可达 = WARN(本脚本对网络问题的既定态度:WARN,不 FAIL)。
|
||
if timeout 15 git -C "$REPO" ls-remote --exit-code origin >/dev/null 2>&1; then
|
||
echo " [ OK ] origin 可达(\`git ls-remote origin\` 能拿到 refs)"
|
||
else
|
||
echo " [WARN] origin **不可达**(或 15s 内没应答)—— 本机的推送/取远端会失败"
|
||
echo " 注意:这条只说明'到不了',不说明'地址写错了';两者要分开看。"
|
||
echo " ★ 这里判 WARN、而 AGC 闹钟(criteria-hygiene 第 6 条)对'远端不可达'判**红** ——"
|
||
echo " 两处政策相反是**有意的**,别以'一致性'为名统一掉(统一到哪边都会弄坏一半):"
|
||
echo " · 这里问的是'本机配置能不能用' ⇒ 离线不是配置错 ⇒ **WARN**;"
|
||
echo " · 那边问的是'凭证有没有进过远端历史' ⇒ 查不了就**答不出** ⇒ **红**"
|
||
echo " ('看不到'不等于'干净')。"
|
||
echo " 理由原文见 client/electron/test/criteria-hygiene.test.mjs 该条注释。"
|
||
fi
|
||
fi
|
||
|
||
# ★ `go vet`/`go test` 提到这个相位来跑(pi 评审 2026-09-14)。
|
||
#
|
||
# 原先的措辞是"**所有会红的门禁都跑过了**(前端 typecheck/test/build、共用模块同源、
|
||
# 各插件测试、插件构建)",而紧接着的一段就把 `go vet + go test` 列进"干跑没有执行"
|
||
# —— **那两条正是会红的门禁**,两段话自相矛盾,而读者只会读那句加粗的结论。
|
||
# 更实际的理由:这两道门恰恰最容易在**别人的机器上**红(Go 版本、依赖、平台),
|
||
# 而"第一个拿到 root 的人第一次跑门禁"正是 `--check` 要解决的场景。
|
||
# 它们不写系统目录(只写 go 缓存),所以放进这个相位没有副作用。
|
||
echo "==> 校验 Gateway 源码(go vet + go test)"
|
||
# ★★ 先把"前面哪道门红了"传出去,**再**跑 go vet —— 位置是实测定下来的,不是随手放的:
|
||
# 我第一版把这段放在文件末尾,实测 go vet 因 go 缓存权限红 ⇒ `set -e` 直接中止
|
||
# ⇒ **那段话永远走不到**。放在 go vet **之前**,无论后面谁红它都报得出来。
|
||
# (**一个只在"一切顺利"时才报的总结 = 只在没出问题时才说话的诊断**,同一个病。)
|
||
if [[ -n "${CHECK_GATE_RC:-}" ]]; then
|
||
echo " [FAIL] 前端门禁没过(退出码 ${CHECK_GATE_RC})—— 上面标了 [FAIL] 的那一处就是" >&2
|
||
echo " ⇒ 但**诊断已经全部跑完**(本次改动要的正是这个:别让第一道红吃掉后面的诊断)。" >&2
|
||
fi
|
||
# 环境不再在这里逐个预检 —— 开头 source 的 env-defaults.sh 已经保证
|
||
# HOME/TMPDIR/GOMODCACHE/PATH 齐备(那道预检是"第四次"的补丁,已被一处取代)。
|
||
# ★★ 环境失败**不许冒充代码缺陷**(pi 2026-09-15;规矩本来就在 `lib/env-defaults.sh:25`:
|
||
# "失败要说清是环境问题…不要让它冒充代码缺陷")。
|
||
#
|
||
# 实测过的形状:`go vet ./...` 报
|
||
# internal/middleware/auth.go:9:2: open /root/.cache/go-build/55/…-d: permission denied
|
||
# —— 它**指着源码文件**说 permission denied,而一行 .go 都没问题;跟着本步原来那句
|
||
# "不过 —— 先修好再安装" 就把人送去改代码。pi 用 `go vet ./internal/repo/`(通过)
|
||
# vs `go vet ./...`(死在缓存条目)两条命令就分开了 ⇒ 真因是 build 缓存不可用。
|
||
#
|
||
# 现在**两道**:① `env-defaults.sh` 的 ③c 预检 GOCACHE(可写、探针实测);
|
||
# ② 这里按**输出分类**兜住剩下的(预检过不了的在 ① 就 exit 2 了,这里是二手保险)。
|
||
# 分类命中 ⇒ 退出码 **2(环境)**,并且**不再说"先修好"**。
|
||
GOOUT="$(mktemp -t agentmail-govet-XXXXXX)"
|
||
if ! ( cd "$REPO/server" && go vet ./... && go test ./... ) >"$GOOUT" 2>&1; then
|
||
cat "$GOOUT" >&2
|
||
# ⚠️ 分类必须在 `rm` **之前**读那个文件 —— 先删再 grep 会让这条分类**永远不触发**
|
||
# (那正是本仓反复出现的形状:判据存在但永远不会响)。
|
||
if grep -qE 'permission denied|cannot find (package|module)|no space left on device|module cache not found|build cache is required|GOCACHE' "$GOOUT"; then
|
||
rm -f "$GOOUT"
|
||
echo " [FAIL] go vet / go test **跑不起来**(环境问题,不是代码缺陷)" >&2
|
||
echo " 上面报错里出现了缓存/权限/空间类的字样 —— 它常常**指着源码文件**报错," >&2
|
||
echo " 但一行 .go 都没问题(实测形状:open <GOCACHE>/…-d: permission denied)。" >&2
|
||
echo " 药方:GOCACHE=<可写目录> 重跑。按仓库约定这算**环境**(退出码 2)。" >&2
|
||
# ★★ 干跑里**不在这里退**(pi 2026-09-15 的相位标记):这一步只 gate 得了 go 那一段,
|
||
# 而干跑的产品是诊断 ⇒ 记账、继续,让后面与它无关的门(干的产物自证等)照跑,
|
||
# 最后由末尾的 `agentmail_env_flush` 统一按环境 exit 2。
|
||
# ⚠️ 我第一版**没改这里**,于是末尾那个 flush **永远走不到**
|
||
# (实测:退出码 2,末尾环境报文一行都没有)—— 又一个"判据在,但走不到"。
|
||
if [ "${AGENTMAIL_ENV_DEFER:-0}" = "1" ]; then
|
||
# 用**同一个 KEY** `gocache`:这条与 ③c 的预检是**同一个根因**
|
||
#(缓存不可写 ⇒ go 跑不起来),不该被数成两项。
|
||
agentmail_env_defer "gocache" "go vet / go test 跑不起来:缓存/权限/空间问题(见上面原文)。
|
||
药方:GOCACHE=<可写目录> 重跑。"
|
||
else
|
||
exit 2
|
||
fi
|
||
else
|
||
rm -f "$GOOUT"
|
||
echo " [FAIL] go vet / go test 不过 —— 先修好再安装" >&2
|
||
echo " (本次输出里**没有**缓存/权限/空间类字样 ⇒ 按**代码缺陷**处理。)" >&2
|
||
exit 1
|
||
fi
|
||
fi
|
||
rm -f "$GOOUT"
|
||
echo " [ OK ] go vet + go test 通过"
|
||
cat <<'EOF'
|
||
|
||
==> 干跑结束:**除了"构建 Gateway 二进制"之外,所有会红的门禁都跑过了**
|
||
(前端 typecheck/test/build、共用模块同源、各插件测试、插件构建、
|
||
**go vet + go test**)。
|
||
干跑的成功只能说明"门是好的",不能说明"装得上"。
|
||
|
||
下面这些步骤干跑**没有执行**(它们要写工作区外的目录,正式安装需要 root 或先放行):
|
||
· 前端产物 → server/internal/static/static/
|
||
· 构建 Gateway(go build -trimpath -o server/agentmail-gateway;vet/test 上面已跑)
|
||
· 生成 /etc/agentmail/*.env(已存在的不覆盖)+ chmod 0600
|
||
· 装 systemd unit → /etc/systemd/system/,daemon-reload + enable --now
|
||
· 把邮件工具扩展注册进 ~/.pi/agent/settings.json
|
||
|
||
拿不准就先跑 ./deploy/install.sh --check;正式安装用 sudo ./deploy/install.sh。
|
||
EOF
|
||
# ★ **无条件 `exit 0` 是上一个版本的洞**(pi 2026-09-15):干跑在门禁红时也报成功,
|
||
# 而这条链上引用过 `--check` 的 `exit 0` 当绿读数 ⇒ 那个读数不可信了。
|
||
# 现在只要前面记下了非零码就把那个码传出去。**"诊断跑完了" ≠ "门禁都过了"。**
|
||
#
|
||
# ★★ 推迟的环境问题在这里统一报(pi 2026-09-15):它们**不是"某道门红了"**,
|
||
# 而是"**有一步跑不起来**"。所以优先级最高、退出码 **2(环境)**,
|
||
# 而诊断**已经全部跑完**(这正是推迟到末尾的理由:环境预检在开头 exit 2
|
||
# 会把与它毫无关系的门一并吃掉 —— 那是本仓修过一次的老毛病,入口换成了环境预检而已)。
|
||
# ⚠️ 位置也得对:我第一版把这段放在**前端门禁的 early-return 之后**,
|
||
# 于是"环境不足 + 前端门禁红"时它**永远不会执行** —— 又一次"判据在,但走不到"。
|
||
# 实测抓到(`--check` 仍退 1、环境报文一行都没有)。
|
||
if ! agentmail_env_flush; then
|
||
echo ' ⇒ 退出码 2(环境)。**注意这与「某道门红了(1)」是两件事**:环境不足时,"门绿"也装不上。' >&2
|
||
exit 2
|
||
fi
|
||
exit "${CHECK_GATE_RC:-0}"
|
||
fi
|
||
|
||
echo "==> 前端产物嵌入 Gateway"
|
||
# 只清构建产物,不能 rm -rf 整个目录:
|
||
# placeholder.html 在版本库里(让 go:embed 在新克隆里能编译),
|
||
# 删掉它会让 git 看到一个本地删除,下一次 commit -a 就把它从仓库里带走了。
|
||
rm -rf "$REPO/server/internal/static/static/assets"
|
||
rm -f "$REPO/server/internal/static/static/index.html"
|
||
cp -r "$REPO/client/electron/dist/." "$REPO/server/internal/static/static/"
|
||
|
||
echo "==> 构建 Gateway(单二进制,内含前端 + SQLite)"
|
||
# 先删再建:go build -o 到已存在的路径时可能拿到 stale 二进制(此坑中过多次)
|
||
# `-trimpath` 与 redeploy-gateway.sh 同源:不带它时 Go 会把源文件绝对路径编进
|
||
# 生产二进制(2026-09-14 实测 57 处 /home/program/agentmail/…)。
|
||
rm -f "$REPO/server/agentmail-gateway"
|
||
( cd "$REPO/server" && go vet ./... && go test ./... && go build -trimpath -o "$REPO/server/agentmail-gateway" ./cmd/server )
|
||
|
||
echo "==> 安装到 $PREFIX"
|
||
# ★ 权限位**显式给**,不靠 umask(pi 评审 2026-09-14 提的低优先项,实测是真问题):
|
||
# `install -d` 的权限受进程 umask 影响,而下面这些目录里装着**邮件数据库**。
|
||
# 实测本机生产:`/opt/agentmail/data` = **755**、`agentmail.db` = **644**(全局可读),
|
||
# 而同一段代码里 `agent-config`/`pi-config` 是显式 `-m 0700` —— 同一个脚本里两套口径。
|
||
# 数据库里是全部往来邮件(含正文与地址),不该全局可读。
|
||
# 目录 0700 是真正的保护(库文件权限由创建它的进程决定);
|
||
# `/etc/agentmail` 里是密码与密钥,同样收到 0700。
|
||
install -d -m 0755 "$PREFIX"
|
||
install -d -m 0700 "$PREFIX/data"
|
||
install -d -m 0700 "$ETC"
|
||
install -m 0755 "$REPO/server/agentmail-gateway" "$PREFIX/agentmail-gateway"
|
||
|
||
# ---- env 文件:仅在缺失时生成,密码随机 ----
|
||
if [[ ! -f "$ETC/gateway.env" ]]; then
|
||
ADMIN_PASS="$(head -c 18 /dev/urandom | base64 | tr -d '/+=' | head -c 20)"
|
||
cat > "$ETC/gateway.env" <<EOF
|
||
# AgentMail Gateway 环境变量
|
||
ADMIN_USER=admin
|
||
ADMIN_PASSWORD=$ADMIN_PASS
|
||
|
||
# 生产环境走 HTTPS 时置 true,Cookie 才会带 Secure 标记
|
||
SECURE_COOKIE=false
|
||
|
||
# 允许的前端跨域来源(逗号分隔);单二进制自带前端时通常无需配置
|
||
# CORS_ORIGINS=https://mail.example.com
|
||
EOF
|
||
chmod 0600 "$ETC/gateway.env"
|
||
echo " 已生成 $ETC/gateway.env(管理员 admin / $ADMIN_PASS)"
|
||
else
|
||
echo " $ETC/gateway.env 已存在,保留不动"
|
||
fi
|
||
|
||
if [[ ! -f "$ETC/opencode.env" ]]; then
|
||
cat > "$ETC/opencode.env" <<EOF
|
||
# opencode mail-bridge 插件配置
|
||
AGENTMAIL_GATEWAY_URL=http://127.0.0.1:8180
|
||
AGENTMAIL_AGENT_NAME=opencode
|
||
|
||
# 接入密钥:留空时插件会在 \$AGENTMAIL_CONFIG_DIR/agent.key 本地生成一把并打印到日志,
|
||
# 拿着它到 Web 后台「Agent 密钥」登记即可接入(journalctl -u opencode-serve | grep mail-bridge)。
|
||
# 也可以先在后台签发密钥,再把它填在这里。
|
||
AGENTMAIL_AGENT_KEY=
|
||
AGENTMAIL_CONFIG_DIR="$PREFIX/agent-config"
|
||
|
||
# 处理来信时使用的模型
|
||
AGENTMAIL_REPLY_PROVIDER=llmsproxy
|
||
AGENTMAIL_REPLY_MODEL=AUTO
|
||
|
||
# opencode serve 绑在 127.0.0.1,但同机任何进程都能调它开会话,
|
||
# 因此仍然设置访问密码。
|
||
OPENCODE_SERVER_PASSWORD=$(head -c 18 /dev/urandom | base64 | tr -d '/+=' | head -c 24)
|
||
EOF
|
||
chmod 0600 "$ETC/opencode.env"
|
||
install -d -m 0700 "$PREFIX/agent-config"
|
||
echo " 已生成 $ETC/opencode.env(server password 为随机值;接入密钥首启时本地生成)"
|
||
else
|
||
echo " $ETC/opencode.env 已存在,保留不动"
|
||
fi
|
||
|
||
if [[ ! -f "$ETC/pi.env" ]]; then
|
||
cat > "$ETC/pi.env" <<EOF
|
||
# pi mail-bridge 插件配置
|
||
AGENTMAIL_GATEWAY_URL=http://127.0.0.1:8180
|
||
AGENTMAIL_AGENT_NAME=pi
|
||
|
||
# 接入密钥:留空时插件会在 \$AGENTMAIL_CONFIG_DIR/agent.key 本地生成一把并打印到日志,
|
||
# 拿着它到 Web 后台「Agent 密钥」登记即可接入(journalctl -u pi-mail-bridge | grep 密钥)。
|
||
AGENTMAIL_AGENT_KEY=
|
||
AGENTMAIL_CONFIG_DIR="$PREFIX/pi-config"
|
||
|
||
# 处理来信时首选的模型。留空表示用 pi 自己的默认(~/.pi/agent/settings.json)。
|
||
# 管理员在后台划定模型范围时,范围优先于这里的设置(B-2.2)。
|
||
AGENTMAIL_REPLY_PROVIDER=llmsproxy
|
||
AGENTMAIL_REPLY_MODEL=AUTO
|
||
|
||
# 单轮上限。超时**不算失败**:pi 仍在后台跑,转发挂在 agent_end 上,
|
||
# 说完了自然会转出去。这个值只决定「多久之后不再阻塞投递流水线」。
|
||
AGENTMAIL_TURN_TIMEOUT_MS=60000
|
||
EOF
|
||
chmod 0600 "$ETC/pi.env"
|
||
install -d -m 0700 "$PREFIX/pi-config"
|
||
echo " 已生成 $ETC/pi.env(接入密钥首启时本地生成)"
|
||
else
|
||
echo " $ETC/pi.env 已存在,保留不动"
|
||
fi
|
||
|
||
echo "==> 安装 systemd 单元"
|
||
# 单元与 drop-in 的**唯一真相**在 deploy/systemd/(镜像 systemd 的目录结构)。
|
||
#
|
||
# 以前这里逐个 install 三个 .service,而 drop-in(失败通知/重启退避等)只存在于
|
||
# /etc 里 —— 于是"仓库里的是旧的、机器上的是新的",谁跑一次本脚本就把部署退回
|
||
# 源码目录(旧版 ExecStart 指向 /home/program/agentmail/plugins/...)。
|
||
install -d "$PREFIX/bin"
|
||
install -m 0755 "$REPO/deploy/service-failure-notify.mjs" "$PREFIX/bin/service-failure-notify.mjs"
|
||
|
||
# 边界工具(Landlock 沙箱):只有本机能真建立边界时才装。理由与判据见
|
||
# redeploy-gateway.sh 同一段(am-sandbox 自己 fail closed,装个用不了的工具
|
||
# 等于给"档位=workspace"发空头支票)。
|
||
SANDBOX_STAGE="$(mktemp -d)/am-sandbox"
|
||
if ( cd "$REPO/server" && go build -trimpath -o "$SANDBOX_STAGE" ./cmd/am-sandbox ) \
|
||
&& AM_SANDBOX="$SANDBOX_STAGE" bash "$REPO/deploy/check-sandbox.sh" >/tmp/am-sandbox-check.log 2>&1; then
|
||
install -m 0755 "$SANDBOX_STAGE" "$PREFIX/bin/am-sandbox"
|
||
echo " 边界工具已装(判据 $(grep -c '\[ OK \]' /tmp/am-sandbox-check.log) 项全绿)"
|
||
else
|
||
echo " [警告] 本机建立不起 Landlock 边界:不装 am-sandbox(见 /tmp/am-sandbox-check.log)" >&2
|
||
fi
|
||
rm -f "$SANDBOX_STAGE"
|
||
while IFS= read -r f; do
|
||
rel="${f#"$REPO/deploy/systemd/"}"
|
||
install -D -m 0644 "$f" "/etc/systemd/system/$rel"
|
||
done < <(find "$REPO/deploy/systemd" -type f)
|
||
systemctl daemon-reload
|
||
|
||
echo "==> 启用并启动"
|
||
systemctl enable --now agentmail-gateway.service
|
||
if command -v opencode >/dev/null 2>&1; then
|
||
systemctl enable --now opencode-serve.service
|
||
else
|
||
echo " 未找到 opencode,跳过 opencode-serve(装好后执行:systemctl enable --now opencode-serve)"
|
||
fi
|
||
if [[ -d "$PI_SDK" ]]; then
|
||
systemctl enable --now pi-mail-bridge.service
|
||
else
|
||
echo " 未找到 pi SDK,跳过 pi-mail-bridge(装好后执行:systemctl enable --now pi-mail-bridge)"
|
||
fi
|
||
|
||
sleep 3
|
||
echo
|
||
echo "==> 状态"
|
||
systemctl --no-pager --lines=0 status agentmail-gateway.service || true
|
||
curl -sf -m 5 http://127.0.0.1:8180/health && echo " 健康检查通过" || echo " 健康检查失败,查看:journalctl -u agentmail-gateway -n 50"
|
||
|
||
# ─────────────────────────────────────────────────────────────────
|
||
# 日常只改后端时不需要重跑本脚本(它会重装 npm 依赖、重写 systemd 单元、
|
||
# 重新生成 env)。换二进制走:
|
||
#
|
||
# bash deploy/redeploy-gateway.sh # 含测试与后置验证
|
||
# bash deploy/redeploy-gateway.sh --dry-run # 先看要做什么
|
||
#
|
||
# 那个脚本用 sqlite3 .backup 备份数据库、install -m 0755 原子替换二进制,
|
||
# 并在验证不过时自动回滚。
|