Files
cli/tools/deploy.sh
T

421 lines
17 KiB
Bash
Executable File
Raw 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
# note<TAB>{cli}<TAB>{原因} 非逐指令的說明(例:kiro 整批改走複製退路的理由)
# restart<TAB>{路徑} 這次寫下的重啟狀態檔
# requires<TAB>{domain}<TAB>{檢查結果} update 前的 jsc.requires 檢查
# result<TAB>{cli}<TAB>{mode}<TAB>{domain 清單}<TAB>{ok|fail}
# 結束碼:全部指令成功 0;任一指令失敗 1;參數錯誤 2。skip、note 不算失敗,但呼叫端要據實回報。
# 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}"
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
}
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
}
check_requires() { # $1=domain;0=可更新,1=略過這個 domain
[ "$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 'skip\t%s\t%s\n' "$1" "相依版本不符,未更新:$out"
return 1
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
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