- VERSION file as authoritative version source - export.mjs reads VERSION with package.json fallback - bw-ota.ps1 DryRun mode for safe testing - auto-setup.ps1 bumped to v3.2.0 (Phase 8 OTA)
150 lines
4.6 KiB
JavaScript
150 lines
4.6 KiB
JavaScript
#!/usr/bin/env node
|
||
'use strict';
|
||
/**
|
||
* SessionStart MCP Probe (Phase 1 · T1.2)
|
||
* sentinel: PHASE1_T1_2_MCP_PROBE_HOOK_2026_04_24
|
||
*
|
||
* 事件: UserPromptSubmit (Bookworm 无 SessionStart 键,用 UserPromptSubmit + 日期守卫替代)
|
||
* 目的: 每日首次会话轻量探测 MCP 配置健康度,结果写 logs/mcp-health-<date>.json
|
||
*
|
||
* 预算:
|
||
* - 今日 snapshot 已存在: <5ms (fs.existsSync 快速返回)
|
||
* - 首次运行: <500ms (22 个 MCP 的命令存在性检查)
|
||
*
|
||
* 容错:
|
||
* - Feature flag 关闭: 立即 exit 0
|
||
* - 任何异常: exit 0 (fail-open,永不阻断用户输入)
|
||
*
|
||
* 不做的事:
|
||
* - 不 spawn MCP 子进程 (太慢,交给 /mcp-probe 技能)
|
||
* - 不做实际 HTTP 请求
|
||
* - 不修改 .claude.json
|
||
*/
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const os = require('os');
|
||
|
||
const HOME = process.env.USERPROFILE || process.env.HOME || os.homedir();
|
||
const CLAUDE_ROOT = process.env.CLAUDE_HOME ||
|
||
(fs.existsSync(path.join(HOME, '.claude')) ? path.join(HOME, '.claude') : HOME);
|
||
const LOGS_DIR = path.join(CLAUDE_ROOT, 'logs');
|
||
const FEATURE_FLAGS_FILE = path.join(CLAUDE_ROOT, '.bookworm-features.json');
|
||
const TODAY = new Date().toISOString().slice(0, 10);
|
||
const HEALTH_FILE = path.join(LOGS_DIR, 'mcp-health-' + TODAY + '.json');
|
||
const CONFIG_FILE = path.join(HOME, '.claude.json');
|
||
|
||
function safeExit() { process.exit(0); }
|
||
|
||
// 已知在 PATH 上的命令(避免 which 调用开销)
|
||
const WELL_KNOWN_CMDS = /^(npx|node|python|python3|bash|sh|pnpm|yarn|uvx|go|deno|bun)$/;
|
||
|
||
function commandPlausible(cmd) {
|
||
if (!cmd || typeof cmd !== 'string') return false;
|
||
if (path.isAbsolute(cmd)) return fs.existsSync(cmd);
|
||
// Windows 可能配置带 .cmd/.exe/.bat 后缀 (e.g. npx.cmd),剥离后比对已知命令
|
||
const stripped = cmd.replace(/\.(exe|cmd|bat)$/i, '');
|
||
if (WELL_KNOWN_CMDS.test(stripped)) return true;
|
||
// 回退: 按 PATH 扫描 (受限于 PATH 数量,10-30 次 existsSync 可接受)
|
||
const PATH_DIRS = (process.env.PATH || process.env.Path || '').split(path.delimiter).filter(Boolean);
|
||
const candidates = [cmd, cmd + '.exe', cmd + '.cmd', cmd + '.bat'];
|
||
for (const dir of PATH_DIRS) {
|
||
for (const c of candidates) {
|
||
try { if (fs.existsSync(path.join(dir, c))) return true; } catch {}
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function probeMcp(name, cfg) {
|
||
try {
|
||
if (cfg && (cfg.type === 'http' || cfg.type === 'sse')) {
|
||
const hasUrl = !!cfg.url && /^https?:\/\//.test(cfg.url);
|
||
return {
|
||
kind: cfg.type,
|
||
url: cfg.url || null,
|
||
urlValid: hasUrl,
|
||
reachable: hasUrl
|
||
};
|
||
}
|
||
// 默认 stdio
|
||
const command = (cfg && cfg.command) || '';
|
||
const exists = commandPlausible(command);
|
||
return {
|
||
kind: 'stdio',
|
||
command,
|
||
commandExists: exists,
|
||
reachable: exists
|
||
};
|
||
} catch (e) {
|
||
return { error: String(e.message || e), reachable: false };
|
||
}
|
||
}
|
||
|
||
function main() {
|
||
// [P0-2] SESSION_ONCE_v1 — 会话级去重 (<1ms)
|
||
try { if (require('./lib/session-once.js').hasRun('mcp-probe')) return safeExit(); } catch {}
|
||
|
||
// Feature flag 检查
|
||
try {
|
||
if (fs.existsSync(FEATURE_FLAGS_FILE)) {
|
||
const flags = JSON.parse(fs.readFileSync(FEATURE_FLAGS_FILE, 'utf8'));
|
||
if (flags && flags.mcp_probe === false) return safeExit();
|
||
}
|
||
} catch {}
|
||
|
||
// 今日 snapshot 已存在 → 跳过 (日级守卫)
|
||
if (fs.existsSync(HEALTH_FILE)) return safeExit();
|
||
|
||
// 读取 MCP 配置
|
||
let mcpServers = {};
|
||
try {
|
||
const cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
||
mcpServers = cfg.mcpServers || {};
|
||
} catch {
|
||
return safeExit();
|
||
}
|
||
|
||
const results = {};
|
||
let reachable = 0;
|
||
let unreachable = 0;
|
||
const unreachableList = [];
|
||
|
||
for (const name of Object.keys(mcpServers)) {
|
||
const r = probeMcp(name, mcpServers[name]);
|
||
results[name] = r;
|
||
if (r.reachable) reachable++;
|
||
else { unreachable++; unreachableList.push(name); }
|
||
}
|
||
|
||
const snapshot = {
|
||
schema_version: 1,
|
||
date: TODAY,
|
||
probedAt: new Date().toISOString(),
|
||
probeKind: 'lightweight-static',
|
||
totalMcps: Object.keys(mcpServers).length,
|
||
reachable,
|
||
unreachable,
|
||
unreachableList,
|
||
results
|
||
};
|
||
|
||
// 确保 logs 目录
|
||
try { fs.mkdirSync(LOGS_DIR, { recursive: true }); } catch {}
|
||
|
||
// 原子写
|
||
try {
|
||
const tmp = HEALTH_FILE + '.tmp';
|
||
fs.writeFileSync(tmp, JSON.stringify(snapshot, null, 2), 'utf8');
|
||
fs.renameSync(tmp, HEALTH_FILE);
|
||
// [P0-2] SESSION_ONCE_v1
|
||
try { require('./lib/session-once.js').markRun('mcp-probe'); } catch {}
|
||
} catch {}
|
||
|
||
safeExit();
|
||
}
|
||
|
||
if (require.main === module) main();
|
||
|
||
module.exports = { probeMcp, commandPlausible };
|