feat(deploy): 部署收尾刷新 current 連結農場

$JSC_HOME/current 是一組不帶版本的符號連結,每個外掛一條,指向快取裡帶版本號的實體目錄。技能文件裡所有跨外掛的腳本呼叫都以這一層為根,因為它不帶版本號、寫得進權限允許清單。

問題是沒有任何東西會更新這些連結,只有 wire-cli.sh 會更新 jsc-hooks 那一條。其餘幾條是人手動建的,建好之後就停在當時的版本。實際後果是部署完四個 domain 之後,快取裡是新版,連結卻還指著舊版:助理巡檢照文件的字面路徑跑,跑到的是舊腳本,而其中一個舊版底下根本沒有它要呼叫的檔案。失敗無聲,只有心跳停止,沒人盯就不會有人發現。

部署改成收尾時刷新整組連結。挑部署來做,是因為它本來就知道裝了哪些 domain、裝到哪個版本,資訊最齊。

四個設計決定:

基準 CLI 取 claude、codex、copilot、kiro 之中第一支找得到的,整輪只有那一支寫連結。連結農場只有一組,不可能同時指向五個 CLI 的副本;而五支 CLI 是平行跑的,五支都寫會互相覆寫,最後指到哪一份是隨機的、出事重現不出來。antigravity 一律不當基準,它的來源是本地 clone,而那份 clone 明文允許是維護者的開發樹,把全機器路徑指到做到一半的樹正好是這次要修的那種毛病。

版本目錄取版本排序最大、且真的有 plugin.json、且本身不是符號連結的那一層。要求 plugin.json 是因為裝到一半的目錄沒有它,挑到會讓連結指向不完整的外掛而且照樣不報錯。

解除安裝的判準是「連結還在、指向卻沒了」,不是「這輪解除安裝過這個 domain」。只解除安裝其中一支 CLI 時,連結可能還指著另一支手上完好的副本,那一條必須留著。

連結建立失敗印一行繼續,不記進失敗清單。這一段跑在外掛都裝好之後,部署本身已經成功;記成失敗會連帶跳過重啟閘門,操作者拿到的是一台明明裝好卻被說成失敗的機器。缺陷仍然看得見,因為輸出多了一行。

目標存在但不是符號連結時一律不覆寫,印 skip 要人工處理。ln -sfn 對著實體目錄下手會把連結建進那個目錄裡,農場當場壞掉還不會報錯。

新增 link 行讓呼叫端讀得到每一條連結指到哪裡,狀態五選一。技能文件與行為契約跟著更新,另修正一句因這次改動而失效的敘述:原本寫 current 底下沒有 jsc-cli,刷新之後那條連結會存在,改成講清楚它仍然靠不住,因為第一次建起它的正是這一輪。
This commit is contained in:
2026-09-03 12:19:59 +08:00
parent 253443e94e
commit a439636c4a
4 changed files with 179 additions and 13 deletions
+150 -1
View File
@@ -12,10 +12,21 @@
# warn<TAB>{domain}<TAB>{原因} 相依版本不符,仍照樣更新的提醒
# compat<TAB>codex<TAB>{舊路徑}<TAB>{新路徑} codex 舊版快取路徑補成指向新版的相容連結
# note<TAB>{cli}<TAB>{原因} 非逐指令的說明(例:kiro 整批改走複製退路的理由)
# link<TAB>{domain}<TAB>{狀態}<TAB>{連結路徑}<TAB>{指向或原因} current 連結農場這一條的刷新結果
# restart<TAB>{路徑} 這次寫下的重啟狀態檔
# requires<TAB>{domain}<TAB>{檢查結果} update 前的 jsc.requires 檢查
# result<TAB>{cli}<TAB>{mode}<TAB>{domain 清單}<TAB>{ok|fail}
# 結束碼:全部指令成功 0;任一指令失敗 1;參數錯誤 2。skip、warn、note 不算失敗,但呼叫端要據實回報。
# 結束碼:全部指令成功 0;任一指令失敗 1;參數錯誤 2。skip、warn、note、link 不算失敗,
# 但呼叫端要據實回報。
# link 那幾行講的是 $JSC_HOME/current/ 這一組不帶版本號的符號連結:一個 domain 一條,
# 指向該外掛在快取裡帶版本號的實體目錄。技能文件裡所有跨外掛的腳本呼叫都以那一層當根,
# 因為它不帶版本號、寫得進權限允許清單。install 與 update 收尾會把整組連結刷新一次,
# uninstall 收尾會清掉指向已消失的那幾條。狀態欄的五個值:
# ok 連結已經指到這次實際安裝的版本目錄
# removed 指向已消失,這一條清掉了
# skip 這一輪不動它,原因寫在最後一欄
# fail 該指的目標取不到或連結建不起來,這一條維持原樣,指向可能還是舊版本
# dryrun 乾跑,只算出會指到哪裡,沒有真的寫
# update 前每個 domain 都先跑一次同目錄的 check-requires.sh,它的四種結束碼分流如下:
# 0 相依符合,或沒有宣告相依 → 照常更新這個 domain
# 1 相依版本不符或缺相依 plugin
@@ -57,6 +68,7 @@ MKT="$HOST/$OWNER/meta.git"
REPO_BASE="$HOST/$OWNER"
LOCAL_DIR="${JSC_LOCAL_PLUGINS:-${JSC_HOME:-$HOME/.jsc}/plugins}"
KIRO_SKILLS="${JSC_KIRO_SKILLS:-$HOME/.kiro/skills}"
CURRENT_DIR="${JSC_HOME:-$HOME/.jsc}/current"
DRYRUN="${JSC_DEPLOY_DRYRUN:-0}"
FAILED=0
@@ -113,6 +125,141 @@ codex_preserve_old_plugin_cache() { # $1=plugin name $2=required file under vers
done
}
# 連結農場一台機器只有一組,五支 CLI 卻各裝各的副本,所以只能挑一支當基準。
# 挑法是固定的偏好順序,取這台機器上第一支裝得到的:
# claude 擺第一——現有那幾條連結本來就指向它的快取,換基準等於把每一條寫進技能文件的
# 路徑悄悄換到另一份副本上,而文件不會跟著改。
# codex 次之,同樣是 CLI 自己的外掛快取寫下來的帶版本目錄,版本號是它裝到什麼就是什麼。
# copilot 與 kiro 只當退路:它們的路徑不帶版本號,連結指得到,卻查不出指的是哪一版。
# antigravity 一律不當基準。它的來源是本地 clone,那份 clone 允許是維護者的開發樹,
# sync_local 碰到未提交的變更還會刻意不 pull;把全機器的路徑指到一份做到一半的樹,
# 正好就是這次要修掉的那種無聲跑錯腳本。
# 用 command -v 判斷裝沒裝,跟 detect-clis.sh 的認定一致,所以每一支平行跑的 deploy.sh
# 都會算出同一個基準,整輪只有一支真的去寫連結。五支都寫的話,平行行程會互相覆寫,
# 最後指到哪一份是隨機的,出了事也重現不出來。
link_base_cli() {
for _b in claude codex copilot kiro; do
if command -v "$(cli_bin "$_b")" >/dev/null 2>&1; then
printf '%s' "$_b"
return 0
fi
done
return 1
}
# 某個外掛在快取根目錄底下的實體版本目錄。claude 與 codex 的快取一個版本一層,
# 取版本排序最大、而且真的有 plugin.json 的那一層:裝到一半的目錄沒有 plugin.json,
# 挑到它會讓連結指向一份不完整的外掛,而且照樣不會報錯。
# 本身就是符號連結的項目也要跳過——舊版相容連結就是那樣,指過去只是多繞一層,
# 原目標一被清掉還會跟著一起斷。
latest_version_dir() { # $1=某個外掛的快取根目錄
[ -d "$1" ] || return 1
_vfound=$(
for _vp in "$1"/*; do
[ -d "$_vp" ] || continue
[ -L "$_vp" ] && continue
[ -f "$_vp/plugin.json" ] || continue
printf '%s\n' "$_vp"
done | sort -V | tail -n1
)
[ -n "$_vfound" ] || return 1
printf '%s' "$_vfound"
}
# 基準 CLI 底下這個 domain 的實體目錄。這裡只收得起當基準的那四支,
# antigravity 不列:它從來不會被選成基準,補一條沒人走的分支只會讓人以為那也是選項。
plugin_dir() { # $1=CLI 代號 $2=domain;印出實體目錄,取不到回 1
case "$1" in
claude) latest_version_dir "$HOME/.claude/plugins/cache/jsc/jsc-$2" ;;
codex) latest_version_dir "$(codex_plugin_cache_root "jsc-$2")" ;;
copilot)
_pd="${COPILOT_HOME:-$HOME/.copilot}/installed-plugins/jsc/jsc-$2"
[ -d "$_pd" ] || return 1
printf '%s' "$_pd"
;;
kiro)
_pd="$KIRO_SKILLS/jsc-$2"
[ -d "$_pd" ] || return 1
printf '%s' "$_pd"
;;
*) return 1 ;;
esac
}
link_line() { # $1=domain 或 - $2=狀態 $3=連結路徑 $4=指向或原因
printf 'link\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4"
}
# 刷新一條連結。
# 失敗只記一行、不中止整輪:這一段跑在外掛都裝好之後,部署本身已經成功了。把整輪記成
# 失敗會連帶跳過重啟閘門與 result 行,操作者拿到的是一台明明裝好、卻被說成失敗的機器,
# 反而更難處置。連結沒刷新確實是缺陷,但補救方式是一行看得見的紀錄加上人來判斷,
# 不是把一次成功的部署丟掉。
refresh_one() { # $1=基準 CLI $2=domain
_rl="$CURRENT_DIR/jsc-$2"
# 目標已經在、又不是符號連結時一律不覆寫。ln -sfn 對著一個實體目錄下手,會把連結建進
# 那個目錄裡面(變成 current/jsc-{domain}/jsc-{domain}),農場當場就壞了還不會報錯;
# 改成 rm -rf 清掉更糟,那是這支腳本沒建過、也可能沒有第二份的內容。
if [ -e "$_rl" ] && [ ! -L "$_rl" ]; then
link_line "$2" skip "$_rl" '目標已存在而且不是符號連結,這一條不覆寫,請人工確認'
return 0
fi
if ! _rt=$(plugin_dir "$1" "$2"); then
if [ "$DRYRUN" = 1 ]; then
link_line "$2" dryrun "$_rl" "乾跑沒有真的安裝,$1 底下還取不到 jsc-$2 的版本目錄"
else
link_line "$2" fail "$_rl" "$1 底下找不到 jsc-$2 的版本目錄,這一條維持原樣"
fi
return 0
fi
if [ "$DRYRUN" = 1 ]; then
link_line "$2" dryrun "$_rl" "$_rt"
return 0
fi
if mkdir -p "$CURRENT_DIR" 2>/dev/null && ln -sfn "$_rt" "$_rl" 2>/dev/null; then
link_line "$2" ok "$_rl" "$_rt"
else
link_line "$2" fail "$_rl" '連結建不起來,這一條維持原樣,指向可能還是舊版本'
fi
}
# 解除安裝之後清掉指向已經消失的那一條。判準是連結還在、指向卻沒了:
# 這一輪只解除安裝其中一支 CLI 時,連結可能還指著另一支 CLI 手上完好的副本,那一條要留著。
# 斷掉的連結不清,等於對外宣稱一支已經不在機器上的外掛還裝著,下一輪體檢會照著它報錯狀態。
prune_one() { # $1=domain
_ql="$CURRENT_DIR/jsc-$1"
[ -L "$_ql" ] || return 0
[ -e "$_ql" ] && return 0
if [ "$DRYRUN" = 1 ]; then
link_line "$1" dryrun "$_ql" '指向已消失,這一條會被清掉'
return 0
fi
if rm -f "$_ql" 2>/dev/null; then
link_line "$1" removed "$_ql" '-'
else
link_line "$1" fail "$_ql" '指向已消失卻清不掉,這一條還是斷的'
fi
}
# 整組連結的刷新收尾。跑在各 CLI 的部署動作全部結束之後,不夾在每個 domain 的安裝指令
# 中間:連結要指的是寫完的內容,中途去指有機會指到只寫了一半的目錄。
refresh_links() {
if ! _lbase=$(link_base_cli); then
link_line - skip "$CURRENT_DIR" '這台機器一支基準 CLI 都沒裝到,沒有版本目錄可指'
return 0
fi
if [ "$CLI" != "$_lbase" ]; then
link_line - skip "$CURRENT_DIR" "這一輪的基準 CLI 是 $_lbase,$CLI 這一支不動連結"
return 0
fi
for _ldom in $DOMAINS; do
case "$MODE" in
install|update) refresh_one "$_lbase" "$_ldom" ;;
uninstall) prune_one "$_ldom" ;;
esac
done
}
usage() {
echo "用法:deploy.sh [-n] {install|update|uninstall} {claude|codex|copilot|antigravity|kiro} {domain} [domain...]" >&2
exit 2
@@ -432,6 +579,8 @@ case "$CLI" in
*) usage ;;
esac
refresh_links
if [ "$FAILED" -eq 0 ]; then
mark_restart
printf 'result\t%s\t%s\t%s\tok\n' "$CLI" "$MODE" "$DOMAINS"