sparda-mcp 0.5.4 → 0.7.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/README.md +4 -1
- package/package.json +1 -1
- package/src/commands/doctor.js +42 -1
- package/src/commands/init.js +42 -13
- package/src/commands/negentropy.js +234 -0
- package/src/commands/remove.js +17 -0
- package/src/commands/seed.js +282 -0
- package/src/commands/sync.js +29 -16
- package/src/detect.js +41 -1
- package/src/generator/express.js +2 -1
- package/src/generator/nextjs.js +113 -0
- package/src/index.js +12 -1
- package/src/parser/nextjs.js +248 -0
- package/src/server/stdio.js +1 -1
- package/templates/nextjs-router.txt +383 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// commands/seed.js — the genome (R4.5 lite): everything the organism LEARNED,
|
|
2
|
+
// distilled into a file that can regerminate elsewhere (dev → prod, or a
|
|
3
|
+
// community seed for a popular stack) without re-paying the learning.
|
|
4
|
+
//
|
|
5
|
+
// The closed circle: app → usage → seed → next app. Structure and lessons
|
|
6
|
+
// only — never a value, never a secret, never a security decision:
|
|
7
|
+
// EXPORTED : semantic descriptions/workflows, immune antibodies, failure
|
|
8
|
+
// lessons, Labs circuit structure.
|
|
9
|
+
// NEVER : localKey, port, policies, per-tool `enabled`, events, paths.
|
|
10
|
+
// An imported seed is UNTRUSTED input: every text field is re-sanitized, the
|
|
11
|
+
// security fields are stripped even if present, and nothing it contains can
|
|
12
|
+
// enable a write or change a policy (hard rule #3 is not negotiable by file).
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { sanitizeDescription } from '../security/sanitize.js';
|
|
16
|
+
import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
|
|
17
|
+
|
|
18
|
+
const SEED_VERSION = 'sparda-seed/v1';
|
|
19
|
+
const MAX_ANTIBODIES = 50; // same cap as the bridge
|
|
20
|
+
const MAX_WORKFLOWS = 20;
|
|
21
|
+
const MAX_CIRCUITS = 30; // same cap as the condenser
|
|
22
|
+
const MAX_FAILURES = 50;
|
|
23
|
+
|
|
24
|
+
const clean = (raw, fallback = '') => sanitizeDescription(raw, fallback).text;
|
|
25
|
+
|
|
26
|
+
// ── pure: manifest → seed ──────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
export function buildSeed(manifest) {
|
|
29
|
+
const seed = {
|
|
30
|
+
version: SEED_VERSION,
|
|
31
|
+
framework: manifest.framework ?? 'unknown',
|
|
32
|
+
exportedAt: new Date().toISOString(),
|
|
33
|
+
semantic: { descriptions: {}, workflows: [] },
|
|
34
|
+
antibodies: {},
|
|
35
|
+
failures: {},
|
|
36
|
+
circuits: {},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
for (const [tool, desc] of Object.entries(manifest.semantic?.descriptions ?? {})) {
|
|
40
|
+
seed.semantic.descriptions[tool] = clean(desc);
|
|
41
|
+
}
|
|
42
|
+
for (const wf of (manifest.semantic?.workflows ?? []).slice(0, MAX_WORKFLOWS)) {
|
|
43
|
+
seed.semantic.workflows.push({
|
|
44
|
+
name: clean(wf.name, 'workflow'),
|
|
45
|
+
description: clean(wf.description),
|
|
46
|
+
steps: Array.isArray(wf.steps) ? wf.steps.map((s) => clean(s)).slice(0, 20) : [],
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
for (const [sig, a] of Object.entries(manifest.immune?.antibodies ?? {}).slice(
|
|
50
|
+
0,
|
|
51
|
+
MAX_ANTIBODIES,
|
|
52
|
+
)) {
|
|
53
|
+
seed.antibodies[sig] = { diagnosis: clean(a.diagnosis), hits: Number(a.hits) || 0 };
|
|
54
|
+
}
|
|
55
|
+
for (const [sig, f] of Object.entries(manifest.sparding?.failures ?? {}).slice(
|
|
56
|
+
0,
|
|
57
|
+
MAX_FAILURES,
|
|
58
|
+
)) {
|
|
59
|
+
seed.failures[sig] = { count: Number(f.count) || 0, lesson: clean(f.lesson) };
|
|
60
|
+
}
|
|
61
|
+
for (const [key, cir] of Object.entries(manifest.labs?.circuits ?? {}).slice(
|
|
62
|
+
0,
|
|
63
|
+
MAX_CIRCUITS,
|
|
64
|
+
)) {
|
|
65
|
+
// structure only, exactly what the condenser persists — never values
|
|
66
|
+
seed.circuits[key] = {
|
|
67
|
+
steps: Array.isArray(cir.steps) ? cir.steps : [],
|
|
68
|
+
links: Array.isArray(cir.links) ? cir.links : [],
|
|
69
|
+
seen: Number(cir.seen) || 0,
|
|
70
|
+
...(cir.composite
|
|
71
|
+
? {
|
|
72
|
+
composite: {
|
|
73
|
+
name: clean(cir.composite.name, 'composite'),
|
|
74
|
+
description: clean(cir.composite.description),
|
|
75
|
+
source: 'seed',
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
: {}),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return seed;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── pure: (manifest, seed) → merged manifest — local knowledge wins ────────
|
|
85
|
+
|
|
86
|
+
export function mergeSeed(manifest, seed) {
|
|
87
|
+
if (!seed || seed.version !== SEED_VERSION) {
|
|
88
|
+
throw Object.assign(new Error('not a SPARDA seed (missing/unknown version field).'), {
|
|
89
|
+
code: 'USER',
|
|
90
|
+
hint: `expected "version": "${SEED_VERSION}" — re-export with npx sparda-mcp seed export`,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
const m = structuredClone(manifest);
|
|
94
|
+
const toolNames = new Set(Object.keys(m.tools ?? {}));
|
|
95
|
+
const sigToolExists = (sig) => {
|
|
96
|
+
// signatures are `source|tool|status` — a lesson about a tool this app
|
|
97
|
+
// does not have is noise, not knowledge
|
|
98
|
+
const tool = String(sig).split('|')[1];
|
|
99
|
+
return tool === 'null' || tool === 'undefined' || toolNames.has(tool);
|
|
100
|
+
};
|
|
101
|
+
const report = {
|
|
102
|
+
descriptions: 0,
|
|
103
|
+
workflows: 0,
|
|
104
|
+
antibodies: 0,
|
|
105
|
+
failures: 0,
|
|
106
|
+
circuits: 0,
|
|
107
|
+
skipped: 0,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
m.semantic ??= { descriptions: {}, workflows: [] };
|
|
111
|
+
m.semantic.descriptions ??= {};
|
|
112
|
+
m.semantic.workflows ??= [];
|
|
113
|
+
for (const [tool, desc] of Object.entries(seed.semantic?.descriptions ?? {})) {
|
|
114
|
+
if (!toolNames.has(tool)) {
|
|
115
|
+
report.skipped++;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (m.semantic.descriptions[tool]) continue; // local knowledge wins
|
|
119
|
+
m.semantic.descriptions[tool] = clean(desc);
|
|
120
|
+
report.descriptions++;
|
|
121
|
+
}
|
|
122
|
+
const wfNames = new Set(m.semantic.workflows.map((w) => w.name));
|
|
123
|
+
for (const wf of (seed.semantic?.workflows ?? []).slice(0, MAX_WORKFLOWS)) {
|
|
124
|
+
const name = clean(wf.name, 'workflow');
|
|
125
|
+
if (wfNames.has(name) || m.semantic.workflows.length >= MAX_WORKFLOWS) continue;
|
|
126
|
+
wfNames.add(name);
|
|
127
|
+
m.semantic.workflows.push({
|
|
128
|
+
name,
|
|
129
|
+
description: clean(wf.description),
|
|
130
|
+
steps: Array.isArray(wf.steps) ? wf.steps.map((s) => clean(s)).slice(0, 20) : [],
|
|
131
|
+
source: 'seed',
|
|
132
|
+
});
|
|
133
|
+
report.workflows++;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
m.immune ??= { antibodies: {} };
|
|
137
|
+
m.immune.antibodies ??= {};
|
|
138
|
+
for (const [sig, a] of Object.entries(seed.antibodies ?? {})) {
|
|
139
|
+
if (!sigToolExists(sig)) {
|
|
140
|
+
report.skipped++;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (
|
|
144
|
+
Object.keys(m.immune.antibodies).length >= MAX_ANTIBODIES &&
|
|
145
|
+
!m.immune.antibodies[sig]
|
|
146
|
+
)
|
|
147
|
+
continue;
|
|
148
|
+
const existing = m.immune.antibodies[sig];
|
|
149
|
+
if (existing) {
|
|
150
|
+
existing.hits = Math.max(Number(existing.hits) || 0, Number(a.hits) || 0);
|
|
151
|
+
} else {
|
|
152
|
+
m.immune.antibodies[sig] = {
|
|
153
|
+
diagnosis: clean(a.diagnosis),
|
|
154
|
+
hits: Number(a.hits) || 0,
|
|
155
|
+
firstSeen: new Date().toISOString(),
|
|
156
|
+
lastSeen: new Date().toISOString(),
|
|
157
|
+
source: 'seed',
|
|
158
|
+
};
|
|
159
|
+
report.antibodies++;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
m.sparding ??= {};
|
|
164
|
+
m.sparding.failures ??= {};
|
|
165
|
+
for (const [sig, f] of Object.entries(seed.failures ?? {})) {
|
|
166
|
+
if (!sigToolExists(sig)) {
|
|
167
|
+
report.skipped++;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const existing = m.sparding.failures[sig];
|
|
171
|
+
if (existing) {
|
|
172
|
+
existing.count = Math.max(Number(existing.count) || 0, Number(f.count) || 0);
|
|
173
|
+
} else if (Object.keys(m.sparding.failures).length < MAX_FAILURES) {
|
|
174
|
+
m.sparding.failures[sig] = { count: Number(f.count) || 0, lesson: clean(f.lesson) };
|
|
175
|
+
report.failures++;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
m.labs ??= {};
|
|
180
|
+
m.labs.circuits ??= {};
|
|
181
|
+
for (const [key, cir] of Object.entries(seed.circuits ?? {})) {
|
|
182
|
+
if (m.labs.circuits[key]) continue; // local observation wins
|
|
183
|
+
const stepTools = (Array.isArray(cir.steps) ? cir.steps : [])
|
|
184
|
+
.map((s) => (typeof s === 'string' ? s : s?.tool))
|
|
185
|
+
.filter(Boolean);
|
|
186
|
+
if (!stepTools.every((t) => toolNames.has(t))) {
|
|
187
|
+
report.skipped++;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (Object.keys(m.labs.circuits).length >= MAX_CIRCUITS) continue;
|
|
191
|
+
m.labs.circuits[key] = {
|
|
192
|
+
steps: cir.steps,
|
|
193
|
+
links: Array.isArray(cir.links) ? cir.links : [],
|
|
194
|
+
seen: Number(cir.seen) || 0,
|
|
195
|
+
firstSeen: new Date().toISOString(),
|
|
196
|
+
lastSeen: new Date().toISOString(),
|
|
197
|
+
...(cir.composite
|
|
198
|
+
? {
|
|
199
|
+
composite: {
|
|
200
|
+
name: clean(cir.composite.name, 'composite'),
|
|
201
|
+
description: clean(cir.composite.description),
|
|
202
|
+
source: 'seed',
|
|
203
|
+
createdAt: new Date().toISOString(),
|
|
204
|
+
},
|
|
205
|
+
}
|
|
206
|
+
: {}),
|
|
207
|
+
};
|
|
208
|
+
report.circuits++;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// the file may claim anything — the organism's security is not up for
|
|
212
|
+
// negotiation: whatever a seed carries, these never cross (hard rule #3/#5)
|
|
213
|
+
// (localKey, port, policies and per-tool `enabled` were simply never read.)
|
|
214
|
+
return { manifest: m, report };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── the command: seed export [--out f] | seed import <file> ───────────────
|
|
218
|
+
|
|
219
|
+
export async function runSeed(opts, args) {
|
|
220
|
+
const sub = args[0];
|
|
221
|
+
const manifestPath = path.join(opts.cwd, 'sparda.json');
|
|
222
|
+
if (!fs.existsSync(manifestPath)) {
|
|
223
|
+
throw Object.assign(new Error('sparda.json not found.'), {
|
|
224
|
+
code: 'USER',
|
|
225
|
+
hint: 'run `npx sparda-mcp init` first',
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
let manifest;
|
|
229
|
+
try {
|
|
230
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
231
|
+
} catch {
|
|
232
|
+
throw Object.assign(new Error('sparda.json is not valid JSON.'), {
|
|
233
|
+
code: 'USER',
|
|
234
|
+
hint: 'restore it from git or re-run `npx sparda-mcp init`',
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (sub === 'export') {
|
|
239
|
+
const seed = buildSeed(manifest);
|
|
240
|
+
const out = opts.out ?? 'sparda-seed.json';
|
|
241
|
+
atomicWrite(path.resolve(opts.cwd, out), JSON.stringify(seed, null, 2) + '\n');
|
|
242
|
+
console.log(
|
|
243
|
+
`✓ Seed exported: ${out} — ${Object.keys(seed.antibodies).length} antibodies, ${Object.keys(seed.semantic.descriptions).length} descriptions, ${seed.semantic.workflows.length} workflows, ${Object.keys(seed.circuits).length} circuits, ${Object.keys(seed.failures).length} lessons`,
|
|
244
|
+
);
|
|
245
|
+
console.log(' Structure and lessons only. No key, no policy, no value ever leaves.');
|
|
246
|
+
return { seed };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (sub === 'import') {
|
|
250
|
+
const file = args[1];
|
|
251
|
+
if (!file) {
|
|
252
|
+
throw Object.assign(new Error('missing seed file.'), {
|
|
253
|
+
code: 'USER',
|
|
254
|
+
hint: 'usage: npx sparda-mcp seed import <sparda-seed.json>',
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
const abs = path.resolve(opts.cwd, file);
|
|
258
|
+
if (!fs.existsSync(abs)) {
|
|
259
|
+
throw Object.assign(new Error(`seed file not found: ${file}`), { code: 'USER' });
|
|
260
|
+
}
|
|
261
|
+
let seed;
|
|
262
|
+
try {
|
|
263
|
+
seed = JSON.parse(fs.readFileSync(abs, 'utf8'));
|
|
264
|
+
} catch {
|
|
265
|
+
throw Object.assign(new Error('seed file is not valid JSON.'), { code: 'USER' });
|
|
266
|
+
}
|
|
267
|
+
const { manifest: merged, report } = mergeSeed(manifest, seed);
|
|
268
|
+
atomicWrite(manifestPath, JSON.stringify(merged, null, 2) + '\n');
|
|
269
|
+
console.log(
|
|
270
|
+
`✓ Seed germinated into sparda.json — +${report.antibodies} antibodies, +${report.descriptions} descriptions, +${report.workflows} workflows, +${report.circuits} circuits, +${report.failures} lessons (${report.skipped} entries skipped: unknown tools here)`,
|
|
271
|
+
);
|
|
272
|
+
console.log(
|
|
273
|
+
' Local knowledge always wins; keys, policies and enabled flags were never read.',
|
|
274
|
+
);
|
|
275
|
+
return { report };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
throw Object.assign(new Error(`unknown seed subcommand: ${sub ?? '(none)'}`), {
|
|
279
|
+
code: 'USER',
|
|
280
|
+
hint: 'usage: npx sparda-mcp seed export [--out file] | seed import <file>',
|
|
281
|
+
});
|
|
282
|
+
}
|
package/src/commands/sync.js
CHANGED
|
@@ -4,9 +4,11 @@ import path from 'node:path';
|
|
|
4
4
|
import { detectStack } from '../detect.js';
|
|
5
5
|
import { parseExpressProject } from '../parser/express.js';
|
|
6
6
|
import { parseFastAPIProject } from '../parser/fastapi.js';
|
|
7
|
+
import { parseNextProject } from '../parser/nextjs.js';
|
|
7
8
|
import { sanitizeDescription } from '../security/sanitize.js';
|
|
8
9
|
import { generateExpress } from '../generator/express.js';
|
|
9
10
|
import { generateFastAPI } from '../generator/fastapi.js';
|
|
11
|
+
import { generateNext } from '../generator/nextjs.js';
|
|
10
12
|
|
|
11
13
|
export async function runSync(opts) {
|
|
12
14
|
const log = (m) => {
|
|
@@ -25,6 +27,8 @@ export async function runSync(opts) {
|
|
|
25
27
|
let routes, entryAppVars;
|
|
26
28
|
if (stack.framework === 'express') {
|
|
27
29
|
({ routes } = parseExpressProject(opts.cwd, stack.entryFile));
|
|
30
|
+
} else if (stack.framework === 'nextjs') {
|
|
31
|
+
({ routes } = parseNextProject(opts.cwd, stack.entryFile));
|
|
28
32
|
} else {
|
|
29
33
|
({ routes, entryAppVars } = parseFastAPIProject(
|
|
30
34
|
opts.cwd,
|
|
@@ -52,22 +56,31 @@ export async function runSync(opts) {
|
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
// regenerate; carry-over keeps enabled overrides, semantic cache and localKey
|
|
55
|
-
stack.framework === 'express'
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
59
|
+
if (stack.framework === 'express') {
|
|
60
|
+
generateExpress({
|
|
61
|
+
cwd: opts.cwd,
|
|
62
|
+
entryFile: stack.entryFile,
|
|
63
|
+
moduleType: stack.moduleType,
|
|
64
|
+
port: manifest.port ?? stack.port,
|
|
65
|
+
routes,
|
|
66
|
+
});
|
|
67
|
+
} else if (stack.framework === 'nextjs') {
|
|
68
|
+
generateNext({
|
|
69
|
+
cwd: opts.cwd,
|
|
70
|
+
appDir: stack.entryFile,
|
|
71
|
+
port: manifest.port ?? stack.port,
|
|
72
|
+
routes,
|
|
73
|
+
});
|
|
74
|
+
} else {
|
|
75
|
+
generateFastAPI({
|
|
76
|
+
cwd: opts.cwd,
|
|
77
|
+
entryFile: stack.entryFile,
|
|
78
|
+
port: manifest.port ?? stack.port,
|
|
79
|
+
routes,
|
|
80
|
+
entryAppVars,
|
|
81
|
+
pythonCmd: stack.pythonCmd,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
71
84
|
|
|
72
85
|
for (const a of added) log(`[sparda] + ${a}`);
|
|
73
86
|
for (const r of removed) log(`[sparda] - ${r}`);
|
package/src/detect.js
CHANGED
|
@@ -10,6 +10,25 @@ export function detectStack(cwd) {
|
|
|
10
10
|
if (fs.existsSync(pkgPath)) {
|
|
11
11
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
12
12
|
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
13
|
+
// next before express: a project with the next dep IS a Next app (express is
|
|
14
|
+
// occasionally present as a utility dep and would misroute detection)
|
|
15
|
+
if (deps.next) {
|
|
16
|
+
const appDir = ['app', 'src/app'].find((d) => {
|
|
17
|
+
const abs = path.join(cwd, d);
|
|
18
|
+
return fs.existsSync(abs) && fs.statSync(abs).isDirectory();
|
|
19
|
+
});
|
|
20
|
+
if (!appDir)
|
|
21
|
+
throw err(
|
|
22
|
+
'Next.js detected but no App Router directory (app/ or src/app/).',
|
|
23
|
+
'SPARDA supports the App Router only (route.js handlers) — the Pages Router is not supported.',
|
|
24
|
+
);
|
|
25
|
+
return {
|
|
26
|
+
framework: 'nextjs',
|
|
27
|
+
entryFile: appDir,
|
|
28
|
+
port: detectNextPort(cwd, pkg),
|
|
29
|
+
nextVersion: deps.next,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
13
32
|
if (deps.express) {
|
|
14
33
|
const entryFile = findExpressEntry(cwd, pkg);
|
|
15
34
|
const moduleType = detectModuleType(cwd, pkg, entryFile);
|
|
@@ -22,7 +41,7 @@ export function detectStack(cwd) {
|
|
|
22
41
|
expressVersion: deps.express,
|
|
23
42
|
};
|
|
24
43
|
}
|
|
25
|
-
const known = ['@nestjs/core', '
|
|
44
|
+
const known = ['@nestjs/core', 'fastify', 'koa'].find((d) => deps[d]);
|
|
26
45
|
if (known)
|
|
27
46
|
throw err(
|
|
28
47
|
`${known} detected — not supported yet. Express & FastAPI only in v0.`,
|
|
@@ -47,6 +66,27 @@ export function detectStack(cwd) {
|
|
|
47
66
|
);
|
|
48
67
|
}
|
|
49
68
|
|
|
69
|
+
// `next dev -p 3001` / `--port 3001` in scripts, then PORT= in .env, then 3000
|
|
70
|
+
function detectNextPort(cwd, pkg) {
|
|
71
|
+
for (const script of [pkg.scripts?.dev, pkg.scripts?.start]) {
|
|
72
|
+
if (!script) continue;
|
|
73
|
+
const m = script.match(/(?:-p|--port)[\s=]+(\d{2,5})/);
|
|
74
|
+
if (m) return Number(m[1]);
|
|
75
|
+
}
|
|
76
|
+
const envPath = path.join(cwd, '.env');
|
|
77
|
+
if (fs.existsSync(envPath)) {
|
|
78
|
+
const line = fs
|
|
79
|
+
.readFileSync(envPath, 'utf8')
|
|
80
|
+
.split(/\r?\n/)
|
|
81
|
+
.find((l) => l.startsWith('PORT='));
|
|
82
|
+
if (line) {
|
|
83
|
+
const v = Number(line.split('=')[1].trim());
|
|
84
|
+
if (v) return v;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return 3000;
|
|
88
|
+
}
|
|
89
|
+
|
|
50
90
|
function detectPython() {
|
|
51
91
|
const cmds = ['python3', 'python', 'py'];
|
|
52
92
|
for (const cmd of cmds) {
|
package/src/generator/express.js
CHANGED
|
@@ -283,7 +283,8 @@ export function removeInjection(cwd, manifest) {
|
|
|
283
283
|
|
|
284
284
|
// Returns what was done ('created' | 'appended' | null) so the manifest can
|
|
285
285
|
// record it and `remove` can revert the exact edit (byte-for-byte promise).
|
|
286
|
-
|
|
286
|
+
// Exported: the Next.js generator shares the exact same contract.
|
|
287
|
+
export function ensureGitignore(cwd) {
|
|
287
288
|
const gi = path.join(cwd, '.gitignore');
|
|
288
289
|
const line = '.sparda/';
|
|
289
290
|
if (fs.existsSync(gi)) {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// generator/nextjs.js — file-based injection for the App Router.
|
|
2
|
+
// Next.js has no `app = express()` line to inject after: the filesystem is the
|
|
3
|
+
// router, so SPARDA's injection IS a generated catch-all route handler at
|
|
4
|
+
// <appDir>/mcp/[...sparda]/route.js. Nothing in the user's code is touched —
|
|
5
|
+
// `remove` deletes the file and the diff is clean by construction.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { toolNameFor } from '../parser/express.js';
|
|
11
|
+
import { ensureGitignore } from './express.js';
|
|
12
|
+
import { carryOverManifest, defaultSpardingMemory } from './manifest.js';
|
|
13
|
+
import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
export function generateNext({ cwd, appDir, port, routes }) {
|
|
18
|
+
const taken = new Set([
|
|
19
|
+
'sparda_info',
|
|
20
|
+
'sparda_list_disabled_tools',
|
|
21
|
+
'sparda_get_context',
|
|
22
|
+
]);
|
|
23
|
+
const tools = {};
|
|
24
|
+
for (const r of routes) {
|
|
25
|
+
const name = toolNameFor(r, taken);
|
|
26
|
+
tools[name] = {
|
|
27
|
+
method: r.method.toUpperCase(),
|
|
28
|
+
path: r.path,
|
|
29
|
+
enabled: !r.mutating, // write-safety: mutating tools off by default
|
|
30
|
+
pathParams: r.params.filter((p) => p.in === 'path').map((p) => p.name),
|
|
31
|
+
description: r.description,
|
|
32
|
+
params: r.params,
|
|
33
|
+
confidence: r.confidence,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const prev = carryOverManifest(cwd, tools);
|
|
37
|
+
|
|
38
|
+
// --- sparding safety memory & fingerprints (same contract as Express/FastAPI)
|
|
39
|
+
const sparding = defaultSpardingMemory(prev);
|
|
40
|
+
const oldFingerprints = sparding.toolFingerprints ?? {};
|
|
41
|
+
const newFingerprints = {};
|
|
42
|
+
for (const [name, t] of Object.entries(tools)) {
|
|
43
|
+
const raw = `${t.method}|${t.path}|${JSON.stringify(t.params)}`;
|
|
44
|
+
const fp = crypto.createHash('sha256').update(raw).digest('hex').slice(0, 8);
|
|
45
|
+
newFingerprints[name] = fp;
|
|
46
|
+
const oldFp = oldFingerprints[name];
|
|
47
|
+
if (oldFp && oldFp !== fp) {
|
|
48
|
+
sparding.events.push({
|
|
49
|
+
ts: new Date().toISOString(),
|
|
50
|
+
tool: name,
|
|
51
|
+
decision: 'audit',
|
|
52
|
+
risk: 'medium',
|
|
53
|
+
reasons: [
|
|
54
|
+
`route structure modified (fingerprint changed from ${oldFp} to ${fp})`,
|
|
55
|
+
],
|
|
56
|
+
});
|
|
57
|
+
if (sparding.events.length > 100) sparding.events.shift();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
sparding.toolFingerprints = newFingerprints;
|
|
61
|
+
|
|
62
|
+
// stable across re-runs so a running bridge/host pair never desyncs
|
|
63
|
+
const localKey = prev?.localKey ?? crypto.randomUUID();
|
|
64
|
+
|
|
65
|
+
// .js on purpose: Next enables allowJs in the tsconfig it manages, so the
|
|
66
|
+
// generated handler compiles untouched inside TS projects too.
|
|
67
|
+
const routerRel = `${appDir}/mcp/[...sparda]/route.js`;
|
|
68
|
+
const routerAbs = path.join(cwd, ...routerRel.split('/'));
|
|
69
|
+
fs.mkdirSync(path.dirname(routerAbs), { recursive: true });
|
|
70
|
+
|
|
71
|
+
let tpl = fs.readFileSync(
|
|
72
|
+
path.join(__dirname, '..', '..', 'templates', 'nextjs-router.txt'),
|
|
73
|
+
'utf8',
|
|
74
|
+
);
|
|
75
|
+
tpl = tpl
|
|
76
|
+
.replace('__TOOLS_JSON__', JSON.stringify(tools, null, 2))
|
|
77
|
+
.replace('__SPARDING_POLICIES__', JSON.stringify(sparding.policies ?? {}))
|
|
78
|
+
.replace('__LOCAL_KEY__', localKey)
|
|
79
|
+
.replace('__PORT__', String(port));
|
|
80
|
+
atomicWrite(routerAbs, tpl);
|
|
81
|
+
|
|
82
|
+
const gitignore = ensureGitignore(cwd) ?? prev?.gitignore ?? null;
|
|
83
|
+
|
|
84
|
+
const manifest = {
|
|
85
|
+
version: 1,
|
|
86
|
+
framework: 'nextjs',
|
|
87
|
+
entryFile: appDir, // the scanned App Router root, not a code entry point
|
|
88
|
+
port,
|
|
89
|
+
localKey,
|
|
90
|
+
generatedFiles: [routerRel],
|
|
91
|
+
injectedFiles: [], // file-based injection: no user file is ever modified
|
|
92
|
+
createdAt: new Date().toISOString(),
|
|
93
|
+
tools: Object.fromEntries(
|
|
94
|
+
Object.entries(tools).map(([k, v]) => [
|
|
95
|
+
k,
|
|
96
|
+
{ method: v.method, path: v.path, enabled: v.enabled },
|
|
97
|
+
]),
|
|
98
|
+
),
|
|
99
|
+
...(gitignore ? { gitignore } : {}),
|
|
100
|
+
...(prev?.semantic ? { semantic: prev.semantic } : {}),
|
|
101
|
+
...(prev?.immune ? { immune: prev.immune } : {}),
|
|
102
|
+
...(prev?.labs ? { labs: prev.labs } : {}),
|
|
103
|
+
sparding,
|
|
104
|
+
};
|
|
105
|
+
atomicWrite(path.join(cwd, 'sparda.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
tools,
|
|
109
|
+
manifest,
|
|
110
|
+
routerFile: routerRel,
|
|
111
|
+
injection: { injected: false, manual: null, fileBased: true },
|
|
112
|
+
};
|
|
113
|
+
}
|
package/src/index.js
CHANGED
|
@@ -20,7 +20,9 @@ const opts = {
|
|
|
20
20
|
probe: flags.has('--probe'),
|
|
21
21
|
html: flags.has('--html'),
|
|
22
22
|
json: flags.has('--json'),
|
|
23
|
+
app: flags.has('--app'),
|
|
23
24
|
port: getOpt('port', null),
|
|
25
|
+
out: getOpt('out', null),
|
|
24
26
|
cwd: process.cwd(),
|
|
25
27
|
};
|
|
26
28
|
|
|
@@ -67,6 +69,14 @@ try {
|
|
|
67
69
|
await runReport(opts);
|
|
68
70
|
break;
|
|
69
71
|
}
|
|
72
|
+
case 'seed': {
|
|
73
|
+
const { runSeed } = await import('./commands/seed.js');
|
|
74
|
+
await runSeed(
|
|
75
|
+
opts,
|
|
76
|
+
rest.filter((a) => !a.startsWith('--')),
|
|
77
|
+
);
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
70
80
|
default:
|
|
71
81
|
console.log(`SPARDA v${VERSION} — Turn any codebase into an MCP server.
|
|
72
82
|
|
|
@@ -77,8 +87,9 @@ Usage:
|
|
|
77
87
|
npx sparda-mcp sync Re-sync the router after route changes (no prompts)
|
|
78
88
|
npx sparda-mcp hook Install the git sentinel (auto-sync after commits)
|
|
79
89
|
npx sparda-mcp remove Remove SPARDA from this project (clean git diff)
|
|
80
|
-
npx sparda-mcp doctor Diagnose your setup
|
|
90
|
+
npx sparda-mcp doctor Diagnose your setup (--app: negentropy scan — drift, dead routes, rot)
|
|
81
91
|
npx sparda-mcp report The black box: what AI agents did to this app
|
|
92
|
+
npx sparda-mcp seed Export/import the learned genome (export [--out f] | import <f>)
|
|
82
93
|
|
|
83
94
|
Flags: --yes (skip prompts) --port <n> --quiet --verbose
|
|
84
95
|
--probe (init: also run the app to discover dynamic routes the AST missed)
|