@luanpdd/kit-mcp 1.16.0 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/kit/file-manifest.json +2 -2
- package/package.json +3 -3
- package/src/cli/index.js +1 -1
- package/src/core/manifest-verify.js +73 -6
- package/src/core/path-safety.js +30 -0
- package/src/core/sync.js +84 -2
package/kit/file-manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.
|
|
3
|
-
"timestamp": "2026-05-
|
|
2
|
+
"version": "1.17.0",
|
|
3
|
+
"timestamp": "2026-05-09T15:58:57.716Z",
|
|
4
4
|
"files": {
|
|
5
5
|
"COMANDOS.md": "d24ec61a6ec35db314cc5f2ae287bfb927b794789c8f1d558c55862f5e6534b2",
|
|
6
6
|
"COMPATIBILITY.md": "794e336a87045cdf0161785b9a7a0975a49abbd80bdd816b8852251fcc8126ca",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luanpdd/kit-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.0",
|
|
4
4
|
"description": "Generic infrastructure to ship YOUR personal kit of agents/commands/skills as an MCP server, with cross-IDE sync (Claude Code, Cursor, Codex, Gemini, Windsurf, Antigravity, Copilot, Trae).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -49,11 +49,11 @@
|
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
51
51
|
"commander": "^14.0.3",
|
|
52
|
-
"open": "^11.0.0",
|
|
53
52
|
"picocolors": "^1.1.1"
|
|
54
53
|
},
|
|
55
54
|
"optionalDependencies": {
|
|
56
55
|
"@inquirer/prompts": "^8.4.2",
|
|
57
|
-
"chokidar": "^5.0.0"
|
|
56
|
+
"chokidar": "^5.0.0",
|
|
57
|
+
"open": "^11.0.0"
|
|
58
58
|
}
|
|
59
59
|
}
|
package/src/cli/index.js
CHANGED
|
@@ -30,7 +30,7 @@ import { installMcp, listInstallTargets } from '../mcp-server/install.js';
|
|
|
30
30
|
import * as render from './render.js';
|
|
31
31
|
import { c, icons, spinner, progress, select, confirm } from '../core/ui.js';
|
|
32
32
|
import { readLock, lockPathFor } from '../ui/lockfile.js';
|
|
33
|
-
import { checkUpgrade
|
|
33
|
+
import { checkUpgrade } from './upgrade-check.js';
|
|
34
34
|
// PERF-16-04: ui/server.js, ui/wrapper.js, ui/browser.js are loaded LAZILY
|
|
35
35
|
// inside the subcommand handlers that need them. See:
|
|
36
36
|
// - maybeWrapForUi (gated on lockfile presence)
|
|
@@ -14,8 +14,39 @@ import path from 'node:path';
|
|
|
14
14
|
import fs from 'node:fs/promises';
|
|
15
15
|
import crypto from 'node:crypto';
|
|
16
16
|
|
|
17
|
+
// PERF-17-01: parallelize SHA256 hashing in batches of 16. Same pattern
|
|
18
|
+
// as Phase 88.01 sync.js. Hardcoded — env override is overengineering
|
|
19
|
+
// for verifyManifest (single hot path, not user-facing latency budget).
|
|
20
|
+
const BATCH_SIZE = 16;
|
|
21
|
+
|
|
22
|
+
// PERF-17-01: in-memory cache for verifyManifest. Same pattern as kit.js
|
|
23
|
+
// listKit cache (PERF-01). Watch triggers (file save → re-sync) call this
|
|
24
|
+
// back-to-back; the 2nd+ call within TTL hits cache and returns <5ms.
|
|
25
|
+
//
|
|
26
|
+
// Caching rules:
|
|
27
|
+
// - Only cache ok=true results. mismatches/missing → recompute every call
|
|
28
|
+
// so devs see fixes immediately (don't punish them for the slow path).
|
|
29
|
+
// - Bypass via KIT_MCP_VERIFY_NO_CACHE=1 (test isolation + emergency dev escape).
|
|
30
|
+
// - Cache key is kitRoot — different roots are independent entries.
|
|
31
|
+
const VERIFY_CACHE_TTL_MS = 30_000;
|
|
32
|
+
const verifyManifestCache = new Map(); // kitRoot -> { value, ts }
|
|
33
|
+
const NO_CACHE_ENV = 'KIT_MCP_VERIFY_NO_CACHE';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Test/emergency helper — clears the cache. Exported for unit tests.
|
|
37
|
+
* Production code should never need this; use the env var instead.
|
|
38
|
+
*/
|
|
39
|
+
export function clearVerifyManifestCache() { verifyManifestCache.clear(); }
|
|
40
|
+
|
|
17
41
|
const SKIP_ENV = 'KIT_MCP_SKIP_MANIFEST_CHECK';
|
|
18
42
|
|
|
43
|
+
/**
|
|
44
|
+
* SEC-14-05: verify kit/file-manifest.json against actual file contents.
|
|
45
|
+
* PERF-17-01: hashes in Promise.all batches of 16 (was sequential pre-v1.17).
|
|
46
|
+
* Called by syncTo() in install path before any write — refuses to project a tampered kit.
|
|
47
|
+
* @param {string} kitRoot - absolute path to kit/ directory.
|
|
48
|
+
* @returns {Promise<{ok: boolean, skipped?: boolean, reason?: string, mismatches?: Array, missing?: string[]}>}
|
|
49
|
+
*/
|
|
19
50
|
export async function verifyManifest(kitRoot) {
|
|
20
51
|
if (process.env[SKIP_ENV] === '1') {
|
|
21
52
|
process.stderr.write(
|
|
@@ -24,6 +55,15 @@ export async function verifyManifest(kitRoot) {
|
|
|
24
55
|
return { ok: true, skipped: true };
|
|
25
56
|
}
|
|
26
57
|
|
|
58
|
+
// PERF-17-01: cache hit — repeated calls within TTL skip the I/O + hashing.
|
|
59
|
+
// Bypass via KIT_MCP_VERIFY_NO_CACHE=1 (tests + dev emergency escape).
|
|
60
|
+
if (process.env[NO_CACHE_ENV] !== '1') {
|
|
61
|
+
const cached = verifyManifestCache.get(kitRoot);
|
|
62
|
+
if (cached && Date.now() - cached.ts < VERIFY_CACHE_TTL_MS) {
|
|
63
|
+
return cached.value;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
27
67
|
const manifestPath = path.join(kitRoot, 'file-manifest.json');
|
|
28
68
|
let manifest;
|
|
29
69
|
try {
|
|
@@ -50,27 +90,54 @@ export async function verifyManifest(kitRoot) {
|
|
|
50
90
|
const mismatches = [];
|
|
51
91
|
const missing = [];
|
|
52
92
|
|
|
53
|
-
|
|
93
|
+
const entries = Object.entries(manifest.files);
|
|
94
|
+
|
|
95
|
+
// Per-file check — returns { rel, status: 'ok'|'mismatch'|'missing', expected?, actual? }.
|
|
96
|
+
// Pure function (no side effects on shared arrays) so Promise.all in batches
|
|
97
|
+
// is safe — caller aggregates after each batch resolves.
|
|
98
|
+
const checkOne = async ([rel, expected]) => {
|
|
54
99
|
const abs = path.join(kitRoot, rel);
|
|
55
100
|
let buf;
|
|
56
101
|
try {
|
|
57
102
|
buf = await fs.readFile(abs);
|
|
58
103
|
} catch {
|
|
59
|
-
missing
|
|
60
|
-
continue;
|
|
104
|
+
return { rel, status: 'missing' };
|
|
61
105
|
}
|
|
62
106
|
// Normalize CRLF→LF before hashing so manifest is platform-stable.
|
|
63
107
|
// git checkout converts EOL on Windows but Linux CI checks out LF —
|
|
64
|
-
// hashing raw bytes would diverge across platforms.
|
|
108
|
+
// hashing raw bytes would diverge across platforms. (PRESERVED from v1.15)
|
|
65
109
|
const normalized = Buffer.from(buf.toString('binary').replace(/\r\n/g, '\n'), 'binary');
|
|
66
110
|
const actual = crypto.createHash('sha256').update(normalized).digest('hex');
|
|
67
111
|
if (actual !== expected) {
|
|
68
|
-
|
|
112
|
+
return { rel, status: 'mismatch', expected, actual };
|
|
113
|
+
}
|
|
114
|
+
return { rel, status: 'ok' };
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Sequential batches — within a batch, Promise.all parallelizes hashing;
|
|
118
|
+
// between batches, await bounds max-in-flight at BATCH_SIZE (defensive
|
|
119
|
+
// against fd ulimit on large kits). Order of completion within a batch
|
|
120
|
+
// doesn't matter — aggregator below is order-independent.
|
|
121
|
+
for (let i = 0; i < entries.length; i += BATCH_SIZE) {
|
|
122
|
+
const slice = entries.slice(i, i + BATCH_SIZE);
|
|
123
|
+
const results = await Promise.all(slice.map(checkOne));
|
|
124
|
+
for (const r of results) {
|
|
125
|
+
if (r.status === 'mismatch') {
|
|
126
|
+
mismatches.push({ path: r.rel, expected: r.expected.slice(0, 16), actual: r.actual.slice(0, 16) });
|
|
127
|
+
} else if (r.status === 'missing') {
|
|
128
|
+
missing.push(r.rel);
|
|
129
|
+
}
|
|
69
130
|
}
|
|
70
131
|
}
|
|
71
132
|
|
|
72
133
|
if (mismatches.length === 0 && missing.length === 0) {
|
|
73
|
-
|
|
134
|
+
const result = { ok: true };
|
|
135
|
+
// PERF-17-01: cache only ok=true. Mismatch/missing always recompute
|
|
136
|
+
// so dev fixing a tampered file sees the next sync recover immediately.
|
|
137
|
+
if (process.env[NO_CACHE_ENV] !== '1') {
|
|
138
|
+
verifyManifestCache.set(kitRoot, { value: result, ts: Date.now() });
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
74
141
|
}
|
|
75
142
|
|
|
76
143
|
// Build a concise reason — first 3 mismatches, plus counts.
|
package/src/core/path-safety.js
CHANGED
|
@@ -32,6 +32,36 @@ import fs from 'node:fs/promises';
|
|
|
32
32
|
// six regexes; one suffices.
|
|
33
33
|
const SENTINEL = 'MCP sync requires projectRoot to be a git workspace';
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* SEC-14-03: validate that a `projectRoot` supplied via MCP message points to
|
|
37
|
+
* a real git workspace before any handler dispatches into sync.js / reverse-sync.js.
|
|
38
|
+
*
|
|
39
|
+
* Pure function — never throws. Returns a discriminated union so MCP handlers
|
|
40
|
+
* can wrap rejections in `{ error }` envelopes without try/catch boilerplate
|
|
41
|
+
* (matches handleSync/handleGates/handleForensics in src/mcp-server/index.js).
|
|
42
|
+
*
|
|
43
|
+
* Validation chain (each step short-circuits on rejection):
|
|
44
|
+
* 1. `projectRoot` is non-empty string (rejects nullish, empty, non-string types).
|
|
45
|
+
* 2. `path.resolve()` collapses `..` segments and produces an absolute path.
|
|
46
|
+
* 3. The resolved path exists and is a directory (UNC failures bubble up here).
|
|
47
|
+
* 4. A `.git` entry exists somewhere in the ancestor chain (file or directory —
|
|
48
|
+
* `git worktree` uses a file). Walk-up bounded by `path.dirname()` fixed point.
|
|
49
|
+
*
|
|
50
|
+
* The CLI does NOT call this — `bin/cli.js` trusts whoever invoked it (same
|
|
51
|
+
* trust model as Phase 79.01's gates.run guard). Only MCP-message-sourced paths
|
|
52
|
+
* need this check.
|
|
53
|
+
*
|
|
54
|
+
* Rejection reasons all embed the literal `"git workspace"` string — public
|
|
55
|
+
* contract relied on by test/unit/mcp-projectroot-guard.test.js and downstream
|
|
56
|
+
* MCP clients. Don't rephrase without coordinating callers.
|
|
57
|
+
*
|
|
58
|
+
* @param {unknown} projectRoot - the candidate path supplied by an MCP client.
|
|
59
|
+
* Expected to be an absolute filesystem path; any other shape is rejected.
|
|
60
|
+
* @returns {Promise<{ok: true, resolvedPath: string} | {ok: false, reason: string}>}
|
|
61
|
+
* On success, `resolvedPath` is the path-resolved absolute form of `projectRoot`
|
|
62
|
+
* (callers should use it instead of the raw input). On failure, `reason` is a
|
|
63
|
+
* human-readable string suitable for MCP `{error}` envelopes.
|
|
64
|
+
*/
|
|
35
65
|
export async function validateProjectRoot(projectRoot) {
|
|
36
66
|
// Reject empty / nullish up-front. We require an explicit projectRoot from
|
|
37
67
|
// MCP messages — falling back to `process.cwd()` of the MCP server would let
|
package/src/core/sync.js
CHANGED
|
@@ -31,6 +31,40 @@ function resolveBatchSize() {
|
|
|
31
31
|
return n;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// PERF-17-02: opt-out of stat-based diff skip. Forces full sync (every op writes)
|
|
35
|
+
// for cleanup/recovery scenarios where target files may be subtly out of sync
|
|
36
|
+
// (manual edits, partial fs corruption) but pass the mtime+size diff heuristic.
|
|
37
|
+
function resolveForceFullSync() {
|
|
38
|
+
return process.env.KIT_MCP_FORCE_FULL_SYNC === '1';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Project the canonical kit/ into an IDE-specific layout (claude-code, cursor, etc.).
|
|
43
|
+
*
|
|
44
|
+
* Workflow:
|
|
45
|
+
* 1. SEC-14-05: verifyManifest(kitRoot) — refuses tampered kits (Phase 83+90).
|
|
46
|
+
* 2. Build ops[] (rules + agents + commands + skills + framework/hooks treeCopy).
|
|
47
|
+
* 3. PERF-17-02: stat-based diff filter — skip treeCopy ops whose target already
|
|
48
|
+
* matches source (mtime+size). Bypassed via KIT_MCP_FORCE_FULL_SYNC=1.
|
|
49
|
+
* 4. PERF-16-01: Promise.all batches=16 over writeOps (Phase 88.01).
|
|
50
|
+
*
|
|
51
|
+
* onProgress callback receives one event per op (written or skipped); skipped ops
|
|
52
|
+
* carry `skipped: true` for UI granularity.
|
|
53
|
+
*
|
|
54
|
+
* Stable API v1.0+ preserved: return shape unchanged. `written[]` lists all op
|
|
55
|
+
* paths (projected files), not just actually-written — semantics: "what's in the
|
|
56
|
+
* target tree after this call", not "what fs.writeFile ran".
|
|
57
|
+
*
|
|
58
|
+
* @param {string} targetId - registry target id (e.g. 'claude-code', 'cursor').
|
|
59
|
+
* @param {object} [opts]
|
|
60
|
+
* @param {string} [opts.projectRoot=process.cwd()] - destination project root.
|
|
61
|
+
* @param {string} [opts.kitRoot] - canonical kit/ root (auto-resolved if absent).
|
|
62
|
+
* @param {'reference'|'copy'|'symlink'} [opts.mode='reference'] - projection mode.
|
|
63
|
+
* @param {boolean} [opts.dryRun=false] - skip all fs writes; ops still listed.
|
|
64
|
+
* @param {Function} [opts.onProgress] - per-op callback ({phase, current, total, label, skipped?}).
|
|
65
|
+
* @param {object} [opts.kit] - pre-loaded kit (skips listKit re-walk).
|
|
66
|
+
* @returns {Promise<{target, mode, projectRoot, kitRoot, written, dryRun}>}
|
|
67
|
+
*/
|
|
34
68
|
export async function syncTo(targetId, opts = {}) {
|
|
35
69
|
const target = getTarget(targetId);
|
|
36
70
|
const projectRoot = path.resolve(opts.projectRoot ?? process.cwd());
|
|
@@ -116,6 +150,51 @@ export async function syncTo(targetId, opts = {}) {
|
|
|
116
150
|
let completed = 0;
|
|
117
151
|
const total = ops.length;
|
|
118
152
|
|
|
153
|
+
// PERF-17-02: stat-based diff filter — skip ops whose target already matches source.
|
|
154
|
+
// Only applies to treeCopy ops (framework/hooks subtrees) — content ops (agents,
|
|
155
|
+
// commands, skills, rules) include `Generated by kit-mcp at ${ISO timestamp}` so
|
|
156
|
+
// they re-render every time and can't safely diff. treeCopy ops dominate wall
|
|
157
|
+
// time on large kits (327+ files), so this captures the PERF-17-02 win.
|
|
158
|
+
//
|
|
159
|
+
// Filter logic per op:
|
|
160
|
+
// - forceFullSync env set → never skip
|
|
161
|
+
// - !treeCopy (content op) → never skip
|
|
162
|
+
// - target stat fails (absent)→ never skip (must write)
|
|
163
|
+
// - src stat fails (defensive)→ never skip (let copy fail naturally)
|
|
164
|
+
// - target.size === src.size AND target.mtimeMs >= src.mtimeMs → SKIP
|
|
165
|
+
//
|
|
166
|
+
// Implementation: Promise.all over ops produces { op, skip } pairs. Skipped ops
|
|
167
|
+
// emit onProgress({ skipped: true }) and increment the same `completed` counter
|
|
168
|
+
// as written ops (so progress UI shows full ops.length total).
|
|
169
|
+
const forceFullSync = resolveForceFullSync();
|
|
170
|
+
|
|
171
|
+
const diffOne = async (op) => {
|
|
172
|
+
if (forceFullSync) return { op, skip: false };
|
|
173
|
+
if (!op.treeCopy) return { op, skip: false };
|
|
174
|
+
let targetStat;
|
|
175
|
+
try { targetStat = await fs.stat(op.path); }
|
|
176
|
+
catch { return { op, skip: false }; }
|
|
177
|
+
let srcStat;
|
|
178
|
+
try { srcStat = await fs.stat(op.srcAbs); }
|
|
179
|
+
catch { return { op, skip: false }; }
|
|
180
|
+
if (targetStat.size === srcStat.size && targetStat.mtimeMs >= srcStat.mtimeMs) {
|
|
181
|
+
return { op, skip: true };
|
|
182
|
+
}
|
|
183
|
+
return { op, skip: false };
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// Stats are cheap — no batch limit needed (Promise.all over all ops is fine).
|
|
187
|
+
const diffResults = await Promise.all(ops.map(diffOne));
|
|
188
|
+
const writeOps = [];
|
|
189
|
+
for (const { op, skip } of diffResults) {
|
|
190
|
+
if (skip) {
|
|
191
|
+
completed += 1;
|
|
192
|
+
onProgress({ phase: op.kind, current: completed, total, label: path.basename(op.path), skipped: true });
|
|
193
|
+
} else {
|
|
194
|
+
writeOps.push(op);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
119
198
|
// Apply one op (mkdir + write or copy + onProgress).
|
|
120
199
|
// Each op is independent: ops[] is built so writes don't share parent
|
|
121
200
|
// directories that need ordering — mkdir({recursive:true}) is idempotent
|
|
@@ -129,17 +208,20 @@ export async function syncTo(targetId, opts = {}) {
|
|
|
129
208
|
}
|
|
130
209
|
// Counter increment is single-threaded by JS event loop semantics —
|
|
131
210
|
// no torn reads even with 16 ops resolving in any order.
|
|
211
|
+
// (PERF-17-02: diff filter increments the same counter for skipped ops before
|
|
212
|
+
// this batch loop runs, so `current` in onProgress reflects total progress.)
|
|
132
213
|
completed += 1;
|
|
133
214
|
onProgress({ phase: op.kind, current: completed, total, label: path.basename(op.path) });
|
|
134
215
|
};
|
|
135
216
|
|
|
217
|
+
// PERF-16-01 batched writes — now operating on writeOps (post-diff filter).
|
|
136
218
|
// Sequential batches — within a batch, Promise.all parallelizes writes;
|
|
137
219
|
// between batches, we await to bound max-in-flight at BATCH_SIZE. If any
|
|
138
220
|
// op in a batch rejects, Promise.all rejects on first failure (matches
|
|
139
221
|
// existing behavior — sync.js had no retry logic, so a single fs error
|
|
140
222
|
// already aborted the install).
|
|
141
|
-
for (let i = 0; i <
|
|
142
|
-
const slice =
|
|
223
|
+
for (let i = 0; i < writeOps.length; i += BATCH_SIZE) {
|
|
224
|
+
const slice = writeOps.slice(i, i + BATCH_SIZE);
|
|
143
225
|
await Promise.all(slice.map(applyOp));
|
|
144
226
|
}
|
|
145
227
|
}
|