feat(doctor): 新增執行環境體檢技能

What: 新增 jsc-cli:doctor,一次體檢技能版本、Hook 接線、全域設定與自我設定,只讀不改,結果寫進 wiki CHECK_{HASH}。
Why: 安裝或更新技能組之後,沒有任何工具說得出這台機器還缺什麼。設定散在環境變數、rc 檔與專案目錄,出錯時只能一個一個猜。
How: 版本比對複用 jsc-hooks 的 version-guard.sh report,接線狀態複用新加的 wire-cli.sh status(唯讀),設定則由 tools/scan-config.sh 比對 tools/config-spec.tsv 判定必要或選擇。規格表是必要性的唯一判準,掃描只負責抓出漏登錄的變數。
Who: 體檢與修復流程,搭配 jsc-cli:setup 收尾。
This commit is contained in:
2026-08-26 10:43:37 +08:00
parent 08ad10353b
commit 2adf9a172e
5 changed files with 424 additions and 0 deletions
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env sh
# scan-config.sh — 依 config-spec.tsv 盤點 jsc 技能組的設定現況。唯讀,不寫任何設定。
# 用法:
# scan-config.sh spec [global|project|all] # 印規格表(去掉註解與 internal 列)
# scan-config.sh scan [global|project|all] # 逐項檢查現況,印 TSV
# scan-config.sh orphans # 掃原始碼,找出沒登錄進規格表的變數
# 選項:
# -o 離線模式:需要連 Gitea 的檢查一律標 skipped,不發送請求
#
# scan 的輸出(TSV,每行一項):
# item<TAB>scope<TAB>required<TAB>actual<TAB>expect<TAB>fix<TAB>verdict
# verdict = ok 設定妥當
# default 未設定,走預設值,可以正常運作
# unset 選擇性項目未設定,沒有預設值,相關功能會降級
# missing 必要項目缺了,相關技能跑不動
# invalid 有值但驗不過(目錄不在、認證失敗、格式不對)
# skipped 離線模式略過,未取得結論
# 最後固定一行 summary<TAB>{missing 數}<TAB>{invalid 數}<TAB>{unset 數}<TAB>{skipped 數}
#
# 帶 TOKEN 的項目一律只印 set 或 unset,不印值:體檢報告會寫進 wiki,憑證不能跟著上去。
#
# 結束碼: 0=掃描完成(有沒有問題都算完成,判斷交給呼叫端) 2=用法錯誤 3=找不到規格表
set -eu
HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
SPEC="${JSC_CONFIG_SPEC:-$HERE/config-spec.tsv}"
TAB=$(printf '\t')
OFFLINE=0
[ -f "$SPEC" ] || { echo "找不到規格表:$SPEC(可用 JSC_CONFIG_SPEC 指定)" >&2; exit 3; }
usage() {
echo "用法:scan-config.sh [-o] {spec|scan|orphans} [global|project|all]" >&2
exit 2
}
case "${1:-}" in
-o) OFFLINE=1; shift ;;
esac
cmd="${1:-}"
scope_want="${2:-all}"
case "$cmd" in spec|scan|orphans) ;; *) usage ;; esac
case "$scope_want" in global|project|all) ;; *) usage ;; esac
# 找出 jsc-gitea 的 tools/gitea.sh。所有 Gitea 操作一律經由它(技能準則),不自行拼 API 呼叫。
# 找不到就回傳 1,呼叫端把需要連線的檢查標成 skipped,不讓整份體檢失敗。
gitea_sh() {
if [ -n "${JSC_GITEA_TOOLS:-}" ] && [ -f "$JSC_GITEA_TOOLS/gitea.sh" ]; then
printf '%s\n' "$JSC_GITEA_TOOLS/gitea.sh"; return 0
fi
_root="${CLAUDE_PLUGIN_ROOT:-$HERE/..}"
for _c in "$_root/../gitea/tools/gitea.sh" "$_root/../jsc-gitea/tools/gitea.sh"; do
[ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; }
done
_c=$(ls -d "$_root"/../../jsc-gitea/*/tools/gitea.sh \
"$_root"/../../gitea/*/tools/gitea.sh \
"$HOME"/.claude/plugins/cache/*/jsc-gitea/*/tools/gitea.sh 2>/dev/null \
| sort | tail -n1)
[ -n "$_c" ] && [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; }
_c=$(command -v gitea.sh 2>/dev/null || true)
[ -n "$_c" ] && { printf '%s\n' "$_c"; return 0; }
return 1
}
# 規格表的資料列(去註解、去空行)
spec_rows() { grep -v '^#' "$SPEC" | grep -v '^[[:space:]]*$'; }
# 這一列要不要納入本次掃描。$1=kind $2=scope
# internal 列只為登錄而存在(讓 orphans 認得出它們不是漏網之魚),永遠不進體檢報告。
row_wanted() {
[ "$1" != internal ] || return 1
[ "$scope_want" = all ] || [ "$2" = "$scope_want" ]
}
if [ "$cmd" = spec ]; then
spec_rows | while IFS="$TAB" read -r key kind scope required def verify fix desc; do
row_wanted "$kind" "$scope" || continue
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$key" "$scope" "$required" "$def" "$verify" "$fix" "$desc"
done
exit 0
fi
if [ "$cmd" = orphans ]; then
# 規格表沒有的變數 = 有人加了設定卻忘了登錄。體檢照樣印出來,維護者才補得上。
root="${JSC_PLUGINS_ROOT:-$(CDPATH= cd -- "$HERE/../.." && pwd)}"
[ -d "$root" ] || { echo "找不到 plugins 根目錄:$root(可用 JSC_PLUGINS_ROOT 指定)" >&2; exit 3; }
known=$(spec_rows | cut -f1 | sed 's/^\$//' | sort -u)
# 前面加詞邊界,否則帶別種前綴的變數(例如 PERSONA_ 開頭那批)會被從中間切出一段誤報。
# 這行註解本身也不寫出完整變數字面:掃描連自己的原始碼一起掃,寫了就會掃到自己。
grep -rhoE '\b(JSC|GITEA)_[A-Z0-9_]+' \
--include='*.sh' --include='*.md' --include='*.json' \
--exclude-dir=.git --exclude-dir=.jsc-monorepo-archive "$root" 2>/dev/null \
| sort | uniq -c | sort -rn \
| while read -r count name; do
# 原始碼寫的是樣板字面(JSC_WIKI_REPO_{TYPE}),抓出來會多一條尾巴底線;
# 去掉再比對,否則每次體檢都會多報一個不存在的變數。
name=${name%_}
printf '%s\n' "$known" | grep -qx "$name" && continue
case "$name" in JSC_WIKI_REPO_TYPE) continue ;; esac
printf 'orphan\t%s\t%s\n' "$name" "$count"
done
exit 0
fi
# --- scan ---
n_missing=0; n_invalid=0; n_unset=0; n_skipped=0
GITEA=$(gitea_sh 2>/dev/null || true)
# 連線類檢查的共用前置:離線、或找不到 gitea.sh,都直接標 skipped。
online_ready() {
[ "$OFFLINE" = 0 ] || return 1
[ -n "$GITEA" ] || return 1
}
# 值展開:規格表的檔案類 key 會帶 $JSC_HOME 這種變數,照字面找檔案永遠找不到。
# 開頭的 ~ 要先換成 $HOME:雙引號裡的 ~ 不展開,留著會讓 ~/.jsc 這種預設值一律驗不過。
# set +u 是必要的:預設值裡的 $JSC_HOME 常常正是「還沒設定」的那一個,
# 展開它在 set -u 底下會直接中斷整份掃描,體檢就停在半路。
expand() {
_e="$1"
case "$_e" in "~/"*) _e="$HOME/${_e#\~/}" ;; esac
( set +u; eval "printf '%s' \"$_e\"" ) 2>/dev/null
}
emit() { # item scope required actual expect fix verdict
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" "$5" "$6" "$7"
case "$7" in
missing) n_missing=$((n_missing + 1)) ;;
invalid) n_invalid=$((n_invalid + 1)) ;;
unset) n_unset=$((n_unset + 1)) ;;
skipped) n_skipped=$((n_skipped + 1)) ;;
esac
}
# 對一個已取得的值跑 verify。印出 verdict(ok/invalid/skipped)。$1=verify $2=值
run_verify() {
case "$1" in
set|none) echo ok ;;
dir) [ -d "$2" ] && echo ok || echo invalid ;;
file) [ -f "$2" ] && echo ok || echo invalid ;;
gitea-api)
online_ready || { echo skipped; return; }
GITEA_HOST="$2" sh "$GITEA" api GET /version >/dev/null 2>&1 && echo ok || echo invalid ;;
gitea-auth)
online_ready || { echo skipped; return; }
sh "$GITEA" owners >/dev/null 2>&1 && echo ok || echo invalid ;;
wiki-repo)
case "$2" in
*/*)
online_ready || { echo ok; return; }
sh "$GITEA" default-branch "$2" >/dev/null 2>&1 && echo ok || echo invalid ;;
*) echo invalid ;;
esac ;;
*) echo ok ;;
esac
}
# 迴圈不可以放在管線右邊:那會變成子 shell,計數加不回來,summary 永遠是 0。
rows=$(mktemp) || { echo "無法建立暫存檔" >&2; exit 3; }
spec_rows > "$rows"
while IFS="$TAB" read -r key kind scope required def verify fix desc; do
[ -n "${key:-}" ] || continue
row_wanted "$kind" "$scope" || continue
if [ "$kind" = env ]; then
eval "val=\${$key:-}"
secret=0
case "$key" in *TOKEN*) secret=1 ;; esac
if [ -n "$val" ]; then
verdict=$(run_verify "$verify" "$val")
if [ "$secret" = 1 ]; then actual=set; else actual="$val"; fi
emit "$key" "$scope" "$required" "$actual" "$desc" "$fix" "$verdict"
continue
fi
# 未設定:有預設值就用預設值再驗一次,驗得過才算走得下去。
if [ "$def" != "-" ]; then
# 預設值寫成另一個變數名(JSC_WIKI_REPO_LOG 退回 JSC_WIKI_REPO)時,要跟去看那一個。
# 退路自己也空著卻回報「走預設值」,會讓體檢說得過去、實際上功能整個不能用。
case "$def" in
[A-Z]*)
if printf '%s' "$def" | grep -qx '[A-Z][A-Z0-9_]*'; then
eval "fallback=\${$def:-}"
if [ -z "$fallback" ]; then
emit "$key" "$scope" "$required" "未設定(退路 $def 也未設定)" "$desc" "$fix" unset
continue
fi
emit "$key" "$scope" "$required" "未設定(退回 $def=$fallback)" "$desc" "$fix" default
continue
fi ;;
esac
dval=$(expand "$def")
case "$verify" in
# 預設路徑還沒建立,算「尚未啟用」而不是「設錯了」:invalid 專指有值卻驗不過。
dir|file) if [ "$(run_verify "$verify" "$dval")" = ok ]; then verdict=default; else verdict=unset; fi ;;
*) verdict=default ;;
esac
emit "$key" "$scope" "$required" "未設定(預設 $def)" "$desc" "$fix" "$verdict"
continue
fi
[ "$required" = yes ] && verdict=missing || verdict=unset
emit "$key" "$scope" "$required" 未設定 "$desc" "$fix" "$verdict"
continue
fi
# kind=file:規格表的 key 本身就是路徑
path=$(expand "$key" 2>/dev/null || printf '%s' "$key")
if [ -e "$path" ]; then
emit "$key" "$scope" "$required" "$path" "$desc" "$fix" ok
elif [ "$required" = yes ]; then
emit "$key" "$scope" "$required" 不存在 "$desc" "$fix" missing
else
emit "$key" "$scope" "$required" 不存在 "$desc" "$fix" unset
fi
done < "$rows"
rm -f "$rows"
printf 'summary\t%s\t%s\t%s\t%s\n' "$n_missing" "$n_invalid" "$n_unset" "$n_skipped"
exit 0