#!/usr/bin/env node /** * @file check-mcp-build.js * @description `mcp/build/` is committed so a plugin install has a working MCP * server the instant the session opens — Claude Code starts plugin MCP servers * immediately and offers no "not ready yet" retry, so an async bootstrap cannot * win that race. The cost is that the artifact can drift from `mcp/src`. * * Freshness is tracked by hashing the CONTENT of `mcp/src` (plus the manifests * and tsconfig) into `mcp/build/.srchash`. Modification times are useless here: * a fresh clone or checkout stamps every file with the same time in arbitrary * order. * * Run by the pre-commit hook and by `/ccam-doctor`. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); const HASH_FILE = ".srchash"; /** Every file whose content the build depends on, in a stable order. */ function sourceFiles(mcpDir) { const files = []; const walk = (dir) => { let entries; try { entries = fs .readdirSync(dir, { withFileTypes: true }) .sort((a, b) => (a.name < b.name ? -1 : 1)); } catch { return; } for (const e of entries) { const full = path.join(dir, e.name); if (e.isDirectory()) walk(full); else files.push(full); } }; walk(path.join(mcpDir, "src")); for (const f of ["package.json", "package-lock.json", "tsconfig.json"]) { const full = path.join(mcpDir, f); if (fs.existsSync(full)) files.push(full); } return files; } function sourceHash(root = path.resolve(__dirname, "..")) { const mcpDir = path.join(root, "mcp"); const h = crypto.createHash("sha256"); for (const f of sourceFiles(mcpDir)) { h.update(path.relative(mcpDir, f).replace(/\\/g, "/")); h.update(fs.readFileSync(f)); } return h.digest("hex"); } /** * @returns {{ok:boolean, reason:string, expected:string, recorded:string|null}} */ function mcpBuildStatus(root = path.resolve(__dirname, "..")) { const buildDir = path.join(root, "mcp", "build"); const expected = sourceHash(root); if (!fs.existsSync(path.join(buildDir, "index.js"))) { return { ok: false, reason: "mcp/build/index.js is missing", expected, recorded: null }; } let recorded = null; try { recorded = fs.readFileSync(path.join(buildDir, HASH_FILE), "utf8").trim(); } catch { return { ok: false, reason: "mcp/build has no recorded source hash", expected, recorded: null }; } return recorded === expected ? { ok: true, reason: "up to date", expected, recorded } : { ok: false, reason: "mcp/build is stale — mcp/src has changed since it was built", expected, recorded, }; } /** Stamp the current source hash into the build directory. */ function writeHash(root = path.resolve(__dirname, "..")) { const buildDir = path.join(root, "mcp", "build"); fs.mkdirSync(buildDir, { recursive: true }); fs.writeFileSync(path.join(buildDir, HASH_FILE), sourceHash(root) + "\n", "utf8"); } if (require.main === module) { if (process.argv.includes("--write")) { writeHash(); console.log("mcp/build/.srchash updated"); } else { const status = mcpBuildStatus(); if (status.ok) { console.log("mcp/build is up to date"); } else { console.error(`${status.reason}\nRun: npm run mcp:build`); process.exitCode = 1; } } } module.exports = { sourceHash, mcpBuildStatus, writeHash };