$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,刷新之後那條連結會存在,改成講清楚它仍然靠不住,因為第一次建起它的正是這一輪。
591 lines
26 KiB
Bash
Executable File
591 lines
26 KiB
Bash
Executable File
#!/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>{原因} 相依版本不符,仍照樣更新的提醒
|
||
# 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、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 的存取庫抓到本地:有 .git 就 pull,沒有就 clone。
|
||
# 目標是開發中的樹時只印 skip,改用現地內容安裝,不 pull:這支腳本可以被指到任何
|
||
# 目錄,蓋掉維護者未提交或未推送的工作救不回來,安裝一份舊內容還能重跑。
|
||
sync_local() { # $1=domain
|
||
dir="$LOCAL_DIR/$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
|
||
run git -C "$dir" pull
|
||
else
|
||
run git clone "$REPO_BASE/$1.git" "$dir"
|
||
fi
|
||
}
|
||
|
||
# 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
|
||
|
||
if [ "$FAILED" -eq 0 ]; then
|
||
mark_restart
|
||
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
|