Shell 日誌輸出(scripts/lib/log_shared.sh)¶
repo 內所有 shell 腳本共用的一套 console 輸出詞彙:info / success / warn / error / die / skip,加上區段標題與輕量的 pass/fail 計數器。
為什麼需要它¶
在這個檔案出現之前,13 個腳本各自手刻同一段色碼區塊與 helper 三件組 —— 而且分成四種互不相容的寫法:
| 寫法 | 出現在 |
|---|---|
echo -e "${BLUE}[INFO]${NC} $1" |
run_once_before_00_bootstrap、run_after_25_bat_theme… |
printf '%b\n' "${_C_BLU}[INFO]${_C_RST} $*" |
scripts/upgrade_tools.sh |
printf '%s\n' "${_C_BLU}[INFO]${_C_RST} $*" |
scripts/pre-commit-doctor.sh |
printf "${CYAN}[INFO]${RESET} %s\n" "$*" |
scripts/import_ssh_to_bw.sh |
'\033'(需要 %b)與 $'\033'(真正的 ESC 字元)混用;success 在兩個檔案印 [OK]、其餘印 [SUCCESS];只有三個檔案用 [[ -t 1 ]] 判斷是否上色,另外十個無條件上色;NO_COLOR 則完全沒人理會。
為什麼不用現成的 library¶
現成選項是存在的 —— bashlog、ShLog、lobash —— 而且 gum log 早就由 devtools role 裝在每一台機器上。但有兩個硬限制把它們全部排除:
run_once_before_00_bootstrap.sh.tmpl跑在什麼都還沒安裝之前。 它不能依賴gum,也不能依賴任何需要先curl下載的 library。- chezmoi 會把每個
run_*.sh.tmplrender 到暫存路徑再執行。 執行當下腳本沒有可靠的路徑回到 source tree,source無從解析。
與 scripts/lib/sudo_shared.sh 同樣的推理,也是同樣的解法。
如何引用¶
兩種機制,依「腳本執行時是否有穩定路徑回到 source tree」來選。
Inline —— chezmoi run-script¶
用 include 而非 includeTemplate —— 這個檔案是純 bash,沒有 {{ … }} token 需要再次 render。
改動 lib 會重新觸發所有 inline 它的 run_onchange_after_*
inline 進去的副本屬於消費端腳本的內容,改了就會改變其 hash。下一次 chezmoi apply 時,這代表每一台主機都要重跑完整 ansible、完整 brew bundle、yazi 外掛重新檢查等等。要改就一次改完。
Source —— scripts/*.sh¶
_REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=lib/log_shared.sh
# shellcheck disable=SC1091
source "$_REPO_ROOT/scripts/lib/log_shared.sh"
這些腳本只會從 repo checkout 執行,所以路徑是穩定的。
哪些不能用¶
任何會被部署到 $HOME 的檔案。scripts/** 列在 .chezmoiignore.tmpl,所以這個檔案永遠不會落到目標機器上 —— 執行期唯一存在的副本就是 inline 進去的那份,以及從 checkout source 的那份。dot_config/television/executable_azure-rotate-ip.sh 保留自己那兩行 die/warn 正是因為這個原因。
API¶
訊息行¶
全部接受多個參數,以單一空白接合。
| 呼叫 | 標籤 | 顏色 | 輸出串流 |
|---|---|---|---|
info "msg" |
[INFO] |
藍 | stdout |
success "msg" |
[SUCCESS] |
綠 | stdout |
warn "msg" |
[WARN] |
黃 | stdout |
error "msg" |
[ERROR] |
紅 | stderr(見 LOG_STREAM) |
skip "msg" |
[SKIP] |
灰 | stdout |
die "msg" |
[ERROR] |
紅 | stderr,然後 exit 1 |
die 固定 exit 1。有明確 exit code 契約的腳本 —— pre-commit-doctor.sh 用 0/1/2/3 —— 自己呼叫 error 再 exit N。
結構¶
| 呼叫 | 效果 |
|---|---|
step "Heading" |
空行 + 粗體標題 |
hr |
灰色分隔線 |
dim "msg" |
無標籤的灰字(提示、指令回顯) |
驗證模式¶
給一次性的「這東西到底有沒有生效」檢查腳本用:
step "Checking the deploy"
[[ -f /etc/foo.conf ]] && ok "config present" || bad "config missing"
[[ -x /usr/bin/foo ]] && ok "binary present" || bad "binary missing"
log_summary # "2 passed, 0 failed";有失敗則回傳 1
| 呼叫 | 效果 |
|---|---|
ok "msg" |
綠色 ✔,pass 計數 +1 |
bad "msg" |
紅色 ✘,fail 計數 +1 |
log_summary |
印出 N passed, M failed;M > 0 時回傳 1 |
log_fail_count |
印出目前的 fail 數 |
log_reset_counters |
兩個計數器歸零 |
這不是測試框架
要 commit 進 repo、可重複執行的測試請寫在 tests/unit/*.bats(just bats)—— 見 Testing。驗證模式是給那種用完即丟、但仍需要正確 exit code 的 post-apply 檢查腳本。
調色盤¶
_C_RED _C_GRN _C_YLW _C_BLU _C_CYN _C_MAG _C_DIM _C_BLD _C_RST,可直接使用。它們存的是真正的 ESC 字元,所以在 printf '%s'、printf '%b'、echo -e 底下都安全。全部在載入時初始化為空字串,因此就算從沒呼叫 log_init,set -u 的腳本也不會炸。
設定¶
這些要在 include/source 之前設定,或者設定完再手動重跑 log_init。
| 變數 | 預設 | 效果 |
|---|---|---|
LOG_PREFIX |
'' |
把 [INFO]/[WARN]/… 標籤換成單一固定標籤,例如 [raycast-sync]。顏色仍隨嚴重度變化。 |
LOG_STREAM |
split |
split → error/die/bad 走 stderr,其餘走 stdout。stdout → 全部走 stdout。 |
NO_COLOR |
未設 | 有設且非空 → 關閉顏色。 |
CLICOLOR_FORCE |
未設 | 非空且不是 0 → 即使輸出被導管也強制上色。 |
LOG_PREFIX 與 LOG_STREAM 是在呼叫當下讀取的,所以寫在 source 之後也有效。調色盤則在載入時計算 —— 之後才改 NO_COLOR 需要重跑 log_init。
兩個顏色變數都沒設時,只有在 stdout 是 TTY 且 TERM 不是 dumb 的情況下才上色。
CLICOLOR_FORCE 刻意壓過 NO_COLOR¶
本 repo 的 yazi piper 規則會設 CLICOLOR_FORCE 把顏色推進 pipe(見 yazi 預覽 裡的 glow 契約)。使用者環境中順帶存在的 NO_COLOR 不該把它擋掉。
為什麼每個 chezmoi run-script 都設 LOG_STREAM=stdout¶
scripts/fleet/apply.py::_classify_drift() 在 local-host 路徑上吃的是真正的 stderr 行,而且只要有一行它不認得,就判定「不是純粹的 drift」—— 該主機於是被回報成 failed 而非 drift。遷移前那 13 個腳本的警告全部印在 stdout,所以對這個 classifier 是隱形的。把它們改走 stderr 會讓原本無害的 drift 悄悄變成 failed。見 fleet-apply 的不變式。
同樣的理由,warn 即使在 split 模式下也留在 stdout —— 這符合那 13 個腳本原本的行為,也避免 just upgrade-all 2>/dev/null 把 upgrade_tools.sh 的 47 個警告全部吃掉。
實作迴避掉的陷阱¶
其中三個光看程式碼是看不出來的,都由 tests/unit/log_shared.bats 覆蓋。
set -e底下的(( x++ ))。 後置遞增的求值結果是舊值,所以第一次計數(0 → 1)會回傳 exit status 1,直接殺掉呼叫端。lib 全程使用x=$(( x + 1 ))。- 不能有 top-level
return。 這個檔案在九個消費端是被 inline 的、不是被 source 的。用return 0做重複載入防護會讓那些腳本以return: can only 'return' from a function or sourced script中止。 - 每個輸出函式裡的
local IFS=' '。import_ssh_to_bw.sh會重設IFS來切分記錄;沒有這個保護的話,標籤與訊息之間會被黏上一個 ASCII unit separator。 set -u安全性。 調色盤變數與計數器在檔案層級就初始化,不是只在log_init裡。
維護¶
scripts/lib/*.sh有納入 pre-commit 的 shellcheck hook(severity=warning)。但沒有納入 shfmt hook —— 加進去會把sudo_shared.sh整份 4-space 縮排重排。scripts/lib/裡的新程式碼請自行維持 shfmt 相容(shfmt -i 2 -ci -bn)。- 新增消費端時,要同時加進
tests/unit/log_shared.bats的 wiring-guard 測試,以及CLAUDE.md的scripts/lib/log_shared.sh那一列。只要有消費端掉了 include/source 行、或又長回自己的 helper 副本,該測試就會明確失敗。