Files
jiantw83andClaude Opus 5 cd832460ee fix(deploy): 本機複本一個 domain 一把鎖,重啟閘門不等整輪判定
實測踩到兩件事,同一輪、同一個根因。

那一份本機複本一台機器只有一份,而技能規定五支 CLI 平行部署——平行是對的,
它們寫的是不同的外掛目錄。但複本不是:五支都會來 pull 同一個目錄,連只需要
讀 manifest 的那幾支也會(相依檢查從那裡讀)。git 對同一個存取庫的併發寫入
沒有保護,於是同一輪裡兩支撞在一起,一支拿不到 ORIG_HEAD.lock、一支的遠端
refs 換不上去。

後果是最難查的那一種:兩支的整輪判定都變成 fail,而外掛其實全部裝好了——
報告說失敗、實際成功,而真正的原因跟部署無關。

改成一個 domain 一把 mkdir 鎖:那是檔案系統這一層唯一原子的建立動作。等不到
就印一行 warn 改用磁碟上的內容,別人正在拉同一份,硬等下去只是排隊。上一輪
中途死掉留下的鎖用年紀判,門檻放寬到等待秒數的四倍。

複本已經在磁碟上而 pull 拉不動的那一種,也改成只印 warn、不判整輪失敗:
內容在,只是可能比遠端舊。但一定要印出來——安靜地裝一份舊內容,是這一組
工具最怕的那種失效。clone 不存在那一種照舊算失敗,磁碟上根本沒東西可裝。

第二件事更嚴重。原本的寫法是「整輪判定成功才掛重啟閘門」,於是那一輪的
fail 把閘門一起跳過了:外掛換了一半,而唯一沒有被告知要重啟的,剛好就是
正在跑那份剛被換掉的程式碼的那一支 CLI。一道只在成功時才生效的提醒,在最
需要它的那一次不會出現。改成 install 與 update 一律先掛,再判 result。

順帶補一支安全截斷:訊息截長度用的是 cut -c,那數的是位元組,多位元組字
剛好被切成兩半會留一個替代字元,而亂碼不影響結束碼、沒有人會來報。

乾跑那一路一步都不動,連鎖都不取——取鎖是建目錄,那已經是寫入。原本改完
之後乾跑會真的去 pull,這一版修回來了。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 12:35:27 +08:00

675 lines
31 KiB
Bash
Executable File
Raw Permalink 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 sh
# deploy.sh — 對單一 CLI 執行 jsc 技能組的安裝、更新或解除安裝。
# 用法:
# deploy.sh [-n] {install|update|uninstall} {claude|codex|copilot|antigravity|kiro} {domain} [domain...]
# -n 或 --dry-run(或 JSC_DEPLOY_DRYRUN=1):只印指令,不執行。
# {domain} 裸名或帶 jsc- 前綴皆可(例:ask 或 jsc-ask),腳本會自動去掉前綴再組
# jsc-{domain}@jsc;marketplace.json 的 plugins[].name 本身就帶前綴,不必事先剝掉。
# 輸出(TSV,一行一筆):
# cmd<TAB>{指令} 即將執行的指令
# exit<TAB>{結束碼}<TAB>{指令} 該指令的結束碼;dry-run 時結束碼印「-」
# skip<TAB>{domain}<TAB>{原因} 本地 clone 是開發中的樹,略過 git pull
# warn<TAB>{domain}<TAB>{原因} 照樣裝下去、但要人知道的事:相依版本不符,
# 或本機複本這一輪沒有重新拉取(拿不到
# 更新鎖,或 git pull 回非零),那時候
# 裝的是磁碟上現有的內容,可能不是最新版
# compat<TAB>codex<TAB>{舊路徑}<TAB>{新路徑} codex 舊版快取路徑補成指向新版的相容連結
# note<TAB>{cli}<TAB>{原因} 非逐指令的說明(例:kiro 整批改走複製退路的理由)
# link<TAB>{domain}<TAB>{狀態}<TAB>{連結路徑}<TAB>{指向或原因} current 連結農場這一條的刷新結果
# restart<TAB>{路徑} 這次寫下的重啟狀態檔。install 與 update
# 一律寫,整輪判定成 fail 也照寫:外掛
# 已經換了一部分,這時候更需要重啟
# requires<TAB>{domain}<TAB>{檢查結果} update 前的 jsc.requires 檢查
# result<TAB>{cli}<TAB>{mode}<TAB>{domain 清單}<TAB>{ok|fail}
# 結束碼:全部指令成功 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
# → 印一行 warn,這個 domain 照樣更新(跳過會讓落後的 domain 永遠更新不到)
# 4 判不出結論(manifest 讀不到、不是有效 JSON、缺 python3)
# → 印一行 note,這個 domain 照樣更新。沒有證據不等於落後,話要跟 warn 分開講
# 2 或其他 檢查腳本自己出錯(用法錯誤或腳本壞掉)→ 印一行 skip,跳過這個 domain 並記為失敗
# marketplace 指令一輪只跑一次:install 與 update 先跑,uninstall 最後跑。
# 各 CLI 的細節都收在這裡,SKILL.md 只描述何時呼叫與參數:
# antigravity 不接受 gitea URL,先 clone 到本地再從路徑安裝,更新時 pull 同一份。
# kiro 的執行檔是 kiro-cli;先探測這個版本認不認得 plugin 子指令(部分版本已經完全
# 移除,例:2.18.1),認得才逐一嘗試、失敗才退回複製,不認得就整批直接走複製,
# 不逐一撞一次「unrecognized subcommand」才退回。
# install 或 update 全數成功時,收尾轉呼叫 jsc-hooks 的 restart-gate.sh require,掛上這支 CLI
# 的重啟閘門(狀態檔一支 CLI 一份,落在 $JSC_HOME/restart-required.d/{cli})。擋人邏輯不在
# 這裡:由 jsc-hooks 讀該 CLI 那一份提示使用者重啟,逃生門 JSC_RESTART_GATE=off 也由那邊判讀。
# 更新指引與移除指引由同目錄的 write-guides.sh 產生,一輪部署跑一次,不在這支腳本裡:
# 這支腳本的職責是「對單一 CLI 部署」,指引寫的是整台機器的樣貌。
# 環境變數:
# JSC_HOME hook 資料目錄,重啟狀態檔寫在這裡(預設 ~/.jsc)
# GITEA_HOST Gitea 站台,可省略 scheme(預設 https://gitea.jsc.idv.tw)
# JSC_GITEA_OWNER 存取庫的 owner(預設 plugins)
# JSC_LOCAL_PLUGINS antigravity/kiro 用的本地 clone 目錄
# 預設 $JSC_HOME/plugins(即 ~/.jsc/plugins),刻意不用 ~/plugins:
# 那是維護者放開發 checkout 的地方,pull 下去會蓋掉未提交的工作。
# 指到開發中的樹(有未提交變更或未推送的 commit)時只印 skip,不 pull。
# JSC_KIRO_SKILLS kiro 退路用的 skills 目錄(預設 ~/.kiro/skills)
# JSC_DEPLOY_DRYRUN 設為 1 等同 -n
set -u
HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
# Gitea 站台一律讀 GITEA_HOST(技能準則指定的變數),未設定才用正本站台。
HOST="${GITEA_HOST:-https://gitea.jsc.idv.tw}"
case "$HOST" in http://*|https://*) ;; *) HOST="https://$HOST" ;; esac
HOST="${HOST%/}"
OWNER="${JSC_GITEA_OWNER:-plugins}"
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
# CLI 代號 → 實際執行檔。唯一真實來源是 jsc-hooks 的 hooks/lib.sh cli_bin()。
# 這裡保留一份副本,因為這支腳本是整組技能的安裝入口:jsc-hooks 還沒裝上來時
# 也要能跑,不能 source 一個可能不存在的檔案。lib.sh 的對應表改了就同步改這裡。
cli_bin() { # $1=CLI 代號
case "$1" in
antigravity) printf 'agy' ;;
kiro) printf 'kiro-cli' ;;
*) printf '%s' "$1" ;;
esac
}
codex_plugin_cache_root() { # $1=plugin name
printf '%s/plugins/cache/jsc/%s\n' "${CODEX_HOME:-$HOME/.codex}" "$1"
}
codex_plugin_versions() { # $1=plugin name
_dir=$(codex_plugin_cache_root "$1")
[ -d "$_dir" ] || return 0
find "$_dir" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -exec basename {} \; 2>/dev/null | sort -V
}
codex_latest_plugin_dir() { # $1=plugin name $2=required file under version dir
_plugin="$1"
_required="$2"
_dir=$(codex_plugin_cache_root "$_plugin")
[ -d "$_dir" ] || return 1
for _p in "$_dir"/*; do
[ -d "$_p" ] || continue
[ ! -L "$_p" ] || continue
[ -f "$_p/$_required" ] || continue
printf '%s\n' "$_p"
done | sort -V | tail -n1
}
codex_preserve_old_plugin_cache() { # $1=plugin name $2=required file under version dir;stdin=更新前版本清單
[ "$DRYRUN" = 1 ] && return 0
_plugin="$1"
_required="$2"
_new=$(codex_latest_plugin_dir "$_plugin" "$_required" || true)
[ -n "$_new" ] || return 0
_root=$(codex_plugin_cache_root "$_plugin")
while IFS= read -r _version; do
[ -n "$_version" ] || continue
_old="$_root/$_version"
[ "$_old" != "$_new" ] || continue
if [ -e "$_old" ] && [ ! -L "$_old" ]; then
continue
fi
ln -sfn "$_new" "$_old" || continue
printf 'compat\tcodex\t%s\t%s\n' "$_old" "$_new"
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
}
# 找 jsc-hooks 的 restart-gate.sh。優先用穩定連結與安裝後複製目錄,再找並排工作樹,
# 最後才掃各 CLI 的 plugin 快取。找不到就回非零,由呼叫端據實回報。
restart_gate_sh() {
if [ -n "${JSC_HOOKS_DIR:-}" ] && [ -f "$JSC_HOOKS_DIR/restart-gate.sh" ]; then
printf '%s\n' "$JSC_HOOKS_DIR/restart-gate.sh"; return 0
fi
_root=$(cd "$(dirname "$0")/.." 2>/dev/null && pwd) || return 1
for _c in \
"${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks/hooks/restart-gate.sh" \
"${JSC_LOCAL_PLUGINS:-${JSC_HOME:-$HOME/.jsc}/plugins}/hooks/hooks/restart-gate.sh" \
"${JSC_KIRO_SKILLS:-$HOME/.kiro/skills}/jsc-hooks/hooks/restart-gate.sh" \
"$_root/../hooks/hooks/restart-gate.sh" \
"$_root/../jsc-hooks/hooks/restart-gate.sh"
do
[ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; }
done
_c=$(ls -d \
"$HOME"/.claude/plugins/cache/*/jsc-hooks/*/hooks/restart-gate.sh \
"$HOME"/.codex/plugins/cache/*/jsc-hooks/*/hooks/restart-gate.sh \
"$HOME"/.kiro/skills/jsc-hooks/hooks/restart-gate.sh \
2>/dev/null | sort | tail -n1)
[ -n "$_c" ] && [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; }
return 1
}
# 相依版本不符只回報、不跳過。跳過的話,落後的 domain 永遠等不到那一版相依,
# 也就永遠更新不到,兩個 domain 互相等就形成死鎖。阻擋改放在技能呼叫那一層:
# jsc-hooks 的 version-guard.sh 會在版本不足時擋下該 domain 的技能,更新照跑不會壞事。
# 檢查腳本自己出錯(退出碼 2 以上:用法錯誤或腳本壞掉)是另一回事,那不是相依不符,
# 讀不到結論就不能當成通過,照舊記 FAILED 並跳過這個 domain。
check_requires() { # $1=domain;0=繼續更新,1=略過這個 domain(只有檢查腳本自己出錯才會回 1)
[ "$MODE" = update ] || return 0
sync_local "$1"
manifest="$LOCAL_DIR/$1/plugin.json"
out=$(sh "$HERE/check-requires.sh" "$CLI" "$manifest" 2>&1)
code=$?
printf 'requires\t%s\t%s\n' "$1" "$out"
if [ "$code" -eq 0 ]; then
return 0
fi
if [ "$code" -eq 1 ]; then
printf 'warn\t%s\t%s\n' "$1" "相依版本不符,這次照樣更新;補齊相依版本以前,叫用這個 domain 的技能會被 jsc-hooks 的 version-guard.sh 擋下。要補的版本:$out"
return 0
fi
# 判不出結論跟版本落後要講不同的話。把兩者混成同一句,環境壞掉會被說成版本落後,
# 操作者照著去補版本永遠補不到問題點。照樣更新的理由與 version-guard.sh 一致:
# 沒有證據不等於落後,缺基礎設施就停掉更新,等於讓環境永遠修不好。
if [ "$code" -eq 4 ]; then
printf 'note\t%s\t%s\n' "$1" "判不出相依版本,這次照樣更新;成因不是版本落後,先修環境再重跑檢查:$out"
return 0
fi
FAILED=1
printf 'skip\t%s\t%s\n' "$1" "相依版本檢查失敗,未更新:$out"
return 1
}
# 掛上部署後的重啟閘門。狀態檔的路徑、格式與判讀全在 jsc-hooks 的 restart-gate.sh,
# 這裡只轉呼叫它的 require 子命令,比照 jsc-sdlc 轉呼叫 sdlc-gate.sh wp-lock 的慣例。
# 兩邊各拼一份格式就會對不上:2026-08-27 這裡曾自己寫四欄 TSV,而 hooks 那端讀的是
# key=value,狀態檔存在卻解不出欄位。格式只能有一個真實來源。
# 找不到 restart-gate.sh 時不要自己補寫一份:閘門本來就由 jsc-hooks 判讀,它不在就沒有
# 判定點,寫下去只是留一個沒人讀的檔案,還會讓下一輪誤以為閘門掛上了。
mark_restart() {
[ "$DRYRUN" = 1 ] && return 0
case "$MODE" in install|update) ;; *) return 0 ;; esac
if ! rg=$(restart_gate_sh); then
printf 'note\t%s\t%s\n' "$CLI" "找不到 jsc-hooks 的 restart-gate.sh,這次沒有掛上重啟閘門"
return 0
fi
if JSC_CLI="$CLI" sh "$rg" require "$MODE" $DOMAINS </dev/null; then
printf 'restart\t%s\n' "${JSC_HOME:-$HOME/.jsc}/restart-required.d/$CLI"
else
printf 'note\t%s\t%s\n' "$CLI" "restart-gate.sh require 失敗,這次沒有掛上重啟閘門"
fi
}
# 執行一個指令,並印出指令本身與結束碼。失敗就記進 FAILED。
run() { # $@=指令
printf 'cmd\t%s\n' "$*"
if [ "$DRYRUN" = 1 ]; then
printf 'exit\t-\t%s\n' "$*"
return 0
fi
"$@"
code=$?
printf 'exit\t%s\t%s\n' "$code" "$*"
[ "$code" -eq 0 ] || FAILED=1
return "$code"
}
# 同 run,但失敗不記進 FAILED——留給「失敗還有退路」的指令用。
run_soft() { # $@=指令
printf 'cmd\t%s\n' "$*"
if [ "$DRYRUN" = 1 ]; then
printf 'exit\t-\t%s\n' "$*"
return 0
fi
"$@"
code=$?
printf 'exit\t%s\t%s\n' "$code" "$*"
return "$code"
}
# 這份 clone 是不是「開發中的樹」。有原因就印出原因,沒有就不印。
# 判斷兩件事:有未提交變更,或有還沒推上去的 commit。兩者被 pull 蓋掉都救不回來。
local_hold() { # $1=存取庫路徑
if [ -n "$(git -C "$1" status --porcelain 2>/dev/null)" ]; then
printf '有未提交變更'
return 0
fi
up=$(git -C "$1" rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null) || return 0
[ -n "$up" ] || return 0
ahead=$(git -C "$1" rev-list --count "$up..HEAD" 2>/dev/null) || return 0
[ "${ahead:-0}" -eq 0 ] || printf '有 %s 個未推送的 commit' "$ahead"
}
# 取一個 domain 的更新鎖。
#
# 這一份本機複本一台機器只有一份,而五支 CLI 的部署是平行跑的——平行是對的,它們寫的是
# 不同的外掛目錄。但這裡不是:五支都會來 pull 同一個目錄,而 git 對同一個存取庫的併發寫入
# 沒有保護。
# 2026-09-07 實測踩到:同一輪裡兩支 CLI 撞在一起,一支拿不到 ORIG_HEAD.lock、一支的遠端
# refs 換不上去,兩支的整輪判定都變成 fail——而外掛其實全部裝好了。那是最難查的一種失效:
# 報告說失敗,實際成功,而真正的原因跟部署無關。
# 一個 domain 一把鎖,用 mkdir 取:那是檔案系統這一層唯一原子的建立動作,兩支同時 mkdir
# 只有一支成功。等不到就改用磁碟上的內容——別人正在拉同一份,硬等下去只是排隊。
clone_lock() { # $1=鎖目錄 $2=最多試幾次(每次之間睡一秒,所以約等於秒數);0=拿到了
_try=0
while :; do
mkdir "$1" 2>/dev/null && return 0
_try=$((_try + 1))
[ "$_try" -ge "$2" ] && break
sleep 1
done
# 等不到還要分一種情形:上一輪中途死掉會留下一個沒有人放的鎖,那種鎖永遠等不到。
# 判準取鎖的年紀,門檻放寬到等待秒數的四倍——比任何一次正常的 pull 都久。
if [ -d "$1" ]; then
_age=$(( $(date +%s) - $(date -r "$1" +%s 2>/dev/null || date +%s) ))
if [ "$_age" -gt $(( $2 * 4 )) ]; then
rmdir "$1" 2>/dev/null || true
mkdir "$1" 2>/dev/null && return 0
fi
fi
return 1
}
# 截一段訊息到指定長度。cut -c 數的是位元組不是字元,一個多位元組字剛好被切成兩半時
# 尾巴會留一個替代字元,而亂碼不影響結束碼、沒有人會來報。截完再過一次 iconv -c 丟掉
# 那個不完整的序列;iconv 不在這台機器上就退回原樣。
clip1() { # $1=位元組上限;讀標準輸入
_raw=$(tr '\n' ' ' | cut -c"1-$1")
_cln=$(printf '%s' "$_raw" | iconv -c -f UTF-8 -t UTF-8 2>/dev/null)
if [ -n "$_cln" ]; then printf '%s' "$_cln"; else printf '%s' "$_raw"; fi
}
# 把某 domain 的存取庫抓到本地:有 .git 就 pull,沒有就 clone。
# 目標是開發中的樹時只印 skip,改用現地內容安裝,不 pull:這支腳本可以被指到任何
# 目錄,蓋掉維護者未提交或未推送的工作救不回來,安裝一份舊內容還能重跑。
sync_local() { # $1=domain
dir="$LOCAL_DIR/$1"
lock="$LOCAL_DIR/.lock-$1"
if [ -d "$dir/.git" ]; then
hold=$(local_hold "$dir")
if [ -n "$hold" ]; then
printf 'skip\t%s\t%s %s,未執行 git pull\n' "$1" "$dir" "$hold"
return 0
fi
# 乾跑一步都不能動,連鎖都不取:取鎖是建目錄,那已經是寫入了。
if [ "$DRYRUN" = 1 ]; then
printf 'cmd\t%s\n' "git -C $dir pull"
printf 'exit\t-\t%s\n' "git -C $dir pull"
return 0
fi
if ! clone_lock "$lock" 30; then
printf 'warn\t%s\t%s\n' "$1" "$dir 的更新鎖等了 30 秒還拿不到,別的 CLI 正在拉同一份;這一輪改用磁碟上現有的內容,沒有重新拉取"
return 0
fi
printf 'cmd\t%s\n' "git -C $dir pull"
_out=$(git -C "$dir" pull 2>&1); _rc=$?
rmdir "$lock" 2>/dev/null || true
printf 'exit\t%s\t%s\n' "$_rc" "git -C $dir pull"
if [ "$_rc" -ne 0 ]; then
# 複本已經在磁碟上,拉不動不等於裝不了:安裝改用現有內容,那可能是舊版。
# 這裡刻意不記 FAILED。記了整輪會判成 fail,而 fail 那條路徑會連重啟閘門一起跳過
# ——外掛換了一半而沒有人被告知要重啟,比一句「拉不動」嚴重得多。
# 但一定要印出來:安靜地裝一份舊內容,是這一組工具最怕的那種失效。
printf 'warn\t%s\t%s\n' "$1" "git pull 回 $_rc,這一輪用磁碟上現有的內容安裝,可能不是最新版:$(printf '%s' "$_out" | clip1 160)"
fi
return 0
fi
if [ "$DRYRUN" = 1 ]; then
run git clone "$REPO_BASE/$1.git" "$dir"
return 0
fi
if ! clone_lock "$lock" 60; then
FAILED=1
printf 'warn\t%s\t%s\n' "$1" "$dir 還不存在,而 clone 鎖等了 60 秒拿不到,這個 domain 這一輪沒有本機複本可以裝"
return 1
fi
run git clone "$REPO_BASE/$1.git" "$dir"
_rc=$?
rmdir "$lock" 2>/dev/null || true
return "$_rc"
}
# claude、copilot、kiro-cli 共用的 plugin 指令組。
marketplace_cli() { # $1=執行檔
case "$MODE" in
install)
run "$1" plugin marketplace add "$MKT"
for d in $DOMAINS; do run "$1" plugin install "jsc-$d@jsc"; done
;;
update)
run "$1" plugin marketplace update jsc
for d in $DOMAINS; do check_requires "$d" && run "$1" plugin update "jsc-$d@jsc"; done
;;
uninstall)
for d in $DOMAINS; do run "$1" plugin uninstall "jsc-$d@jsc"; done
run "$1" plugin marketplace remove jsc
;;
esac
}
# codex 沒有 plugin update 子指令,只有 add、list、marketplace、remove。
# marketplace upgrade 只重抓 marketplace 快照,而 jsc 的 marketplace.json 只列各網域的
# git URL、不含版本,所以那份檔案內容不會變,codex 一律回「already up to date」並結束碼 0。
# 已安裝外掛的版本是 plugin add 當下決定的,快取不會被連帶重抓——換句話說,只跑
# marketplace upgrade 的話,指令全部成功而版本一個都沒動,是最難察覺的那種失敗。
# 正確做法是照樣逐網域 plugin add:codex 的 add 會就地升級到快照裡的最新版。
deploy_codex() {
bin=$(cli_bin codex)
case "$MODE" in
install)
run "$bin" plugin marketplace add "$MKT"
for d in $DOMAINS; do run "$bin" plugin add "jsc-$d@jsc"; done
;;
update)
_old_cli_versions=$(codex_plugin_versions jsc-cli)
_old_hooks_versions=$(codex_plugin_versions jsc-hooks)
run "$bin" plugin marketplace upgrade jsc
for d in $DOMAINS; do
check_requires "$d" && run "$bin" plugin add "jsc-$d@jsc"
if [ "$d" = cli ]; then
printf '%s\n' "$_old_cli_versions" | codex_preserve_old_plugin_cache jsc-cli tools/check-requires.sh
elif [ "$d" = hooks ]; then
printf '%s\n' "$_old_hooks_versions" | codex_preserve_old_plugin_cache jsc-hooks hooks/session-timer.sh
fi
done
;;
uninstall)
for d in $DOMAINS; do run "$bin" plugin remove "jsc-$d@jsc"; done
run "$bin" plugin marketplace remove jsc
;;
esac
}
deploy_antigravity() {
bin=$(cli_bin antigravity)
for d in $DOMAINS; do
case "$MODE" in
install)
sync_local "$d"
run "$bin" plugin install "$LOCAL_DIR/$d"
;;
update)
check_requires "$d" || continue
sync_local "$d"
run "$bin" plugin uninstall "jsc-$d"
run "$bin" plugin install "$LOCAL_DIR/$d"
;;
uninstall)
run "$bin" plugin uninstall "jsc-$d"
;;
esac
done
}
# plugin 指令不支援時的退路:從本地 clone 複製整個 plugin 的可用內容。
#
# 技能文件會直接引用同伴目錄,例如 jsc-sdlc 的 implement 要跑 jsc-sdlc/tools/wp-gate.sh
# 與 jsc-gitea/tools/pr-watch.sh,jsc-sdlc 的 analyze 要讀 references/consensus.md。
# 這些引用在 SKILL.md 裡是完成條件,不是選配。只複製 skills 目錄,kiro 會拿到一份
# 要求跑腳本、腳本卻不在機器上的技能,比不更新更糟——所以 tools、references、
# templates、hooks 與 plugin.json 一併複製。
#
# 目標路徑刻意維持 $KIRO_SKILLS/jsc-{domain}/,跨 plugin 的引用(jsc-gitea/tools/…)
# 以 $KIRO_SKILLS 為根就解析得到。
#
# 每個子目錄都用「先 mkdir,再複製 src/. 到 dst/」的寫法:目標目錄已存在時,
# cp -R src dst/ 會把來源塞進 dst/{名稱}/{名稱},第二次更新就多一層。
kiro_copy() { # $1=domain
sync_local "$1"
dest="$KIRO_SKILLS/jsc-$1"
run mkdir -p "$dest"
run cp -R "$LOCAL_DIR/$1/skills/." "$dest/"
for sub in tools references templates hooks; do
if [ -d "$LOCAL_DIR/$1/$sub" ]; then
run mkdir -p "$dest/$sub"
run cp -R "$LOCAL_DIR/$1/$sub/." "$dest/$sub/"
fi
done
# plugin.json 讓 version-guard.sh 之類的呼叫端查得到本機版本。缺了不算錯誤,
# 只是查不到版本而已,所以不進 run、也不影響整體結束碼。
[ -f "$LOCAL_DIR/$1/plugin.json" ] && run cp "$LOCAL_DIR/$1/plugin.json" "$dest/plugin.json"
return 0
}
# kiro-cli 是否認得 plugin 子指令:一次性偵測,探測本身不印 cmd/exit(不是部署動作,
# 印出來只會讓人誤以為那也是一次失敗的部署嘗試)。部分版本(例:2.18.1)完全沒有這個
# 子指令,逐一嘗試再退回複製,會先洗出一長串看似失敗、實則設計內的錯誤訊息。
kiro_has_plugin_cmd() {
"$bin" --help-all 2>/dev/null | grep -qE '^ plugin( |$)'
}
deploy_kiro() {
bin=$(cli_bin kiro)
if kiro_has_plugin_cmd; then
case "$MODE" in
install) run_soft "$bin" plugin marketplace add "$MKT" ;;
update) run_soft "$bin" plugin marketplace update jsc ;;
esac
for d in $DOMAINS; do
case "$MODE" in
install)
run_soft "$bin" plugin install "jsc-$d@jsc" || kiro_copy "$d"
;;
update)
check_requires "$d" || continue
run_soft "$bin" plugin update "jsc-$d@jsc" || kiro_copy "$d"
;;
uninstall)
run_soft "$bin" plugin uninstall "jsc-$d@jsc" || run rm -rf "$KIRO_SKILLS/jsc-$d"
;;
esac
done
return 0
fi
printf 'note\tkiro\t%s\n' "此版本 kiro-cli 沒有 plugin 子指令,改走本地複製(git pull 或 clone 後,複製 skills、tools、references、templates、hooks 與 plugin.json)"
for d in $DOMAINS; do
case "$MODE" in
install) kiro_copy "$d" ;;
update) check_requires "$d" && kiro_copy "$d" ;;
uninstall) run rm -rf "$KIRO_SKILLS/jsc-$d" ;;
esac
done
[ "$MODE" = uninstall ] && run_soft "$bin" plugin marketplace remove jsc
return 0
}
case "${1:-}" in
-n|--dry-run) DRYRUN=1; shift ;;
esac
[ $# -ge 3 ] || usage
MODE=$1
CLI=$2
shift 2
# 每個 domain 一律去掉開頭的 jsc-:往下每處都自己組 jsc-$d@jsc,收到已帶前綴的名字
# (marketplace.json 的 plugins[].name 就是這樣,例如 jsc-ask)會兜成 jsc-jsc-ask@jsc,
# 讓 claude/copilot/antigravity/kiro 全部裝不上、更新不了。這裡正規化一次,
# 呼叫端不管傳哪種格式都能正常動作,不必每個呼叫端各自記得先去前綴。
_domains=""
for _d in "$@"; do
case "$_d" in
jsc-*) _d="${_d#jsc-}" ;;
esac
_domains="$_domains $_d"
done
DOMAINS="${_domains# }"
case "$MODE" in
install|update|uninstall) ;;
*) usage ;;
esac
case "$CLI" in
claude) marketplace_cli "$(cli_bin claude)" ;;
copilot) marketplace_cli "$(cli_bin copilot)" ;;
codex) deploy_codex ;;
antigravity) deploy_antigravity ;;
kiro) deploy_kiro ;;
*) usage ;;
esac
refresh_links
# 重啟閘門一律先掛,不等整輪判定。
#
# 部分失敗代表磁碟上的外掛已經換了一部分,這時候更需要重啟,不是更不需要。
# 2026-09-07 實測踩到:一條與部署無關的 git pull 失敗把整輪判成 fail,而原本的寫法是
# 「判定成功才掛閘門」——於是那一支 CLI 沒有被掛上閘門,沒有人被告知要重啟,而它跑的
# 是舊版程式碼。一道只在成功時才生效的提醒,在最需要它的那一次不會出現。
mark_restart
if [ "$FAILED" -eq 0 ]; then
printf 'result\t%s\t%s\t%s\tok\n' "$CLI" "$MODE" "$DOMAINS"
exit 0
fi
printf 'result\t%s\t%s\t%s\tfail\n' "$CLI" "$MODE" "$DOMAINS"
exit 1