上一版把整條 PATH 快照進條目,實測直接裝不起來:crontab 一行有長度上限, 回的是 command too long,結束碼 4。這台機器的 PATH 是 2160 字元,去重又去掉 不存在的那幾段之後還有 1332 字元,仍然超過。 改成只帶放得下 CLI 執行檔的那幾個目錄,這台機器實測兩個、合起來 70 字元, 整行降到 685 字元。夠用的理由是這一條要解決的問題很窄:cron 只給 /usr/bin 與 /bin,而偵測 CLI 那一支靠 PATH 找執行檔,找不到就回結束碼 0 加一份空清單 ——看起來像「這台機器沒裝 CLI」。巡檢要跑的指令本身走字面絕對路徑,不靠它。 收尾接上 cron 本來就會給的那兩段,免得只剩自訂目錄而找不到系統工具。 一個代價寫進註解:有些 CLI 裝在會過期的目錄底下,例如帶行程編號的多殼層 目錄,那種路徑下一次開機就不在了。不特別過濾——過濾要認得每一種安裝方式, 那份名單會過期得比路徑還快。重跑一次 install 就重新拍一份,而條目本來就是 安裝當下的快照。 實測:拿條目會帶的那條 PATH 在清空環境裡跑,五支 CLI 全解得出來;真的裝一次 回結束碼 0,服務在跑,連結檢查通過。 三份 manifest 版號 0.2.5 升到 0.2.6。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
820 lines
43 KiB
Bash
Executable File
820 lines
43 KiB
Bash
Executable File
#!/usr/bin/env sh
|
||
# schedule.sh — 助理系統排程的安裝、移除與查現況(供 jsc-assist:assistant 呼叫)。
|
||
#
|
||
# 用法:
|
||
# schedule.sh install [patrol|all] [--dry-run] [--cli {代號}] [--patrol-cmd {指令}] [--period {分鐘}]
|
||
# schedule.sh remove [heartbeat|patrol|all] [--dry-run]
|
||
# schedule.sh status [--dry-run]
|
||
#
|
||
# 工作代號省略時一律是 patrol。這一版只裝巡檢那一筆,心跳由巡檢跑完那一輪自己寫。
|
||
#
|
||
# 結束碼:
|
||
# 0 成功:install 條目寫進去也回讀得到、排程服務在跑;remove 移除完成,或本來就沒裝;
|
||
# status 印完現況(有沒有裝都算成功,看 installed 欄)
|
||
# 1 install 寫進去了,但排程服務沒在跑——條目不會被執行。WSL 預設不啟動 cron,這一碼
|
||
# 多半就是它。呼叫端一律照實講「排程裝了但不會執行」,不可以宣稱會定時執行
|
||
# 2 找不到 jsc-hooks 的 hooks/heartbeat.sh。心跳門檻查不到,週期算不出來,整支停下
|
||
# 3 這台機器沒有可用的排程機制:認不得作業系統,或 crontab 與 schtasks 都找不到
|
||
# 4 排程操作失敗:讀不到現有排程(且失敗原因不是「沒有排程」)、寫入或刪除回非 0
|
||
# 5 回讀驗證失敗:寫入回 0 但條目不在,或移除回 0 但條目還在,又或其他人的條目數量對不上
|
||
# 6 用法錯誤:不認得的子命令、不認得的工作代號、缺參數、判不出要用哪一支 CLI 跑巡檢、
|
||
# --period 給的週期塞不進心跳的過期門檻,或 JSC_HOME 解不出絕對路徑(條目裡的根目錄
|
||
# 只要不是絕對路徑,那一輪就叫不到任何工具)
|
||
#
|
||
# --- 排程只叫巡檢,心跳由巡檢寫 ---
|
||
#
|
||
# 舊版排程每分鐘直接呼叫 heartbeat.sh write。那樣心跳新鮮只證明 cron 活著:巡檢整個壞掉、
|
||
# 一輪都沒跑成,心跳照樣新鮮,靠心跳判定的閘門照樣放行,沒有任何訊號。
|
||
# 現在排程只叫巡檢,巡檢把結果寫上監控頁之後才寫那一次心跳。心跳新鮮於是等於「上一輪巡檢
|
||
# 真的做完了,而且結果記下來了」。
|
||
# 所以 `install heartbeat` 直接回 6,不給裝:裝了就是有第二個寫心跳的人,心跳的意思立刻回到
|
||
# 舊版。舊機器上留著的那一筆 heartbeat 條目,`install patrol` 會順手清掉,並在輸出印
|
||
# legacy_removed=1——不清的話它每分鐘照樣寫,這次改動等於白做。
|
||
# `remove` 與 `status` 仍然認得 heartbeat 這個代號,就是為了清掉與看得到那一筆舊條目。
|
||
#
|
||
# --- 巡檢週期怎麼定 ---
|
||
#
|
||
# 心跳的更新頻率現在等於巡檢週期,所以週期一定要塞得進心跳的過期門檻,不然心跳永遠是過期。
|
||
# 門檻不寫死,改讀 `heartbeat.sh report` 的 `ttl` 欄——那是這台機器實際生效的值。
|
||
# 週期取「漏掉一輪還算新鮮、漏掉兩輪就過期」:2 × 週期 × 60 < 門檻,再往下取一個能整除一小時
|
||
# 的分鐘數,排程間隔才規律。門檻預設 300 秒時算出來是每 2 分鐘一輪。
|
||
# 要拉長巡檢週期就先把門檻調大(`JSC_ASSISTANT_HEARTBEAT_TTL`,單位秒),週期會跟著變長:
|
||
# 門檻 1800 秒算出每 12 分鐘一輪。週期與門檻兩個數字綁在一起算,不會再各走各的。
|
||
#
|
||
# --- 只動自己那一筆 ---
|
||
#
|
||
# 每一筆條目行尾都帶固定標記「# jsc-assist:assistant {工作代號}」,安裝與移除都靠它比對。
|
||
# 安裝先用 `grep -vF` 濾掉自己這幾個工作的舊條目,再把新條目追加上去,整份寫回;**絕不**
|
||
# 把 crontab 當空的重寫。`crontab -l` 在沒有任何排程時會回非 0,錯誤訊息才分得出是「沒有
|
||
# 排程」還是「權限不足」——後者當成空的寫回去,會把使用者整份排程刪光,所以這裡讀不懂
|
||
# 錯誤訊息就直接回 4,不猜。
|
||
# 寫回之後還會數行數:其他人的條目一行都不能少,少了就回 5。
|
||
#
|
||
# --- 排程服務沒在跑,等於沒裝 ---
|
||
#
|
||
# 條目寫進去不代表會被執行。WSL 預設不啟動 cron,要 `sudo service cron start`,而且重開
|
||
# WSL 之後要再啟動一次。安裝完一律檢查服務在不在跑,沒跑就回 1 並講清楚,不可以只回報
|
||
# 「排程已建立」。
|
||
#
|
||
# --- 排程是非互動環境 ---
|
||
#
|
||
# 條目一律接 `</dev/null`,跑的東西讀不到標準輸入,卡不住。heartbeat.sh 本來就不讀 stdin,
|
||
# 這一條是給巡檢那一筆用的:助理跳出權限詢問就會整輪卡死,等於排程沒跑。
|
||
#
|
||
# --- 排程那一輪的環境跟你現在這個殼不一樣 ---
|
||
#
|
||
# cron 給的 PATH 很短,多半只有 /usr/bin 與 /bin,裝在 ~/.local/bin 的 CLI 一律 not found。
|
||
# cron 也不讀 .bashrc,所以殼裡設好的 GITEA_HOST、GITEA_TOKEN、JSC_WIKI_REPO_* 那一輪全是
|
||
# 空的,wiki 連不上,監控頁寫不成,巡檢就不寫心跳,整條排程空轉。
|
||
# 所以條目自己把環境帶齊,兩件事:
|
||
# 一、CLI 用 `command -v` 解成絕對路徑再寫進條目,解不到就回 6 停下,不猜路徑——猜錯只是
|
||
# 把「每輪 not found」換成「每輪跑錯東西」,一樣沒有人在現場看得到。
|
||
# 二、要用到的環境變數在 install 當下就地快照,以 `名稱='值'` 寫成條目的指令前綴。
|
||
# 條目裡不 source 任何設定檔:很多機器的 .bashrc 開頭就是「非互動就 return」,那一招在 cron
|
||
# 底下會安靜失效,失效的樣子跟沒設一模一樣,查起來要花一整輪。快照的代價是條目寫死了安裝
|
||
# 當下的值,環境變數改過就要重跑一次 install,條目才會跟著換。
|
||
# 條目裡帶著金鑰快照,crontab 檔案要保持只有本人讀得到;本腳本印出條目時一律把金鑰遮掉。
|
||
#
|
||
# --- 無人值守那一輪要自己帶寫入確認 ---
|
||
#
|
||
# jsc-gitea 的寫入確認只認 tty,而條目帶 `</dev/null`,排程那一輪永遠沒有 tty,寫 wiki 一定
|
||
# 被擋。頁寫不成就不寫心跳,於是排程裝著卻永遠空轉。所以條目自帶 JSC_GITEA_CONFIRM=yes:
|
||
# 無人值守的那一輪本來就沒有人可以按同意,擋下來也沒有人會看到。
|
||
#
|
||
# --- 根目錄從條目餵進去,不由那一輪自己解 ---
|
||
#
|
||
# 巡檢那一輪要用字面絕對路徑叫工具,所以它得先知道根目錄。那一輪自己解不出來:解路徑的
|
||
# 指令(`readlink -f "$JSC_HOME/current"`、`ls -d "$JSC_HOME/current"`)在無人值守的工作
|
||
# 階段實測一律被擋,連 `ls -d /root/.jsc/current` 這種字面唯讀指令沒有允許規則也照擋。
|
||
# 能寫成字面的話又不必解了。所以根目錄只能從外面餵進去。
|
||
# 本腳本是在機器上、由人叫起來的,解得到根目錄,也解得起。install 於是把解好的字面根目錄
|
||
# 寫進條目的提示文字,那一輪讀提示就拿得到,一個指令都不用跑。
|
||
# 提示裡的格式固定是「工具根目錄={字面絕對路徑}」:技能靠這一段取值,人也讀得懂。
|
||
# 寫進條目的一定是展開後的字面值,不是 $JSC_HOME:條目裡留變數,那一輪拿到的還是變數。
|
||
#
|
||
# --- 裝完要開哪些權限 ---
|
||
#
|
||
# 排程那一輪跑在沒有人的工作階段,跳出權限詢問就是卡住整輪,而且卡到鎖逾時才有下一輪。
|
||
# install 成功之後會把那一輪需要的 allow 規則印出來,一行一條。
|
||
# 實測歸納出兩條判準,印規則一律照它走:
|
||
# 一、無人值守時只有允許清單上的完整字面指令跑得動。沒有「預設安全的唯讀指令」這回事:
|
||
# `ls -d` 這種指令一樣要有自己的規則,不然照擋。
|
||
# 二、路徑中段的萬用字元不匹配。規則與指令都必須是完整字面,所以規則裡不寫版本號的
|
||
# 萬用字元,也不寫 $JSC_HOME 或 ~。
|
||
# 路徑一律走 $JSC_HOME/current/{外掛名},而且印出來的是展開後的字面值:那是一組不帶版本的
|
||
# 連結,指向該外掛在快取裡的最新版,由 deploy 維護。規則就是那組確切路徑,比對得準,外掛
|
||
# 升版也不用回頭改設定;改指到快取的實體路徑反而會因為帶版本號而每次升版都失效。
|
||
# 檔案寫入只認 Edit(...),Write(...) 規則沒有作用,所以不印 Write。
|
||
# 連結不在就先警告:那一輪會因為找不到工具而失敗。連結由 deploy 建,本腳本不代建——排程
|
||
# 腳本自己去補外掛的部署結構,等於兩個地方管同一件事,壞掉的時候查不出是誰建的。
|
||
#
|
||
# --- log 放哪裡 ---
|
||
#
|
||
# $JSC_HOME/assistant/schedule.log(JSC_HOME 未設定時為 ~/.jsc)。刻意放在專案外面:寫進
|
||
# 任何存取庫都會多出未追蹤檔,污染別人的變更盤點。
|
||
#
|
||
# 環境變數:
|
||
# JSC_HOME 助理狀態檔的根目錄,預設 ~/.jsc
|
||
# JSC_ASSIST_CRONTAB_CMD crontab 執行檔,預設 crontab。crontab 不在標準路徑,或要用
|
||
# 假的 crontab 驗濾除邏輯時才設
|
||
# JSC_ASSIST_PATROL_CMD 巡檢要跑的指令,優先於 --patrol-cmd 以外的所有推斷
|
||
# JSC_CLI 目前是哪一支 CLI,決定巡檢預設指令
|
||
# JSC_ASSISTANT_HEARTBEAT_TTL 心跳過期門檻,單位秒。巡檢週期由它算出來,也會快照進條目
|
||
# GITEA_HOST GITEA_TOKEN wiki 連線用。install 當下快照進條目
|
||
# JSC_WIKI_REPO wiki 存取庫預設值。install 當下快照進條目
|
||
# JSC_WIKI_REPO_{TYPE} 各頁型的 wiki 存取庫。已設定的全部快照進條目。名單是當下從
|
||
# 環境撈出來的,不寫死,所以新增頁型自動涵蓋,這支不必跟著改。
|
||
# 目錄頁那一支專用變數也在裡面
|
||
set -u
|
||
|
||
MARK_PREFIX='# jsc-assist:assistant'
|
||
JSC_HOME="${JSC_HOME:-$HOME/.jsc}"
|
||
STATE_DIR="$JSC_HOME/assistant"
|
||
CURRENT="$JSC_HOME/current"
|
||
LOG="$STATE_DIR/schedule.log"
|
||
CRONTAB_CMD="${JSC_ASSIST_CRONTAB_CMD:-crontab}"
|
||
TASK_PREFIX='jsc-assist-assistant'
|
||
|
||
DRYRUN=0
|
||
CLI=''
|
||
PATROL_CMD="${JSC_ASSIST_PATROL_CMD:-}"
|
||
PERIOD=''
|
||
|
||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd)
|
||
SCRIPT_DIR="${SCRIPT_DIR:-.}"
|
||
|
||
# 這支腳本是不是從 $JSC_HOME/current 那一組路徑被叫起來的。不是就大聲警告,但照跑。
|
||
# 只警告、不中止是刻意的取捨:從工作樹直接跑腳本是開發時的正當用法,中止會把那條路擋掉;
|
||
# 真正的失敗會發生在權限閘門那裡——閘門只放行 current 那一組確切路徑,用別的路徑那一輪會
|
||
# 被靜靜擋掉、失敗,而且不會寫心跳,外面只看得到心跳過期。這裡先把話講在前面。
|
||
warn_if_not_current() {
|
||
_self="$SCRIPT_DIR/$(basename -- "$0")"
|
||
_want="$CURRENT/jsc-assist/tools/$(basename -- "$0")"
|
||
case "$SCRIPT_DIR/" in
|
||
"$CURRENT"/*) return 0 ;;
|
||
esac
|
||
printf '[jsc][助理排程][WARN]:這支腳本是從 %s 跑起來的,不是 %s。權限閘門只放行 current 那一組確切路徑:排程那一輪用別的路徑會被靜靜擋掉,那一輪失敗、不寫心跳,外面只看得到心跳過期。開發時這樣跑沒關係,無人值守那一輪一律走 current。\n' \
|
||
"$_self" "$_want" >&2
|
||
return 0
|
||
}
|
||
warn_if_not_current
|
||
|
||
usage() {
|
||
cat >&2 <<'EOF'
|
||
usage: schedule.sh install [patrol|all] [--dry-run] [--cli 代號] [--patrol-cmd 指令] [--period 分鐘]
|
||
schedule.sh remove [heartbeat|patrol|all] [--dry-run]
|
||
schedule.sh status [--dry-run]
|
||
EOF
|
||
exit 6
|
||
}
|
||
|
||
die() { # $1=結束碼 $2=訊息
|
||
printf '[jsc][助理排程][ERR]:%s\n' "$2" >&2
|
||
exit "$1"
|
||
}
|
||
|
||
note() { printf '[jsc][助理排程]:%s\n' "$1" >&2; }
|
||
|
||
# 條目與允許規則共用的字面根目錄。兩邊共用同一個值是刻意的:規則放行哪一組路徑,那一輪就
|
||
# 只能用哪一組路徑叫工具,兩邊各算各的就會差開,而差開的那一輪是被靜靜擋掉,沒有訊號。
|
||
# 相對路徑一律先解成絕對。相對路徑寫進條目等於指向 cron 的工作目錄,那一輪叫不到任何工具。
|
||
ROOT="$CURRENT"
|
||
case "$ROOT" in
|
||
/*) ;;
|
||
*)
|
||
_r=$(CDPATH= cd -- "$ROOT" 2>/dev/null && pwd -L) || _r=''
|
||
[ -n "$_r" ] || die 6 "JSC_HOME 是相對路徑($JSC_HOME),$ROOT 也解不出絕對路徑。條目與允許規則都需要字面絕對路徑,請把 JSC_HOME 設成絕對路徑再跑一次。"
|
||
ROOT="$_r" ;;
|
||
esac
|
||
# 條目提示文字裡的根目錄那一段。技能靠這一段取值,所以格式固定,不隨 CLI 變。
|
||
ROOT_TOKEN="工具根目錄=$ROOT"
|
||
|
||
# 找出 jsc-hooks 的 hooks/heartbeat.sh 絕對路徑。搜尋順序:先環境變數覆寫,再
|
||
# $JSC_HOME/current 那一組連結,然後開發用的並排存取庫版面,最後已安裝的 plugin 快取版面。
|
||
# current 排在快取前面是刻意的:技能與權限規則都以 current 為準,腳本內部再自己去挑另一個
|
||
# 版本,同一輪就會跑到混版的工具,而那種不一致查起來沒有任何線索。
|
||
heartbeat_sh() {
|
||
if [ -n "${JSC_HOOKS_HOOKS:-}" ] && [ -f "$JSC_HOOKS_HOOKS/heartbeat.sh" ]; then
|
||
printf '%s\n' "$JSC_HOOKS_HOOKS/heartbeat.sh"; return 0
|
||
fi
|
||
if [ -f "$CURRENT/jsc-hooks/hooks/heartbeat.sh" ]; then
|
||
printf '%s\n' "$CURRENT/jsc-hooks/hooks/heartbeat.sh"; return 0
|
||
fi
|
||
_root="${CLAUDE_PLUGIN_ROOT:-$SCRIPT_DIR/..}"
|
||
for _c in "$_root/../hooks/hooks/heartbeat.sh" "$_root/../jsc-hooks/hooks/heartbeat.sh"; do
|
||
[ -f "$_c" ] && { (CDPATH= cd -- "$(dirname -- "$_c")" && printf '%s/heartbeat.sh\n' "$(pwd)"); return 0; }
|
||
done
|
||
_c=$(ls -d "$_root"/../../jsc-hooks/*/hooks/heartbeat.sh \
|
||
"$_root"/../../hooks/*/hooks/heartbeat.sh \
|
||
"$HOME"/.claude/plugins/cache/*/jsc-hooks/*/hooks/heartbeat.sh 2>/dev/null \
|
||
| sort | tail -n1)
|
||
[ -n "$_c" ] && [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; }
|
||
_c=$(command -v heartbeat.sh 2>/dev/null || true)
|
||
[ -n "$_c" ] && { printf '%s\n' "$_c"; return 0; }
|
||
return 1
|
||
}
|
||
|
||
os_kind() {
|
||
case "$(uname -s 2>/dev/null)" in
|
||
Linux) printf 'linux' ;;
|
||
Darwin) printf 'darwin' ;;
|
||
MINGW*|MSYS*|CYGWIN*|Windows_NT) printf 'windows' ;;
|
||
*) printf 'unknown' ;;
|
||
esac
|
||
}
|
||
|
||
# 這台機器要用哪一種排程機制。Linux、WSL 與 macOS 都用 crontab;macOS 若要改用 launchd
|
||
# 請自行改寫,本腳本不代為產生 plist。
|
||
mechanism() {
|
||
case "$(os_kind)" in
|
||
windows) command -v schtasks >/dev/null 2>&1 && { printf 'schtasks'; return 0; } ;;
|
||
linux|darwin) command -v "$CRONTAB_CMD" >/dev/null 2>&1 && { printf 'crontab'; return 0; } ;;
|
||
esac
|
||
return 1
|
||
}
|
||
|
||
# 排程服務在不在跑。回 running、stopped 或 unknown。
|
||
service_state() {
|
||
case "$(os_kind)" in
|
||
darwin)
|
||
# macOS 的 cron 由 launchd 隨用隨起,查不到行程不代表沒在跑,一律回 unknown。
|
||
printf 'unknown'; return 0 ;;
|
||
windows)
|
||
printf 'unknown'; return 0 ;;
|
||
esac
|
||
if command -v pgrep >/dev/null 2>&1; then
|
||
if pgrep -x cron >/dev/null 2>&1 || pgrep -x crond >/dev/null 2>&1; then
|
||
printf 'running'
|
||
else
|
||
printf 'stopped'
|
||
fi
|
||
return 0
|
||
fi
|
||
if command -v ps >/dev/null 2>&1; then
|
||
if ps -e 2>/dev/null | grep -qE '[ /](cron|crond)$'; then printf 'running'; else printf 'stopped'; fi
|
||
return 0
|
||
fi
|
||
printf 'unknown'
|
||
}
|
||
|
||
marker_of() { printf '%s %s' "$MARK_PREFIX" "$1"; }
|
||
|
||
# 數行數。用 grep -c '' 不用 wc -l:最後一行沒有換行時 wc -l 會少數一行。
|
||
# grep 數到 0 會回非 0,數字照樣印得出來,所以只在完全沒有輸出時才補 0。
|
||
count_lines() { _n=$(grep -c '' "$1" 2>/dev/null); [ -n "$_n" ] || _n=0; printf '%s' "$_n"; }
|
||
|
||
# 數管線進來的行數,語意同 count_lines。
|
||
count_stdin() { _n=$(grep -c '' 2>/dev/null); [ -n "$_n" ] || _n=0; printf '%s' "$_n"; }
|
||
|
||
# 這台機器實際生效的心跳過期門檻。取自 heartbeat.sh report 的 ttl 欄,不自己重算:
|
||
# 門檻的唯一來源是那支腳本,兩邊各算一次就會漂移。讀不到就退回 300。
|
||
heartbeat_ttl() {
|
||
_t=$("$HEARTBEAT" report </dev/null 2>/dev/null | tr ' ' '\n' | sed -n 's/^ttl=//p' | head -n1)
|
||
case "$_t" in ''|*[!0-9]*) _t=300 ;; esac
|
||
[ "$_t" -gt 0 ] || _t=300
|
||
printf '%s' "$_t"
|
||
}
|
||
|
||
# 由門檻算出巡檢週期,單位分鐘。條件是 2 × 週期 × 60 < 門檻:漏掉一輪還算新鮮,漏掉兩輪
|
||
# 才過期。再往下取一個能整除一小時的分鐘數,`*/N` 的間隔才規律。
|
||
period_for_ttl() { # $1=門檻秒數
|
||
_max=$(( ($1 - 1) / 120 ))
|
||
[ "$_max" -lt 1 ] && _max=1
|
||
_p=1
|
||
for _d in 1 2 3 4 5 6 10 12 15 20 30 60; do
|
||
[ "$_d" -le "$_max" ] && _p="$_d"
|
||
done
|
||
printf '%s' "$_p"
|
||
}
|
||
|
||
spec_of() {
|
||
case "$1" in
|
||
heartbeat) printf '* * * * *' ;;
|
||
patrol) printf '*/%s * * * *' "$PERIOD" ;;
|
||
esac
|
||
}
|
||
|
||
# 巡檢要跑的指令。優先序:--patrol-cmd 或 JSC_ASSIST_PATROL_CMD > 依 CLI 代號推斷。
|
||
# 判不出 CLI,或那一支的執行檔不在 PATH 上,都回非 0 由主流程回 6,不猜。
|
||
# 執行檔一律用 `command -v` 解成絕對路徑:cron 的 PATH 只有 /usr/bin 與 /bin,裸的指令名
|
||
# 每一輪都是 not found,而那一輪不會有人看到錯誤訊息。
|
||
# 每一支 CLI 的提示文字都接上根目錄那一段:那一輪自己解不出根目錄,只能從提示裡拿。
|
||
patrol_command() {
|
||
[ -n "$PATROL_CMD" ] && { printf '%s' "$PATROL_CMD"; return 0; }
|
||
_cli="$CLI"
|
||
[ -n "$_cli" ] || _cli="${JSC_CLI:-}"
|
||
# 認得出 claude 的三個標記要跟 jsc-hooks 的 cli_name() 一模一樣。這裡本來只認第一個,
|
||
# 而工具腳本被叫用時那一個不會設,於是每一次不帶 --cli 的安裝都回用法錯誤——技能本文
|
||
# 的啟動步驟叫的正是不帶 --cli 那一種。同一個判斷寫兩份就是這樣漂走的:共用函式後來
|
||
# 補了兩個標記,這一份沒跟上,而漂移的代價是一個看起來像「這台機器沒裝 CLI」的錯誤。
|
||
# 共用函式加新標記時,這裡要一起加。
|
||
if [ -z "$_cli" ] && { [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] || [ -n "${CLAUDECODE:-}" ] \
|
||
|| [ -n "${CLAUDE_CODE_SESSION_ID:-}" ]; }; then
|
||
_cli=claude
|
||
fi
|
||
case "$_cli" in
|
||
claude) _bin='claude'; _args="-p \"/jsc-assist:assistant 跑一輪巡檢 $ROOT_TOKEN\"" ;;
|
||
codex) _bin='codex'; _args="exec '\$assistant 跑一輪巡檢 $ROOT_TOKEN'" ;;
|
||
copilot) _bin='copilot'; _args="-p \"跑一輪助理巡檢 $ROOT_TOKEN\"" ;;
|
||
antigravity) _bin='agy'; _args="-p \"/jsc-assist:assistant 跑一輪巡檢 $ROOT_TOKEN\"" ;;
|
||
kiro) _bin='kiro-cli'; _args="-p \"跑一輪助理巡檢 $ROOT_TOKEN\"" ;;
|
||
*) printf '判不出要用哪一支 CLI 跑巡檢,請帶 --cli {claude|codex|copilot|antigravity|kiro} 或 --patrol-cmd「指令」。\n' >&2
|
||
return 1 ;;
|
||
esac
|
||
_abs=$(command -v "$_bin" 2>/dev/null) || _abs=''
|
||
# 內建指令與別名也會被 command -v 認出來,但那些寫進 cron 沒有意義,只收絕對路徑。
|
||
case "$_abs" in
|
||
/*) ;;
|
||
*) printf '這台機器的 PATH 上找不到 %s 的執行檔,排程條目沒有絕對路徑可以寫。請先確認 %s 裝好了,或改用 --patrol-cmd 指定完整指令。\n' "$_cli" "$_bin" >&2
|
||
return 1 ;;
|
||
esac
|
||
printf "'%s' %s" "$_abs" "$_args"
|
||
}
|
||
|
||
# 要快照進條目的環境變數名稱。前四支是巡檢那一輪一定要用到的;JSC_WIKI_REPO 系列逐台機器
|
||
# 不同,直接從現在的環境撈出所有已設定的,不寫死清單。
|
||
# 不寫死是刻意的:頁型會增加,目錄頁後來也分了自己的存取庫變數,寫死清單就要跟著改一次,
|
||
# 而漏掉的那一個在排程那一輪是空值,wiki 寫不成、心跳不寫,外面只看得到心跳過期。
|
||
snapshot_names() {
|
||
# PATH 也要快照。cron 給的 PATH 只有 /usr/bin 與 /bin,而這台機器的 CLI 執行檔多半裝在
|
||
# 家目錄底下,所以那一輪偵測 CLI 會一支都找不到——而且是**回結束碼 0、印出空清單**,
|
||
# 不是失敗。看起來像「這台機器沒裝 CLI」,實際上是 PATH 上找不到。
|
||
# 2026-09-04 實測踩到:無人值守那一輪 clis=0,帶 {cli} 代入點的內建項全部代不出目標,
|
||
# 而同一支腳本在有人的 shell 裡回五支。
|
||
# 巡檢那一輪要跑的指令本身走的是字面絕對路徑、不靠 PATH;靠 PATH 的是那幾支腳本自己
|
||
# 呼叫的外部程式,所以修在條目這一層,不是逐支腳本各自去猜安裝路徑——猜就要維護一份
|
||
# 路徑清單,而清單會過期。
|
||
printf '%s\n' GITEA_HOST GITEA_TOKEN JSC_HOME JSC_ASSISTANT_HEARTBEAT_TTL
|
||
env 2>/dev/null \
|
||
| sed -n 's/^\(JSC_WIKI_REPO\)=.*/\1/p; s/^\(JSC_WIKI_REPO_[A-Za-z0-9_]*\)=.*/\1/p' \
|
||
| sort -u
|
||
}
|
||
|
||
# 把值包成單引號字串。值裡的單引號照 POSIX 的 '\'' 寫法拆開再接回來,不然一個引號就把
|
||
# 整條 cron 指令切斷。
|
||
shq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
|
||
|
||
# 條目要帶的 PATH。不是整條照抄——這台機器實測 2160 字元,去重又去掉不存在的那幾段之後
|
||
# 還有 1332 字元,而 crontab 一行有長度上限,照抄會直接回「command too long」而裝不起來。
|
||
# 實測踩過:加上整條 PATH 之後 install 回 4,錯誤訊息是 crontab 自己吐的。
|
||
#
|
||
# 只帶放得下 CLI 執行檔的那幾個目錄,這台機器實測是兩個、合起來 70 字元。這樣夠用的理由是
|
||
# 這一條要解決的問題很窄:cron 只給 /usr/bin 與 /bin,而偵測 CLI 那一支靠 PATH 找執行檔,
|
||
# 找不到就回結束碼 0 加一份空清單——看起來像「這台機器沒裝 CLI」。巡檢要跑的指令本身走的是
|
||
# 字面絕對路徑,不靠這一條。
|
||
#
|
||
# 一個代價要講白:有些 CLI 裝在會過期的目錄底下(例如帶行程編號的多殼層目錄),那種路徑
|
||
# 下一次開機就不在了。這裡不特別過濾——過濾要認得每一種安裝方式,那份名單會過期得比路徑
|
||
# 還快。重跑一次 install 就會重新拍一份,而條目本來就是安裝當下的快照。
|
||
cli_path() {
|
||
_dirs=''
|
||
for _c in claude codex copilot agy kiro-cli; do
|
||
_abs=$(command -v "$_c" 2>/dev/null) || continue
|
||
case "$_abs" in /*) ;; *) continue ;; esac
|
||
_d=$(dirname -- "$_abs")
|
||
case ":$_dirs:" in
|
||
*":$_d:"*) ;;
|
||
*) _dirs="${_dirs:+$_dirs:}$_d" ;;
|
||
esac
|
||
done
|
||
# 收尾接上 cron 本來就會給的那兩段,免得只剩自訂目錄而找不到系統工具。
|
||
printf '%s' "${_dirs:+$_dirs:}/usr/bin:/bin"
|
||
}
|
||
|
||
# 條目的指令前綴:固定三個旗標,加上這一刻的環境快照。沒設定的變數直接跳過,不寫空值——
|
||
# 寫 NAME='' 進去,讀的人分不出是「刻意設成空」還是「安裝時忘了設」。
|
||
# JSC_GITEA_CONFIRM=yes 是必要的:寫入確認只認 tty,排程那一輪沒有 tty,不帶這個旗標監控頁
|
||
# 一定寫不成,而頁寫不成就不寫心跳,排程等於永遠空轉。
|
||
# 刻意不寫死工作階段代號。寫死的話那一輪的兩半會落在不同的值上:start 由 hook 寫,它從 hook
|
||
# 的標準輸入 JSON 讀得到 CLI 真正的代號;end 由工具腳本寫,沒有那份 JSON,只能讀環境變數,
|
||
# 於是讀到寫死的那個字串。兩半的代號不同就永遠配不起來,每一輪都被算成一支中止的技能,
|
||
# 而那個數字只增不減,最後把真的中止蓋掉——那一節存在的理由正是「只有 start 沒有 end 是
|
||
# 中止的唯一證據」。不設這個變數,兩半就都落在 CLI 自己給的代號上。
|
||
# 2026-09-04 實測:一台機器累積了 91 筆假中止,其中 90 筆是同一支被每輪叫用的技能。
|
||
env_prefix() { # 順便把快照到的變數名稱寫進 $1,供輸出列出名稱(只有名稱,不含值)
|
||
_p="PATH=$(shq "$(cli_path)") JSC_CLI=cron JSC_GITEA_CONFIRM=yes"
|
||
_names=''
|
||
for _n in $(snapshot_names); do
|
||
eval "_v=\${$_n:-}"
|
||
[ -n "$_v" ] || continue
|
||
_p="$_p $_n=$(shq "$_v")"
|
||
if [ -z "$_names" ]; then _names="$_n"; else _names="$_names,$_n"; fi
|
||
done
|
||
[ -n "${1:-}" ] && printf '%s' "$_names" >"$1"
|
||
printf '%s' "$_p"
|
||
}
|
||
|
||
# 條目裡有金鑰快照,原樣印出來就是把金鑰留在對話紀錄與 log 裡,所以印之前先遮掉值。
|
||
# 遮到下一個空白為止,不遮到下一個單引號:值裡的單引號會跳脫成 '\'',遇到引號就收手會把
|
||
# 金鑰的後半段漏出來。Gitea 的金鑰不含空白,所以以空白為界是安全的。
|
||
mask_secret() { sed "s/GITEA_TOKEN=[^ ]*/GITEA_TOKEN='***'/g"; }
|
||
|
||
# 組出一筆 crontab 條目。`%` 在 crontab 是換行符號,一律跳脫。
|
||
cron_entry() { # $1=工作代號
|
||
_spec=$(spec_of "$1")
|
||
case "$1" in
|
||
heartbeat) _cmd="JSC_CLI=cron '$HEARTBEAT' write" ;;
|
||
patrol) _cmd="$ENV_PREFIX $PATROL_RESOLVED" ;;
|
||
esac
|
||
printf '%s %s </dev/null >>%s 2>&1 %s' \
|
||
"$_spec" "$_cmd" "'$LOG'" "$(marker_of "$1")" | sed 's/%/\\%/g'
|
||
}
|
||
|
||
task_name() { printf '%s-%s' "$TASK_PREFIX" "$1"; }
|
||
|
||
# 讀現有的 crontab 到 $1。沒有任何排程時 crontab -l 會回非 0,那算正常;讀不懂的錯誤
|
||
# 一律回 4,不當成空的——當成空的寫回去會把使用者整份排程刪光。
|
||
cron_read() { # $1=輸出檔
|
||
_err="$TMPD/err"
|
||
if "$CRONTAB_CMD" -l >"$1" 2>"$_err"; then return 0; fi
|
||
if [ ! -s "$_err" ] || grep -qiE 'no crontab|沒有 crontab' "$_err"; then
|
||
: >"$1"; return 0
|
||
fi
|
||
die 4 "讀不到現有排程,原因不是「沒有排程」:$(tr '\n' ' ' <"$_err")"
|
||
}
|
||
|
||
cron_lines_for() { # $1=crontab 檔 $2=工作代號;印出該工作的條目
|
||
grep -F "$(marker_of "$2")" "$1" 2>/dev/null || true
|
||
}
|
||
|
||
# --- 參數解析 ---
|
||
|
||
CMD="${1:-}"
|
||
[ -n "$CMD" ] || usage
|
||
shift
|
||
case "$CMD" in install|remove|status) ;; *) usage ;; esac
|
||
|
||
JOBS='patrol'
|
||
if [ "$#" -gt 0 ]; then
|
||
case "$1" in
|
||
heartbeat) JOBS='heartbeat'; shift ;;
|
||
patrol) JOBS='patrol'; shift ;;
|
||
all) JOBS='heartbeat patrol'; shift ;;
|
||
esac
|
||
fi
|
||
|
||
while [ "$#" -gt 0 ]; do
|
||
case "$1" in
|
||
--dry-run) DRYRUN=1; shift ;;
|
||
--cli) [ "$#" -ge 2 ] || usage; CLI="$2"; shift 2 ;;
|
||
--patrol-cmd) [ "$#" -ge 2 ] || usage; PATROL_CMD="$2"; shift 2 ;;
|
||
--period) [ "$#" -ge 2 ] || usage; PERIOD="$2"; shift 2 ;;
|
||
*) usage ;;
|
||
esac
|
||
done
|
||
|
||
# 心跳那一筆不給裝。理由見檔頭「排程只叫巡檢,心跳由巡檢寫」:多一個寫心跳的人,心跳的
|
||
# 意思立刻退回舊版。install all 只裝巡檢那一筆。
|
||
case "$CMD:$JOBS" in
|
||
install:heartbeat)
|
||
die 6 '心跳那一筆不裝了。心跳改由巡檢跑完那一輪自己寫,排程只叫巡檢:請跑 `schedule.sh install patrol`。舊機器上留著的 heartbeat 條目,install patrol 會順手清掉。' ;;
|
||
install:*heartbeat*) JOBS='patrol' ;;
|
||
esac
|
||
|
||
HEARTBEAT=$(heartbeat_sh) \
|
||
|| die 2 '找不到 jsc-hooks 的 hooks/heartbeat.sh,心跳的過期門檻查不到,巡檢週期算不出來。請先安裝 jsc-hooks 0.3.7 以上。'
|
||
MECH=$(mechanism) \
|
||
|| die 3 "這台機器沒有可用的排程機制(作業系統:$(os_kind),找不到 $CRONTAB_CMD 或 schtasks)。"
|
||
|
||
# 巡檢週期與心跳門檻綁在一起算。--period 給的值一樣要通過同一條式子,否則裝出來的排程
|
||
# 會讓心跳永遠過期,而且沒有人看得出來是週期設錯。
|
||
TTL=$(heartbeat_ttl)
|
||
if [ -n "$PERIOD" ]; then
|
||
case "$PERIOD" in ''|*[!0-9]*) usage ;; esac
|
||
[ "$PERIOD" -ge 1 ] || usage
|
||
[ $(( 2 * PERIOD * 60 )) -lt "$TTL" ] \
|
||
|| die 6 "巡檢週期 $PERIOD 分鐘塞不進心跳的過期門檻 $TTL 秒(要 2 × 週期 × 60 < 門檻)。心跳現在由巡檢寫,週期比門檻長的話心跳永遠是過期。請改短週期,或先把 JSC_ASSISTANT_HEARTBEAT_TTL 調大。"
|
||
else
|
||
PERIOD=$(period_for_ttl "$TTL")
|
||
fi
|
||
|
||
TMPD=$(mktemp -d) || die 4 '建不出暫存目錄。'
|
||
trap 'rm -rf "$TMPD"' EXIT
|
||
|
||
# 巡檢指令與環境快照在這裡就解出來。放進 cron_entry 再解的話,那支是在命令替換的子行程裡
|
||
# 跑,判不出 CLI 時 die 只結束子行程,主流程會帶著空指令繼續往下裝。
|
||
PATROL_RESOLVED=''
|
||
ENV_PREFIX="PATH=$(shq "$(cli_path)") JSC_CLI=cron JSC_GITEA_CONFIRM=yes"
|
||
SNAPSHOT_NAMES=''
|
||
case "$CMD:$JOBS" in
|
||
install:*patrol*)
|
||
if ! PATROL_RESOLVED=$(patrol_command 2>"$TMPD/cmd.err"); then
|
||
_why=$(tr '\n' ' ' <"$TMPD/cmd.err" 2>/dev/null)
|
||
[ -n "$_why" ] || _why='判不出要用哪一支 CLI 跑巡檢,請帶 --cli {claude|codex|copilot|antigravity|kiro} 或 --patrol-cmd「指令」。'
|
||
die 6 "$_why"
|
||
fi
|
||
ENV_PREFIX=$(env_prefix "$TMPD/snapnames")
|
||
SNAPSHOT_NAMES=$(cat "$TMPD/snapnames" 2>/dev/null)
|
||
# 提示文字裡沒有根目錄那一段,無人值守那一輪就拿不到根目錄,只能停下回報。本腳本自己
|
||
# 產生的指令一律帶著,所以會走到這裡的只有 --patrol-cmd 與 JSC_ASSIST_PATROL_CMD。
|
||
# 這裡只警告不中止:自訂指令有可能根本不是叫這支技能,中止會把那條路擋掉。
|
||
case "$PATROL_RESOLVED" in
|
||
*"$ROOT_TOKEN"*) ;;
|
||
*) note "自訂的巡檢指令裡沒有「$ROOT_TOKEN」。那一輪自己解不出根目錄,只能從提示文字拿,拿不到就會停下回報,什麼都不記。請把這一段原樣加進提示文字裡。" ;;
|
||
esac ;;
|
||
esac
|
||
|
||
# --- 裝完要開的權限 ---
|
||
|
||
# 排程那一輪跑在沒有人的工作階段:跳出一次權限詢問就是整輪卡住,卡到鎖逾時才有下一輪。
|
||
# 這一輪會用到的每一支腳本,各印裸路徑、`sh 路徑`、`bash 路徑` 三種呼叫形式——同一支腳本換
|
||
# 一種叫法就是另一條規則,少印一種就會在那一種叫法上卡住。
|
||
# 路徑一律是 $JSC_HOME/current/{外掛名} 那一組,不是這支腳本現在被放在哪裡:規則放行的是
|
||
# 那一組路徑,巡檢那一輪也只能用那一組路徑去叫工具,兩邊對得起來才不會被靜靜擋掉。
|
||
print_allow_rules() {
|
||
# gitea.sh 一定要有自己這一條。`Skill(jsc-gitea:wiki)` 只放行「叫用那支技能」,技能裡的
|
||
# 每一個 Bash 呼叫仍然各自受檢,少了這一條,那一輪會在寫監控頁時靜靜被擋——頁寫不成就
|
||
# 不寫心跳,外面只看得到心跳過期,看不出是權限擋的。
|
||
# 目錄頁那一個區塊改由 wiki-contents.sh 寫,所以它也要有自己這一條:巡檢那一輪會直接叫它,
|
||
# 少了規則就會停在權限詢問,而那一輪沒有人可以按同意。
|
||
# link-check.sh 同理:兩次寫入前都要先驗連結,少了這一條,驗證那一步就停在權限詢問,那一輪
|
||
# 什麼都寫不成。它排在寫入之前,所以擋住它等於整輪報廢。
|
||
# report-status.sh 是執行狀態事件那一支,這一輪會直接叫它兩次以上:排空與輪替由 patrol.sh
|
||
# 代跑(那是已放行指令的子行程,不會再問一次),但收尾那一筆 skill-end 是技能自己用 Bash 叫的,
|
||
# 那一次就要這一條規則。少了它,那一輪會停在最後一步的權限詢問,而排程那一輪沒有人可以按
|
||
# 同意:那一輪的收尾事件寫不出去,start 永遠配不到 end,下一輪就把一輪其實做完的巡檢報成中止。
|
||
# run-due.sh 是巡檢那一輪真的執行到期內建項的那一支,技能自己用 Bash 叫它。少了這一條,
|
||
# 那一輪會停在那一步的權限詢問,而鎖已經握在手上:下一輪跟著讓開,一直到鎖逾時為止。
|
||
# 它自己跑的那幾支盤點指令是它的子行程,不會再問一次,所以只要放行它自己這一條。
|
||
for _s in "$ROOT/jsc-assist/tools/schedule.sh" \
|
||
"$ROOT/jsc-assist/tools/patrol.sh" \
|
||
"$ROOT/jsc-assist/tools/run-due.sh" \
|
||
"$ROOT/jsc-hooks/hooks/heartbeat.sh" \
|
||
"$ROOT/jsc-hooks/tools/report-status.sh" \
|
||
"$ROOT/jsc-gitea/tools/gitea.sh" \
|
||
"$ROOT/jsc-gitea/tools/wiki-contents.sh" \
|
||
"$ROOT/jsc-gitea/tools/link-check.sh"; do
|
||
printf 'allow_rule=Bash(%s:*)\n' "$_s"
|
||
printf 'allow_rule=Bash(sh %s:*)\n' "$_s"
|
||
printf 'allow_rule=Bash(bash %s:*)\n' "$_s"
|
||
_printed="${_printed:-} ${_s#"$ROOT/"}"
|
||
done
|
||
# 檔案規則寫的是解出來的絕對路徑,不是 $JSC_HOME/assistant:設定檔不展開變數,寫變數名
|
||
# 等於這條規則永遠比對不到。
|
||
printf 'allow_rule=Read(%s/**)\n' "$STATE_DIR"
|
||
printf 'allow_rule=Edit(%s/**)\n' "$STATE_DIR"
|
||
printf 'allow_rule=Skill(jsc-gitea:wiki)\n'
|
||
|
||
# 上面那份清單是寫死的,而技能本文哪天多叫一支腳本,這裡不會自己知道。漏一支的代價不是
|
||
# 報錯,是那一輪停在權限詢問、鎖握在手上,接下來每一輪都讓開到鎖逾時為止——查起來只看得到
|
||
# 「心跳過期」。所以這裡回頭掃一次技能本文,把它叫的每一支腳本跟上面印過的比對。
|
||
# 只警告不中止:本文的寫法可能變,比對規則跟不上時不該連排程都裝不起來。
|
||
# 目錄名以 jsc-assist 為準,找不到才退回不帶前綴的 assist:安裝版面是前者,開發用的並排
|
||
# 工作目錄版面是後者。只認一種的話,在另一種版面底下這道檢核會被 [ -f ] 靜靜跳過——
|
||
# 一道「找不到就當通過」的檢核,跟沒有那道檢核是同一件事。
|
||
_skill=''
|
||
for _cand in "$ROOT/jsc-assist/skills/assistant/SKILL.md" \
|
||
"$ROOT/assist/skills/assistant/SKILL.md"; do
|
||
[ -f "$_cand" ] && { _skill="$_cand"; break; }
|
||
done
|
||
if [ -z "$_skill" ]; then
|
||
note "找不到技能本文,這一次沒有比對「本文叫得到的腳本有沒有全部放行」。找過 $ROOT/jsc-assist 與 $ROOT/assist 兩處。規則清單照樣印在上面,但少一支就是那一輪停在權限詢問,請自己核對一次。"
|
||
else
|
||
_missing=''
|
||
# 只掃 patrol 那一節。規則防的是無人值守那一輪,而 start 與 status 兩個操作都有人在現場,
|
||
# 它們叫的腳本跳一次權限詢問有人按得掉。整份掃的話那幾支會每次都被報成缺規則,而一份
|
||
# 每次都有雜訊的警告,看的人第二次就會跳過它——那時候真的漏一支就沒有人看得到了。
|
||
for _w in $(awk '/^## patrol$/{f=1;next} f && /^## /{exit} f' "$_skill" \
|
||
| sed -n 's|.*{CURRENT}/\(jsc-[a-z-]*/[a-z-]*/[a-z-]*\.sh\).*|\1|p' | sort -u); do
|
||
case " $_printed " in
|
||
*" $_w "*) ;;
|
||
*) _missing="${_missing:+$_missing、}$_w" ;;
|
||
esac
|
||
done
|
||
[ -z "$_missing" ] || note "技能本文叫得到、但上面的規則沒有涵蓋的腳本:$_missing。那一輪叫到它就會停在權限詢問,而鎖已經握在手上,接下來每一輪都會讓開到鎖逾時。請把這幾支補進這一支腳本的規則清單,再重跑一次 install。"
|
||
fi
|
||
}
|
||
|
||
# 巡檢那一輪只用 $JSC_HOME/current 那一組連結叫工具,連結不在就是那一輪一定失敗。這裡只查
|
||
# 與警告,不代建:連結是 deploy 的職責,兩個地方都在建同一組連結,壞掉時查不出是誰建的。
|
||
check_current_links() {
|
||
_miss=''; _paths=''
|
||
for _p in jsc-assist jsc-gitea; do
|
||
[ -e "$ROOT/$_p" ] && continue
|
||
if [ -z "$_miss" ]; then _miss="$_p"; _paths="$ROOT/$_p"
|
||
else _miss="$_miss,$_p"; _paths="$_paths、$ROOT/$_p"; fi
|
||
done
|
||
if [ -n "$_miss" ]; then
|
||
printf 'current_links=missing:%s\n' "$_miss"
|
||
note "找不到這幾個連結:$_paths。巡檢那一輪會照這一組路徑叫工具,連結不在就叫不到,那一輪一定失敗,也不會寫心跳。請先跑 deploy 把連結建起來——本腳本不代建。"
|
||
return 0
|
||
fi
|
||
printf 'current_links=ok\n'
|
||
return 0
|
||
}
|
||
|
||
# --- schtasks(Windows)---
|
||
|
||
schtasks_install() {
|
||
_rc=0
|
||
# 舊版的 heartbeat 任務一併刪掉,理由同 crontab 那一邊。刪不掉不算失敗,往下照裝。
|
||
_legacy=0
|
||
if schtasks /Query /TN "$(task_name heartbeat)" >/dev/null 2>&1; then
|
||
schtasks /Delete /TN "$(task_name heartbeat)" /F >/dev/null 2>&1 && _legacy=1
|
||
fi
|
||
for _job in $JOBS; do
|
||
_tn=$(task_name "$_job")
|
||
case "$_job" in
|
||
patrol) _mo="$PERIOD"; _run="$PATROL_RESOLVED" ;;
|
||
*) continue ;;
|
||
esac
|
||
_tr="cmd /c $_run <NUL >> \"$LOG\" 2>&1"
|
||
if [ "$DRYRUN" -eq 1 ]; then
|
||
printf 'dryrun=schtasks job=%s task=%s interval=%s cmd=%s\n' "$_job" "$_tn" "$_mo" "$_tr"
|
||
continue
|
||
fi
|
||
# /F 只覆蓋同名任務,也就是本腳本自己那一筆,不影響別人的排程。
|
||
schtasks /Create /TN "$_tn" /SC MINUTE /MO "$_mo" /F /TR "$_tr" >/dev/null 2>&1 \
|
||
|| die 4 "schtasks 建立 $_tn 失敗。"
|
||
schtasks /Query /TN "$_tn" >/dev/null 2>&1 || die 5 "schtasks 建立 $_tn 回 0,卻查不到這個任務。"
|
||
printf 'installed=%s task=%s\n' "$_job" "$_tn"
|
||
_rc=0
|
||
done
|
||
printf 'ttl=%s period=%s legacy_removed=%s patrol_root=%s log=%s\n' "$TTL" "$PERIOD" "$_legacy" "$ROOT" "$LOG"
|
||
return "$_rc"
|
||
}
|
||
|
||
schtasks_remove() {
|
||
for _job in $JOBS; do
|
||
_tn=$(task_name "$_job")
|
||
if [ "$DRYRUN" -eq 1 ]; then
|
||
printf 'dryrun=schtasks job=%s task=%s action=delete\n' "$_job" "$_tn"
|
||
continue
|
||
fi
|
||
if ! schtasks /Query /TN "$_tn" >/dev/null 2>&1; then
|
||
note "$_job 本來就沒有排程,不用移除。"
|
||
continue
|
||
fi
|
||
schtasks /Delete /TN "$_tn" /F >/dev/null 2>&1 || die 4 "schtasks 刪除 $_tn 失敗。"
|
||
schtasks /Query /TN "$_tn" >/dev/null 2>&1 && die 5 "schtasks 刪除 $_tn 回 0,任務卻還在。"
|
||
done
|
||
return 0
|
||
}
|
||
|
||
schtasks_status() {
|
||
printf 'mechanism=schtasks service=%s ttl=%s period=%s log=%s\n' \
|
||
"$(service_state)" "$TTL" "$PERIOD" "$LOG"
|
||
for _job in heartbeat patrol; do
|
||
_tn=$(task_name "$_job")
|
||
if schtasks /Query /TN "$_tn" >/dev/null 2>&1; then
|
||
printf 'job=%s installed=yes task=%s\n' "$_job" "$_tn"
|
||
else
|
||
printf 'job=%s installed=no task=%s\n' "$_job" "$_tn"
|
||
fi
|
||
done
|
||
return 0
|
||
}
|
||
|
||
# --- crontab(Linux、WSL、macOS)---
|
||
|
||
crontab_install() {
|
||
_cur="$TMPD/cur"; _new="$TMPD/new"
|
||
cron_read "$_cur"
|
||
|
||
cp "$_cur" "$_new"
|
||
# 先濾掉自己這幾個工作的舊條目,再追加新的。重跑不會疊成兩筆,別人的條目原樣留著。
|
||
# 舊版的 heartbeat 條目一併清掉:留著它每分鐘照樣寫心跳,心跳就退回「只證明 cron 活著」。
|
||
_legacy=$(cron_lines_for "$_new" heartbeat | count_stdin)
|
||
for _job in $JOBS heartbeat; do
|
||
grep -vF "$(marker_of "$_job")" "$_new" >"$TMPD/f" 2>/dev/null || true
|
||
mv "$TMPD/f" "$_new"
|
||
done
|
||
_others=$(count_lines "$_new")
|
||
for _job in $JOBS; do
|
||
cron_entry "$_job" >>"$_new"
|
||
printf '\n' >>"$_new"
|
||
done
|
||
|
||
if [ "$DRYRUN" -eq 1 ]; then
|
||
for _job in $JOBS; do
|
||
printf 'dryrun=crontab job=%s entry=%s\n' "$_job" "$(cron_entry "$_job" | mask_secret)"
|
||
done
|
||
printf 'dryrun=crontab action=write ttl=%s period=%s legacy_removed=%s others_kept=%s total_lines=%s env_snapshot=%s patrol_root=%s\n' \
|
||
"$TTL" "$PERIOD" "$_legacy" "$_others" "$(count_lines "$_new")" "${SNAPSHOT_NAMES:-無}" "$ROOT"
|
||
printf -- '--- 寫回後的 crontab ---\n'
|
||
mask_secret <"$_new"
|
||
return 0
|
||
fi
|
||
|
||
mkdir -p "$STATE_DIR" 2>/dev/null || true
|
||
"$CRONTAB_CMD" "$_new" >/dev/null 2>"$TMPD/err" \
|
||
|| die 4 "crontab 寫入失敗:$(tr '\n' ' ' <"$TMPD/err")"
|
||
|
||
_chk="$TMPD/chk"; cron_read "$_chk"
|
||
for _job in $JOBS; do
|
||
[ -n "$(cron_lines_for "$_chk" "$_job")" ] \
|
||
|| die 5 "crontab 寫入回 0,卻讀不到 $_job 的條目。"
|
||
[ "$(cron_lines_for "$_chk" "$_job" | count_stdin)" -eq 1 ] \
|
||
|| die 5 "$_job 的條目不只一筆,排程會重複執行。"
|
||
done
|
||
_kept=$(grep -vF "$MARK_PREFIX" "$_chk" 2>/dev/null | count_stdin)
|
||
[ "$_kept" -eq "$_others" ] \
|
||
|| die 5 "別人的排程條目從 $_others 筆變成 $_kept 筆,寫回不完整。"
|
||
|
||
for _job in $JOBS; do
|
||
printf 'installed=%s entry=%s\n' "$_job" "$(cron_lines_for "$_chk" "$_job" | mask_secret)"
|
||
done
|
||
printf 'ttl=%s period=%s legacy_removed=%s others_kept=%s env_snapshot=%s patrol_root=%s log=%s\n' \
|
||
"$TTL" "$PERIOD" "$_legacy" "$_kept" "${SNAPSHOT_NAMES:-無}" "$ROOT" "$LOG"
|
||
return 0
|
||
}
|
||
|
||
crontab_remove() {
|
||
_cur="$TMPD/cur"; _new="$TMPD/new"
|
||
cron_read "$_cur"
|
||
|
||
_hit=0
|
||
cp "$_cur" "$_new"
|
||
for _job in $JOBS; do
|
||
if [ -n "$(cron_lines_for "$_new" "$_job")" ]; then
|
||
_hit=$((_hit + 1))
|
||
else
|
||
note "$_job 本來就沒有排程,不用移除。"
|
||
continue
|
||
fi
|
||
grep -vF "$(marker_of "$_job")" "$_new" >"$TMPD/f" 2>/dev/null || true
|
||
mv "$TMPD/f" "$_new"
|
||
done
|
||
|
||
if [ "$_hit" -eq 0 ]; then
|
||
printf 'removed=0 others_kept=%s\n' "$(count_lines "$_cur")"
|
||
return 0
|
||
fi
|
||
|
||
if [ "$DRYRUN" -eq 1 ]; then
|
||
printf 'dryrun=crontab action=remove jobs=%s removed=%s\n' "$JOBS" "$_hit"
|
||
printf -- '--- 寫回後的 crontab ---\n'
|
||
mask_secret <"$_new"
|
||
return 0
|
||
fi
|
||
|
||
"$CRONTAB_CMD" "$_new" >/dev/null 2>"$TMPD/err" \
|
||
|| die 4 "crontab 寫入失敗:$(tr '\n' ' ' <"$TMPD/err")"
|
||
|
||
_chk="$TMPD/chk"; cron_read "$_chk"
|
||
for _job in $JOBS; do
|
||
[ -z "$(cron_lines_for "$_chk" "$_job")" ] \
|
||
|| die 5 "crontab 刪除回 0,$_job 的條目卻還在。"
|
||
done
|
||
_before=$(grep -vF "$MARK_PREFIX" "$_cur" 2>/dev/null | count_stdin)
|
||
_after=$(grep -vF "$MARK_PREFIX" "$_chk" 2>/dev/null | count_stdin)
|
||
[ "$_before" -eq "$_after" ] \
|
||
|| die 5 "別人的排程條目從 $_before 筆變成 $_after 筆,移除動到了不該動的東西。"
|
||
|
||
printf 'removed=%s others_kept=%s\n' "$_hit" "$_after"
|
||
return 0
|
||
}
|
||
|
||
crontab_status() {
|
||
_cur="$TMPD/cur"; cron_read "$_cur"
|
||
printf 'mechanism=crontab service=%s ttl=%s period=%s log=%s\n' \
|
||
"$(service_state)" "$TTL" "$PERIOD" "$LOG"
|
||
for _job in heartbeat patrol; do
|
||
_line=$(cron_lines_for "$_cur" "$_job" | mask_secret)
|
||
if [ -n "$_line" ]; then
|
||
printf 'job=%s installed=yes entry=%s\n' "$_job" "$_line"
|
||
else
|
||
printf 'job=%s installed=no entry=\n' "$_job"
|
||
fi
|
||
done
|
||
return 0
|
||
}
|
||
|
||
# --- 主流程 ---
|
||
|
||
case "$CMD" in
|
||
install)
|
||
case "$MECH" in
|
||
crontab) crontab_install ;;
|
||
schtasks) schtasks_install ;;
|
||
esac
|
||
# 權限規則與快照提醒排在服務檢查前面:結束碼 1 那一條也是條目已經寫進去了,那台機器
|
||
# 一樣要開權限,只是還要先把 cron 服務叫起來。
|
||
print_allow_rules
|
||
check_current_links
|
||
note "上面這幾條 allow 規則要先開,排程那一輪才不會停在權限詢問——那一輪沒有人可以按同意。規則與指令都要是完整字面:無人值守時只有清單上的完整字面指令跑得動,沒有預設放行的唯讀指令;路徑中段的萬用字元也不匹配,版本號寫成 * 的規則一樣擋。規則放行的是 $ROOT 那一組路徑,巡檢也只能用那一組路徑叫工具。檔案寫入只認 Edit,Write 規則沒有作用。"
|
||
note "條目的提示文字帶著「$ROOT_TOKEN」:那一輪自己解不出根目錄,解路徑的指令在無人值守時一樣被擋,所以根目錄由這一筆條目餵進去。這一段被改掉或刪掉,那一輪會停下回報,什麼都不記。"
|
||
note "條目帶著安裝當下的環境變數快照(${SNAPSHOT_NAMES:-無}),其中含 Gitea 金鑰:crontab 檔案請保持只有本人讀得到。這幾個變數改過就要重跑一次 install,條目才會跟著換。"
|
||
[ "$DRYRUN" -eq 1 ] && exit 0
|
||
_svc=$(service_state)
|
||
if [ "$_svc" = stopped ]; then
|
||
printf 'service=stopped\n'
|
||
die 1 '排程條目寫進去了,但 cron 服務沒在跑,條目一次都不會被執行。WSL 預設不啟動 cron:請跑 `sudo service cron start`,而且重開 WSL 之後要再啟動一次。'
|
||
fi
|
||
printf 'service=%s\n' "$_svc"
|
||
[ "$_svc" = unknown ] && note '判不出排程服務在不在跑,請自行確認條目真的會被執行。'
|
||
exit 0 ;;
|
||
remove)
|
||
case "$MECH" in
|
||
crontab) crontab_remove ;;
|
||
schtasks) schtasks_remove ;;
|
||
esac
|
||
exit 0 ;;
|
||
status)
|
||
case "$MECH" in
|
||
crontab) crontab_status ;;
|
||
schtasks) schtasks_status ;;
|
||
esac
|
||
exit 0 ;;
|
||
esac
|