Files
shared/scripts/version-guard.mjs
T
jiantw83andClaude Sonnet 5 2329e4d709 feat(spec-version-guard): 新增版本前置檢查規範、共用腳本與 hook
新增 spec-version-guard 規範(定義遠端發佈版本 vs 當前實際載入版本的比對規則、
fail-closed、錯誤訊息格式)與 scripts/version-guard.mjs(hook/CLI 雙模式,
hook 模式輸出 Claude Code/Copilot 相容的 PreToolUse deny JSON);spec-preflight
的載入順序補上版本檢查第 0 步;新增 hooks/hooks.json 掛 PreToolUse;
do-wiki/models/plan-wiki/plugins-uninstall/todo-wiki 五個 skill 檔頭引用新規範
(plugins-install 刻意排除,避免版本落後時擋住自己的修復手段)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 14:53:35 +08:00

176 lines
6.8 KiB
JavaScript
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 node
'use strict';
/**
* version-guard.mjs — 依 /jsc-shared:spec-version-guard 規範,比對「遠端發佈版本(Gitea
* master 的 plugin.json)」與「當前實際載入版本(本次呼叫實際讀到的 plugin 目錄)」,
* 兩者不同或查不到遠端版本一律 fail-closed(exit 非 0)。可被 hook(PreToolUse/SessionStart)
* 與 agent/CLI 兩種路徑呼叫。
*
* 用法:
* node version-guard.mjs [--plugin-root <path>] [--host <host>] [--timeout-ms <ms>]
*
* --plugin-root 本地 plugin 根目錄(含 plugin.json)。省略時使用 $CLAUDE_PLUGIN_ROOT;
* 兩者皆無則視為無法判定,fail-closed。
* --host Gitea 主機,省略時依序取 $GITEA_HOST,再退回 gitea.jsc.idv.tw。
* --timeout-ms 遠端查詢逾時毫秒數,預設 5000。
*
* 兩種呼叫模式(自動偵測,不需額外旗標):
* 1. hook 模式:stdin 餵進合法 JSON(Claude Code/Copilot 的 PreToolUse event)時,
* 改用 `persona/hooks/_hook.mjs` 已驗證可行的 stdout JSON 回應機制
* (`{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny"|"allow",...}}`),
* exit code 固定 0(是否阻擋由 JSON 內容決定,不是 exit code)。
* 2. CLI/測試模式:stdin 沒有合法 JSON(互動終端機或空管線)時,用 exit code 表示結果
* (0=相符,非 0=阻擋),訊息走 stderr——供人工測試與端到端驗證使用。
*
* 更新時間:2026/08/17 14:25:00
* 相依:Node.js 標準內建功能(fetch/fs/path),無外部套件;需 Node ≥ 20.12 並以
* `node --use-system-ca` 啟動,讓 fetch 走系統信任的 CA store(而非 Node 內建的
* Mozilla CA 清單)——在企業網路/代理攔截 TLS 的環境下,curl 等工具通常已信任
* 系統憑證,但 Node fetch 預設不會,若不加這個旗標會誤判成「查無法取得遠端版本」
* 而非真正的版本落差;呼叫端(hooks.json)的 command 必須帶上此旗標。
* 機密:不涉及 token(遠端 raw plugin.json 端點匿名可讀),輸出仍套用
* shared/scripts/lib/redact-patterns.json 的遮蔽規則以防萬一。
* 退出碼(CLI/測試模式):0=版本相符;2=版本不符、查不到遠端、或本地版本無法判定(皆為 fail-closed)。
* hook 模式固定 exit 0,改用 stdout JSON 的 permissionDecision 表示阻擋與否(見上方兩種呼叫模式)。
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { log } from './lib/log.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REDACT_RULES = JSON.parse(fs.readFileSync(path.join(__dirname, 'lib', 'redact-patterns.json'), 'utf8')).rules;
function redact(text) {
let out = text;
for (const rule of REDACT_RULES) {
out = out.replace(new RegExp(rule.pattern, 'g'), rule.replacement);
}
return out;
}
function parseArgs(argv) {
const args = { pluginRoot: null, host: null, timeoutMs: 5000 };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--plugin-root') args.pluginRoot = argv[++i];
else if (a === '--host') args.host = argv[++i];
else if (a === '--timeout-ms') args.timeoutMs = Number(argv[++i]);
else {
log('ERR', `未知參數:${redact(a)}`, '版本檢查');
process.exit(1);
}
}
return args;
}
function resolvePluginRoot(args) {
return args.pluginRoot || process.env.CLAUDE_PLUGIN_ROOT || null;
}
function resolveHost(args) {
return args.host || process.env.GITEA_HOST || 'gitea.jsc.idv.tw';
}
/** 從本地 plugin.json 的 name(例如 "jsc-shared")推得 repo 短名("shared")。 */
function shortNameFromPluginName(name) {
return name.startsWith('jsc-') ? name.slice('jsc-'.length) : name;
}
async function fetchRemoteVersion(host, shortName, timeoutMs) {
const url = `https://${host}/api/v1/repos/plugins/${shortName}/raw/plugin.json?ref=master`;
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const body = await res.json();
if (typeof body.version !== 'string') {
throw new Error('遠端 plugin.json 缺少 version 欄位');
}
return body.version;
}
function blockMessage(pluginName, remoteVersion, loadedVersion) {
const remoteText = remoteVersion || '查無法取得遠端版本';
return (
`${pluginName} 遠端發佈版本為 ${remoteText},當前實際載入版本為 ${loadedVersion}。\n` +
'請執行 /jsc-shared:plugins-install 更新後重新開啟工作階段。'
);
}
/** 嘗試把 stdin 當 hook event JSON 讀(比照 persona/hooks/_hook.mjs 的 readEvent());讀不到回傳 null。 */
function readHookEvent() {
try {
const raw = fs.readFileSync(0, 'utf8');
if (!raw.trim()) return null;
return JSON.parse(raw);
} catch {
return null;
}
}
/** hook 模式的回應:exit code 固定 0,是否阻擋由 stdout JSON 的 permissionDecision 決定。 */
function respondHook(decision, reason) {
process.stdout.write(
`${JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: decision,
...(reason ? { permissionDecisionReason: reason } : {}),
},
})}\n`
);
process.exit(0);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const hookEvent = readHookEvent();
const isHookMode = hookEvent !== null;
function fail(message) {
log('ERR', redact(message), '版本檢查');
if (isHookMode) respondHook('deny', redact(message));
process.exit(2);
}
const pluginRoot = resolvePluginRoot(args);
if (!pluginRoot) {
fail('無法判定本地 plugin 根目錄(未帶 --plugin-root 且 $CLAUDE_PLUGIN_ROOT 未設定),fail-closed 阻擋。');
return;
}
const pluginJsonPath = path.join(pluginRoot, 'plugin.json');
let localMeta;
try {
localMeta = JSON.parse(fs.readFileSync(pluginJsonPath, 'utf8'));
} catch (err) {
fail(`讀取本地 plugin.json 失敗(${String(err.message)}),fail-closed 阻擋。`);
return;
}
const loadedVersion = localMeta.version;
const shortName = shortNameFromPluginName(localMeta.name);
const host = resolveHost(args);
let remoteVersion = null;
try {
remoteVersion = await fetchRemoteVersion(host, shortName, args.timeoutMs);
} catch (err) {
fail(`${blockMessage(localMeta.name, null, loadedVersion)}(遠端查詢失敗:${String(err.message)})`);
return;
}
if (remoteVersion !== loadedVersion) {
fail(blockMessage(localMeta.name, remoteVersion, loadedVersion));
return;
}
log('INF', `${localMeta.name} 版本相符(${loadedVersion}),放行。`, '版本檢查');
if (isHookMode) respondHook('allow');
process.exit(0);
}
main();