feat(setup): 新增引導與自動設定技能

What: 新增 jsc-cli:setup,讀 doctor 的待修清單逐項確認後修復,並新增 tools/apply-config.sh 負責實際寫入。
Why: 體檢找得出問題,修還是得靠人一個一個查文件。修法又分三種:算得出來的、要人給值的、只能手動的,混在一起講不清楚。
How: 依規格表的 fix 欄分流,auto 直接寫、ask 先用決策樹問到值、manual 印步驟。複合修復交回原主(deploy、hooks-install、models)。apply-config.sh 只動 rc 檔的 # jsc-config 標記段落,寫前備份到 $JSC_HOME/backup/config/,寫後重讀驗證,fish 自動改用 set -gx 語法。
Who: 體檢與修復流程,接在 jsc-cli:doctor 之後。
This commit is contained in:
2026-08-26 10:43:50 +08:00
parent 2adf9a172e
commit e655f9a963
6 changed files with 240 additions and 3 deletions
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env sh
# apply-config.sh — 把設定寫進 shell rc 檔或建立設定目錄(供 /jsc-cli:setup 呼叫)。
# 用法:
# apply-config.sh set {KEY} {VALUE} # 寫入或更新一個環境變數(export)
# apply-config.sh unset {KEY} # 從 jsc 段落移除一個環境變數
# apply-config.sh mkdir {PATH} # 建立目錄(值可帶 $VAR 與開頭的 ~)
# apply-config.sh show # 印出目前 jsc 段落的內容(每行 KEY<TAB>VALUE)
# apply-config.sh rcfiles # 印出這次會寫入的 rc 檔路徑
#
# 寫入位置:每個既有的 shell rc 檔(~/.bashrc、~/.zshrc、~/.config/fish/config.fish)
# 都寫一份,全部不存在時才建立 ~/.bashrc。內容一律收在標記段落之間:
# # jsc-config
# export KEY='值'
# # /jsc-config
# 段落整段重寫,重跑只取代不疊加。段落外的內容一律不動——rc 檔是使用者自己的檔案,
# jsc 只負責自己那一段。
#
# 動到任何檔案之前先原樣備份到 $JSC_HOME/backup/config/{yyyyMMdd_HHmmss}/,備份失敗就不寫入。
# fish 的語法與 POSIX shell 不同,寫進去的是 set -gx KEY 值。
#
# 結束碼: 0=成功 2=用法錯誤 4=備份或寫入失敗
set -eu
JSC_HOME="${JSC_HOME:-$HOME/.jsc}"
MARK_OPEN='# jsc-config'
MARK_SHUT='# /jsc-config'
TAB=$(printf '\t')
usage() {
echo "用法:apply-config.sh {set {KEY} {VALUE}|unset {KEY}|mkdir {PATH}|show|rcfiles}" >&2
exit 2
}
cmd="${1:-}"
case "$cmd" in set|unset|mkdir|show|rcfiles) ;; *) usage ;; esac
rc_files() {
found=""
for f in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.config/fish/config.fish"; do
[ -f "$f" ] && { printf '%s\n' "$f"; found=1; }
done
[ -n "$found" ] || printf '%s\n' "$HOME/.bashrc"
}
if [ "$cmd" = rcfiles ]; then rc_files; exit 0; fi
# 目前段落裡的設定,格式 KEY<TAB>VALUE。取第一個既有 rc 檔為準:寫入時每個檔案內容相同。
read_pairs() {
for f in $(rc_files); do
[ -f "$f" ] || continue
awk -v o="$MARK_OPEN" -v s="$MARK_SHUT" '
$0==o { inb=1; next }
$0==s { inb=0; next }
inb {
line=$0
sub(/^export /, "", line) # POSIX shell
sub(/^set -gx /, "", line) # fish
if (line ~ /^[A-Za-z_][A-Za-z0-9_]*=/) {
eq=index(line, "="); k=substr(line, 1, eq-1); v=substr(line, eq+1)
} else {
sp=index(line, " "); if (sp==0) next
k=substr(line, 1, sp-1); v=substr(line, sp+1)
}
gsub(/^'"'"'|'"'"'$/, "", v)
if (k != "") print k "\t" v
}
' "$f"
return 0
done
}
if [ "$cmd" = show ]; then read_pairs; exit 0; fi
if [ "$cmd" = mkdir ]; then
raw="${2:-}"; [ -n "$raw" ] || usage
case "$raw" in "~/"*) raw="$HOME/${raw#\~/}" ;; esac
path=$( set +u; eval "printf '%s' \"$raw\"" )
[ -n "$path" ] || { echo "路徑展開後是空的:${2:-}" >&2; exit 4; }
mkdir -p "$path" 2>/dev/null || { echo "無法建立目錄:$path" >&2; exit 4; }
printf 'created\t%s\n' "$path"
exit 0
fi
key="${2:-}"
[ -n "$key" ] || usage
case "$key" in
[A-Za-z_]*) ;;
*) echo "變數名不合法:$key" >&2; exit 2 ;;
esac
value="${3:-}"
if [ "$cmd" = set ] && [ -z "$value" ]; then echo "set 需要值" >&2; exit 2; fi
# 合併:既有設定 + 這次的異動,重寫整段。少了這一步,寫第二個變數會蓋掉第一個。
pairs=$(mktemp) || { echo "無法建立暫存檔" >&2; exit 4; }
read_pairs > "$pairs" 2>/dev/null || true
merged=$(mktemp) || { rm -f "$pairs"; echo "無法建立暫存檔" >&2; exit 4; }
while IFS="$TAB" read -r k v; do
[ -n "${k:-}" ] || continue
[ "$k" = "$key" ] && continue
printf '%s\t%s\n' "$k" "$v" >> "$merged"
done < "$pairs"
rm -f "$pairs"
if [ "$cmd" = set ]; then printf '%s\t%s\n' "$key" "$value" >> "$merged"; fi
# 備份:動到的每個檔案先原樣複製一份,備份不了就整個不寫。
stamp=$(date +%Y%m%d_%H%M%S)
backup_dir="$JSC_HOME/backup/config/$stamp"
mkdir -p "$backup_dir" 2>/dev/null || { rm -f "$merged"; echo "無法建立備份目錄:$backup_dir" >&2; exit 4; }
# 依 rc 檔語法組出段落內容。fish 用 set -gx,其餘用 export。
block_for() {
_f="$1"
while IFS="$TAB" read -r k v; do
[ -n "${k:-}" ] || continue
case "$_f" in
*config.fish) printf "set -gx %s '%s'\n" "$k" "$v" ;;
*) printf "export %s='%s'\n" "$k" "$v" ;;
esac
done < "$merged"
}
rc_list=$(mktemp) || { rm -f "$merged"; echo "無法建立暫存檔" >&2; exit 4; }
rc_files > "$rc_list"
rc=0
while IFS= read -r f; do
[ -n "$f" ] || continue
if [ -f "$f" ]; then
cp -p "$f" "$backup_dir/$(basename "$f")" 2>/dev/null \
|| { echo "無法備份 $f,沒有備份就不寫入" >&2; rc=4; continue; }
else
mkdir -p "$(dirname "$f")" 2>/dev/null || { echo "無法建立 $(dirname "$f")" >&2; rc=4; continue; }
touch "$f" 2>/dev/null || { echo "無法建立 $f" >&2; rc=4; continue; }
fi
content=$(block_for "$f")
block=$(printf '%s\n%s\n%s' "$MARK_OPEN" "$content" "$MARK_SHUT")
if grep -qF "$MARK_OPEN" "$f" 2>/dev/null; then
awk -v o="$MARK_OPEN" -v s="$MARK_SHUT" -v b="$block" '
$0==o { print b; skip=1; next }
$0==s { skip=0; next }
skip { next }
{ print }
' "$f" > "$f.jsc-tmp" 2>/dev/null || { rm -f "$f.jsc-tmp"; echo "無法改寫 $f" >&2; rc=4; continue; }
mv "$f.jsc-tmp" "$f" 2>/dev/null || { rm -f "$f.jsc-tmp"; echo "無法覆寫 $f" >&2; rc=4; continue; }
else
( printf '\n%s\n' "$block" >> "$f" ) 2>/dev/null || { echo "無法寫入 $f" >&2; rc=4; continue; }
fi
# 寫完重讀驗證:說寫好了卻沒寫進去,是最難查的失敗
if [ "$cmd" = set ]; then
grep -qF "$key" "$f" 2>/dev/null || { echo "$f 寫入後讀不到 $key" >&2; rc=4; continue; }
fi
printf 'wrote\t%s\n' "$f"
done < "$rc_list"
rm -f "$rc_list" "$merged"
printf 'backup\t%s\n' "$backup_dir"
[ "$rc" = 0 ] || exit 4
printf 'note\t%s\n' "新的設定要開新的 shell 或重新 source rc 檔才生效;本輪工作階段可先手動 export"
exit 0