@luanpdd/kit-mcp 1.15.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 +6 -4
- package/src/cli/index.js +23 -6
- package/src/core/manifest-verify.js +73 -6
- package/src/core/path-safety.js +30 -0
- package/src/core/reverse-sync.js +23 -6
- package/src/core/sync.js +117 -4
- package/src/core/ui.js +19 -1
- package/src/core/watch.js +29 -3
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": {
|
|
@@ -47,11 +47,13 @@
|
|
|
47
47
|
"prepublishOnly": "node scripts/regen-manifest.js && node scripts/update-readme-counts.js && node test/run.mjs test/unit && node test/run.mjs test/integration"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@inquirer/prompts": "^8.4.2",
|
|
51
50
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
52
|
-
"chokidar": "^5.0.0",
|
|
53
51
|
"commander": "^14.0.3",
|
|
54
|
-
"open": "^11.0.0",
|
|
55
52
|
"picocolors": "^1.1.1"
|
|
53
|
+
},
|
|
54
|
+
"optionalDependencies": {
|
|
55
|
+
"@inquirer/prompts": "^8.4.2",
|
|
56
|
+
"chokidar": "^5.0.0",
|
|
57
|
+
"open": "^11.0.0"
|
|
56
58
|
}
|
|
57
59
|
}
|
package/src/cli/index.js
CHANGED
|
@@ -29,11 +29,16 @@ import { listReplays, loadReplay } from '../core/replays.js';
|
|
|
29
29
|
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
|
-
import { createServer } from '../ui/server.js';
|
|
33
32
|
import { readLock, lockPathFor } from '../ui/lockfile.js';
|
|
34
|
-
import {
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
import { checkUpgrade } from './upgrade-check.js';
|
|
34
|
+
// PERF-16-04: ui/server.js, ui/wrapper.js, ui/browser.js are loaded LAZILY
|
|
35
|
+
// inside the subcommand handlers that need them. See:
|
|
36
|
+
// - maybeWrapForUi (gated on lockfile presence)
|
|
37
|
+
// - ui.start (createServer + openBrowser)
|
|
38
|
+
// - ui.open (openBrowser)
|
|
39
|
+
// This trims ~700 LOC + transitive deps off the cold-start path of non-UI
|
|
40
|
+
// commands like `kit kit list-agents --terse`. lockfile.js stays eager because
|
|
41
|
+
// readLock() is called by every withProgress() invocation and is dep-free.
|
|
37
42
|
import http from 'node:http';
|
|
38
43
|
import fs from 'node:fs';
|
|
39
44
|
import os from 'node:os';
|
|
@@ -106,7 +111,7 @@ async function withProgress(label, total, fn, { tool, projectRoot } = {}) {
|
|
|
106
111
|
}
|
|
107
112
|
|
|
108
113
|
// Auto-wrap if a sidecar is running for this projectRoot.
|
|
109
|
-
const wrapper = maybeWrapForUi(onProgress, { tool, projectRoot });
|
|
114
|
+
const wrapper = await maybeWrapForUi(onProgress, { tool, projectRoot });
|
|
110
115
|
try {
|
|
111
116
|
const r = await fn(wrapper);
|
|
112
117
|
if (p) p.finish(label);
|
|
@@ -121,7 +126,11 @@ async function withProgress(label, total, fn, { tool, projectRoot } = {}) {
|
|
|
121
126
|
|
|
122
127
|
// maybeWrapForUi — returns the original callback unchanged when no sidecar is up
|
|
123
128
|
// or the user opted out. Otherwise returns a wrapped callback with .done/.error.
|
|
124
|
-
|
|
129
|
+
//
|
|
130
|
+
// PERF-16-04: this is async because we lazy-load ../ui/wrapper.js only when a
|
|
131
|
+
// sidecar lockfile is detected. Common path (no sidecar) returns synchronously
|
|
132
|
+
// via passthroughWrapper without touching wrapper.js or its transitive deps.
|
|
133
|
+
async function maybeWrapForUi(onProgress, { tool, projectRoot } = {}) {
|
|
125
134
|
const globalOpts = program.opts();
|
|
126
135
|
// commander stores `--no-ui` as opts.ui === false
|
|
127
136
|
if (globalOpts.ui === false || process.env.KIT_MCP_NO_UI === '1') {
|
|
@@ -131,6 +140,8 @@ function maybeWrapForUi(onProgress, { tool, projectRoot } = {}) {
|
|
|
131
140
|
if (!readLock(root)) {
|
|
132
141
|
return passthroughWrapper(onProgress);
|
|
133
142
|
}
|
|
143
|
+
// Lazy import — only paid when a sidecar IS up for this project.
|
|
144
|
+
const { wrapProgressForUi } = await import('../ui/wrapper.js');
|
|
134
145
|
return wrapProgressForUi(onProgress, { projectRoot: root, tool: tool ?? null });
|
|
135
146
|
}
|
|
136
147
|
|
|
@@ -407,6 +418,8 @@ ui.command('start')
|
|
|
407
418
|
const projectRoot = opts.projectRoot || process.cwd();
|
|
408
419
|
const port = opts.port ? Number(opts.port) : undefined;
|
|
409
420
|
const idleMs = opts.idleMs !== undefined ? Number(opts.idleMs) : undefined;
|
|
421
|
+
// PERF-16-04: lazy-load the sidecar HTTP server module only when starting it.
|
|
422
|
+
const { createServer } = await import('../ui/server.js');
|
|
410
423
|
const srv = createServer({ projectRoot, idleMs });
|
|
411
424
|
try {
|
|
412
425
|
const { port: actualPort } = await srv.start({ port });
|
|
@@ -422,6 +435,8 @@ ui.command('start')
|
|
|
422
435
|
}
|
|
423
436
|
}).catch(() => { /* offline / silent */ });
|
|
424
437
|
if (opts.open !== false) {
|
|
438
|
+
// PERF-16-04: lazy-load browser-opener (it lazy-loads `open` package itself).
|
|
439
|
+
const { openBrowser } = await import('../ui/browser.js');
|
|
425
440
|
await openBrowser(url);
|
|
426
441
|
}
|
|
427
442
|
// The server's own SIGINT handler will perform shutdown + cleanup.
|
|
@@ -480,6 +495,8 @@ ui.command('open')
|
|
|
480
495
|
const lock = readLock(projectRoot);
|
|
481
496
|
if (!lock) return fail('no sidecar running — start one with `kit ui start`');
|
|
482
497
|
const url = `http://127.0.0.1:${lock.port}/`;
|
|
498
|
+
// PERF-16-04: lazy-load browser-opener.
|
|
499
|
+
const { openBrowser } = await import('../ui/browser.js');
|
|
483
500
|
const r = await openBrowser(url, { force: true });
|
|
484
501
|
if (!r.opened) {
|
|
485
502
|
process.stderr.write(`${c.yellow(icons.warn)} could not open browser (${r.reason}); copy the URL above\n`);
|
|
@@ -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/reverse-sync.js
CHANGED
|
@@ -31,17 +31,34 @@ export async function detectReverse(targetId, opts = {}) {
|
|
|
31
31
|
|
|
32
32
|
const candidates = [];
|
|
33
33
|
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
// PERF-16-03: parallelize the 5 scans. Each scan reads a distinct subdirectory
|
|
35
|
+
// of the IDE layout (.claude/agents, .claude/commands, .claude/skills,
|
|
36
|
+
// .claude/framework, .claude/hooks) — there is no I/O contention between them.
|
|
37
|
+
//
|
|
38
|
+
// Each scan continues to push into the shared `candidates` array. This is safe
|
|
39
|
+
// under the single-threaded JS event loop: `Array.prototype.push` is a
|
|
40
|
+
// synchronous operation that completes between awaits, so concurrent scans
|
|
41
|
+
// never produce a torn write. The trade-off is that candidate ordering is no
|
|
42
|
+
// longer deterministic across categories — existing reverse-sync tests use
|
|
43
|
+
// `.find` / `.some` / `.filter` and never index `candidates[N]`, so this is a
|
|
44
|
+
// safe widening of the contract.
|
|
45
|
+
//
|
|
46
|
+
// Error semantics: `Promise.all` rejects on the first rejection — identical to
|
|
47
|
+
// the previous sequential `await` chain (which also propagated the first error
|
|
48
|
+
// and aborted the rest). Fail-fast is preserved.
|
|
49
|
+
const pending = [];
|
|
50
|
+
|
|
51
|
+
if (target.agents) pending.push(scanCapability(candidates, 'agent', target.agents, projectRoot, kit.agents, kitRoot));
|
|
52
|
+
if (target.commands) pending.push(scanCapability(candidates, 'command', target.commands, projectRoot, kit.commands, kitRoot));
|
|
53
|
+
if (target.skills) pending.push(scanSkills (candidates, target.skills, projectRoot, [...kit.skills, ...kit.skillsExtras], kitRoot));
|
|
39
54
|
for (const cap of ['framework', 'hooks']) {
|
|
40
55
|
const spec = target[cap];
|
|
41
56
|
if (!spec || spec.mode !== 'mirror-tree') continue;
|
|
42
|
-
|
|
57
|
+
pending.push(scanMirrorTree(candidates, cap, spec, projectRoot, kitRoot));
|
|
43
58
|
}
|
|
44
59
|
|
|
60
|
+
await Promise.all(pending);
|
|
61
|
+
|
|
45
62
|
return { target: targetId, projectRoot, kitRoot, candidates };
|
|
46
63
|
}
|
|
47
64
|
|
package/src/core/sync.js
CHANGED
|
@@ -19,6 +19,52 @@ const STUB_MARKER = '<!-- kit-mcp:reference -->';
|
|
|
19
19
|
const MANAGED_MARKER_FILE = '.kit-mcp-managed';
|
|
20
20
|
const MANAGED_MARKER_BODY = '# Managed by @luanpdd/kit-mcp — this directory is overwritten on every `kit sync install`.\n# Do not edit files here directly; edit the canonical source under kit/ and re-run sync.\n# Removing this file disables `kit sync remove` cleanup of this tree.\n';
|
|
21
21
|
|
|
22
|
+
// PERF-16-01: parallelize file writes in syncTo() via Promise.all batches.
|
|
23
|
+
// BATCH_SIZE=16 default — safe under Linux ulimit 1024 fd default and
|
|
24
|
+
// macOS/Windows equivalents. Configurable via env (e.g. on slow disks).
|
|
25
|
+
// Values outside [1, 256] fall back to 16 (defensive — env vars are strings).
|
|
26
|
+
function resolveBatchSize() {
|
|
27
|
+
const raw = process.env.KIT_MCP_SYNC_BATCH_SIZE;
|
|
28
|
+
if (!raw) return 16;
|
|
29
|
+
const n = Number.parseInt(raw, 10);
|
|
30
|
+
if (!Number.isFinite(n) || n < 1 || n > 256) return 16;
|
|
31
|
+
return n;
|
|
32
|
+
}
|
|
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
|
+
*/
|
|
22
68
|
export async function syncTo(targetId, opts = {}) {
|
|
23
69
|
const target = getTarget(targetId);
|
|
24
70
|
const projectRoot = path.resolve(opts.projectRoot ?? process.cwd());
|
|
@@ -100,16 +146,83 @@ export async function syncTo(targetId, opts = {}) {
|
|
|
100
146
|
}
|
|
101
147
|
|
|
102
148
|
if (!dryRun) {
|
|
103
|
-
|
|
104
|
-
|
|
149
|
+
const BATCH_SIZE = resolveBatchSize();
|
|
150
|
+
let completed = 0;
|
|
151
|
+
const total = ops.length;
|
|
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
|
+
|
|
198
|
+
// Apply one op (mkdir + write or copy + onProgress).
|
|
199
|
+
// Each op is independent: ops[] is built so writes don't share parent
|
|
200
|
+
// directories that need ordering — mkdir({recursive:true}) is idempotent
|
|
201
|
+
// even when 16 ops race for the same parent dir.
|
|
202
|
+
const applyOp = async (op) => {
|
|
105
203
|
await fs.mkdir(path.dirname(op.path), { recursive: true });
|
|
106
204
|
if (op.treeCopy) {
|
|
107
205
|
await fs.copyFile(op.srcAbs, op.path);
|
|
108
206
|
} else {
|
|
109
207
|
await fs.writeFile(op.path, op.content, 'utf8');
|
|
110
208
|
}
|
|
111
|
-
|
|
112
|
-
|
|
209
|
+
// Counter increment is single-threaded by JS event loop semantics —
|
|
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.)
|
|
213
|
+
completed += 1;
|
|
214
|
+
onProgress({ phase: op.kind, current: completed, total, label: path.basename(op.path) });
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// PERF-16-01 batched writes — now operating on writeOps (post-diff filter).
|
|
218
|
+
// Sequential batches — within a batch, Promise.all parallelizes writes;
|
|
219
|
+
// between batches, we await to bound max-in-flight at BATCH_SIZE. If any
|
|
220
|
+
// op in a batch rejects, Promise.all rejects on first failure (matches
|
|
221
|
+
// existing behavior — sync.js had no retry logic, so a single fs error
|
|
222
|
+
// already aborted the install).
|
|
223
|
+
for (let i = 0; i < writeOps.length; i += BATCH_SIZE) {
|
|
224
|
+
const slice = writeOps.slice(i, i + BATCH_SIZE);
|
|
225
|
+
await Promise.all(slice.map(applyOp));
|
|
113
226
|
}
|
|
114
227
|
}
|
|
115
228
|
|
package/src/core/ui.js
CHANGED
|
@@ -10,7 +10,23 @@
|
|
|
10
10
|
// - Zero hidden globals — every primitive is a plain function/class.
|
|
11
11
|
|
|
12
12
|
import pc from 'picocolors';
|
|
13
|
-
|
|
13
|
+
|
|
14
|
+
// PERF-16-05: @inquirer/prompts é optionalDependency. Carregamos lazy dentro
|
|
15
|
+
// de select()/confirm() para que (a) modo MCP server e CI não paguem o custo
|
|
16
|
+
// de boot, e (b) `npm install --omit=optional` produza CLI core funcional
|
|
17
|
+
// (apenas comandos interativos falham com mensagem descritiva).
|
|
18
|
+
let _inquirerModule = null;
|
|
19
|
+
async function loadInquirer() {
|
|
20
|
+
if (_inquirerModule) return _inquirerModule;
|
|
21
|
+
try {
|
|
22
|
+
_inquirerModule = await import('@inquirer/prompts');
|
|
23
|
+
return _inquirerModule;
|
|
24
|
+
} catch (err) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
'Interactive prompts require @inquirer/prompts. Install with `npm i @inquirer/prompts` or pass --yes / --no-interactive to skip the prompt.'
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
14
30
|
|
|
15
31
|
// --- color helpers ---
|
|
16
32
|
|
|
@@ -127,6 +143,7 @@ export async function select(opts) {
|
|
|
127
143
|
if (!process.stdin.isTTY) {
|
|
128
144
|
throw new Error('Interactive prompt unavailable: stdin is not a TTY. Pass the value as a flag instead.');
|
|
129
145
|
}
|
|
146
|
+
const { select: inqSelect } = await loadInquirer();
|
|
130
147
|
return inqSelect(opts);
|
|
131
148
|
}
|
|
132
149
|
|
|
@@ -134,6 +151,7 @@ export async function confirm(opts) {
|
|
|
134
151
|
if (!process.stdin.isTTY) {
|
|
135
152
|
throw new Error('Interactive prompt unavailable: stdin is not a TTY. Pass --yes to skip confirmation.');
|
|
136
153
|
}
|
|
154
|
+
const { confirm: inqConfirm } = await loadInquirer();
|
|
137
155
|
return inqConfirm(opts);
|
|
138
156
|
}
|
|
139
157
|
|
package/src/core/watch.js
CHANGED
|
@@ -11,16 +11,35 @@
|
|
|
11
11
|
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import fs from 'node:fs/promises';
|
|
14
|
-
import chokidar from 'chokidar';
|
|
15
14
|
import { syncTo } from './sync.js';
|
|
16
15
|
import { listTargets } from './registry.js';
|
|
17
|
-
import { resolveKitRoot } from './kit.js';
|
|
16
|
+
import { resolveKitRoot, clearKitCache } from './kit.js';
|
|
17
|
+
|
|
18
|
+
// PERF-16-06: chokidar é optionalDependency. Carregamos lazy dentro de watchKit()
|
|
19
|
+
// para que (a) `kit sync install` (que NÃO usa watch) não pague o custo de boot,
|
|
20
|
+
// e (b) `npm install --omit=optional` produza CLI core funcional (apenas
|
|
21
|
+
// `kit sync watch` falha com mensagem descritiva).
|
|
22
|
+
let _chokidarModule = null;
|
|
23
|
+
async function loadChokidar() {
|
|
24
|
+
if (_chokidarModule) return _chokidarModule;
|
|
25
|
+
try {
|
|
26
|
+
const mod = await import('chokidar');
|
|
27
|
+
_chokidarModule = mod.default || mod;
|
|
28
|
+
return _chokidarModule;
|
|
29
|
+
} catch (err) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
'kit sync watch requires chokidar. Install with `npm i chokidar` or use `kit sync install <target>` for one-shot syncing instead.'
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
18
35
|
|
|
19
36
|
export async function watchKit(targets, opts = {}) {
|
|
20
37
|
const projectRoot = path.resolve(opts.projectRoot ?? process.cwd());
|
|
21
38
|
const kitRoot = resolveKitRoot(opts.kitRoot);
|
|
22
39
|
const mode = opts.mode ?? 'reference';
|
|
23
|
-
|
|
40
|
+
// PERF-16-02: bump default 300 → 500ms to coalesce IDE save-bursts (typical
|
|
41
|
+
// IDE auto-save fires 5-10 events in < 500ms during a single user save).
|
|
42
|
+
const debounceMs = Number.isFinite(opts.debounceMs) ? opts.debounceMs : 500;
|
|
24
43
|
const onLog = opts.onLog ?? (() => {});
|
|
25
44
|
|
|
26
45
|
if (!Array.isArray(targets) || targets.length === 0) {
|
|
@@ -33,6 +52,7 @@ export async function watchKit(targets, opts = {}) {
|
|
|
33
52
|
onLog(`✓ initial sync → ${t} (${r.written.length} files)`);
|
|
34
53
|
}
|
|
35
54
|
|
|
55
|
+
const chokidar = await loadChokidar();
|
|
36
56
|
const watcher = chokidar.watch(kitRoot, {
|
|
37
57
|
ignored: (p) => /(^|[/\\])\.[^/\\]/.test(p), // ignore dotfiles/dotdirs
|
|
38
58
|
persistent: true,
|
|
@@ -46,6 +66,12 @@ export async function watchKit(targets, opts = {}) {
|
|
|
46
66
|
if (pending) clearTimeout(pending);
|
|
47
67
|
pending = setTimeout(async () => {
|
|
48
68
|
pending = null;
|
|
69
|
+
// PERF-16-02: invalidate kitCache (TTL 30s in kit.js PERF-01) BEFORE
|
|
70
|
+
// re-sync — otherwise listKit() inside syncTo can return the pre-edit
|
|
71
|
+
// cached value if the burst happened within the TTL window. Coalescing
|
|
72
|
+
// the edit-burst via debounce means clearKitCache fires AT MOST ONCE
|
|
73
|
+
// per 500ms window, regardless of how many save events came in.
|
|
74
|
+
clearKitCache();
|
|
49
75
|
for (const t of targets) {
|
|
50
76
|
try {
|
|
51
77
|
const r = await syncTo(t, { projectRoot, kitRoot, mode });
|