@node-boost/node-boost 0.1.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/CHANGELOG.md +13 -0
- package/LICENSE.md +21 -0
- package/README.md +196 -0
- package/dist/cli.js +3000 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +380 -0
- package/dist/index.js +2708 -0
- package/dist/index.js.map +1 -0
- package/package.json +82 -0
- package/resources/react/architectures/component-composition/guideline.md +42 -0
- package/resources/react/architectures/component-composition/skill/SKILL.md +19 -0
- package/resources/react/architectures/custom-hooks/guideline.md +38 -0
- package/resources/react/architectures/custom-hooks/skill/SKILL.md +22 -0
- package/resources/react/architectures/data-access-layer/guideline.md +43 -0
- package/resources/react/architectures/data-access-layer/skill/SKILL.md +26 -0
- package/resources/react/architectures/error-loading-boundaries/guideline.md +36 -0
- package/resources/react/architectures/error-loading-boundaries/skill/SKILL.md +18 -0
- package/resources/react/architectures/feature-modules/guideline.md +34 -0
- package/resources/react/architectures/feature-modules/skill/SKILL.md +28 -0
- package/resources/react/architectures/feature-modules/variants/forbid.md +36 -0
- package/resources/react/architectures/feature-modules/variants/public-api.md +37 -0
- package/resources/react/architectures/modern-typescript/guideline.md +47 -0
- package/resources/react/architectures/modern-typescript/skill/SKILL.md +23 -0
- package/resources/react/architectures/secure-by-default/guideline.md +38 -0
- package/resources/react/architectures/secure-by-default/skill/SKILL.md +22 -0
- package/resources/react/architectures/server-first-components/guideline.md +34 -0
- package/resources/react/architectures/server-first-components/skill/SKILL.md +23 -0
- package/resources/react/architectures/state-management/guideline.md +34 -0
- package/resources/react/architectures/state-management/skill/SKILL.md +21 -0
- package/resources/react/architectures/styling-tailwind/guideline.md +33 -0
- package/resources/react/architectures/styling-tailwind/skill/SKILL.md +18 -0
- package/resources/react/architectures/testing-strategy/guideline.md +33 -0
- package/resources/react/architectures/testing-strategy/skill/SKILL.md +19 -0
- package/resources/react/architectures/typed-contracts/guideline.md +31 -0
- package/resources/react/architectures/typed-contracts/skill/SKILL.md +23 -0
- package/resources/react/architectures/ui-states/guideline.md +45 -0
- package/resources/react/architectures/ui-states/skill/SKILL.md +19 -0
- package/resources/react/guidelines/core.md +15 -0
- package/resources/react/guidelines/next/15.md +9 -0
- package/resources/react/guidelines/next/16.md +18 -0
- package/resources/react/guidelines/next/core.md +8 -0
- package/resources/react/guidelines/react/18.md +8 -0
- package/resources/react/guidelines/react/19.md +10 -0
- package/resources/react/guidelines/react/core.md +9 -0
- package/resources/react/guidelines/react-query/core.md +8 -0
- package/resources/react/guidelines/react-router/7.md +9 -0
- package/resources/react/guidelines/react-router/core.md +7 -0
- package/resources/react/guidelines/tailwindcss/3.md +8 -0
- package/resources/react/guidelines/tailwindcss/4.md +9 -0
- package/resources/react/guidelines/tailwindcss/core.md +8 -0
- package/resources/react/guidelines/testing/playwright.md +8 -0
- package/resources/react/guidelines/testing/vitest.md +8 -0
- package/resources/react/guidelines/typescript/core.md +7 -0
- package/resources/react/guidelines/vite/core.md +8 -0
- package/resources/react/guidelines/zod/3.md +8 -0
- package/resources/react/guidelines/zod/4.md +10 -0
- package/resources/react/guidelines/zod/core.md +7 -0
- package/resources/react/guidelines/zustand/core.md +8 -0
- package/resources/react/skills/next-development/SKILL.md +19 -0
- package/resources/react/skills/react-development/SKILL.md +18 -0
- package/resources/react/skills/spa-routing/SKILL.md +18 -0
- package/resources/react/skills/tailwindcss-development/SKILL.md +17 -0
- package/resources/react/skills/testing-frontend/SKILL.md +18 -0
- package/schema.json +216 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,3000 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/index.ts
|
|
4
|
+
import { defineCommand as defineCommand8, runMain } from "citty";
|
|
5
|
+
|
|
6
|
+
// src/cli/commands/audit.ts
|
|
7
|
+
import { defineCommand } from "citty";
|
|
8
|
+
|
|
9
|
+
// src/audit/engine.ts
|
|
10
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
11
|
+
import { join as join8 } from "path";
|
|
12
|
+
import { DiagnosticCategory, Project } from "ts-morph";
|
|
13
|
+
|
|
14
|
+
// src/config/schema.ts
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
var agentNameSchema = z.enum(["claude-code", "codex", "cursor"]);
|
|
17
|
+
var stackNameSchema = z.enum(["next", "vite-react", "react-generic", "unknown"]);
|
|
18
|
+
var architectureSlugSchema = z.enum([
|
|
19
|
+
"feature-modules",
|
|
20
|
+
"server-first-components",
|
|
21
|
+
"data-access-layer",
|
|
22
|
+
"typed-contracts",
|
|
23
|
+
"state-management",
|
|
24
|
+
"custom-hooks",
|
|
25
|
+
"component-composition",
|
|
26
|
+
"styling-tailwind",
|
|
27
|
+
"testing-strategy",
|
|
28
|
+
"error-loading-boundaries",
|
|
29
|
+
"secure-by-default",
|
|
30
|
+
"modern-typescript",
|
|
31
|
+
"ui-states"
|
|
32
|
+
]);
|
|
33
|
+
var auditSeveritySchema = z.enum(["off", "warn", "err"]);
|
|
34
|
+
var featureModulesBoundarySchema = z.enum(["public-api", "forbid"]);
|
|
35
|
+
var nonFeatureModuleArchitectureSlugSchema = z.enum([
|
|
36
|
+
"server-first-components",
|
|
37
|
+
"data-access-layer",
|
|
38
|
+
"typed-contracts",
|
|
39
|
+
"state-management",
|
|
40
|
+
"custom-hooks",
|
|
41
|
+
"component-composition",
|
|
42
|
+
"styling-tailwind",
|
|
43
|
+
"testing-strategy",
|
|
44
|
+
"error-loading-boundaries",
|
|
45
|
+
"secure-by-default",
|
|
46
|
+
"modern-typescript",
|
|
47
|
+
"ui-states"
|
|
48
|
+
]);
|
|
49
|
+
var architectureEntrySchema = z.union([
|
|
50
|
+
architectureSlugSchema,
|
|
51
|
+
z.strictObject({
|
|
52
|
+
name: z.literal("feature-modules"),
|
|
53
|
+
boundary: featureModulesBoundarySchema.default("public-api")
|
|
54
|
+
}),
|
|
55
|
+
z.strictObject({
|
|
56
|
+
name: nonFeatureModuleArchitectureSlugSchema
|
|
57
|
+
})
|
|
58
|
+
]);
|
|
59
|
+
var defaultFeatures = {
|
|
60
|
+
guidelines: true,
|
|
61
|
+
skills: true,
|
|
62
|
+
mcp: true,
|
|
63
|
+
architecture: true,
|
|
64
|
+
hooks: false
|
|
65
|
+
};
|
|
66
|
+
var defaultAudit = {
|
|
67
|
+
exclude: [],
|
|
68
|
+
rules: {},
|
|
69
|
+
ruleOptions: {}
|
|
70
|
+
};
|
|
71
|
+
var featuresSchema = z.object({
|
|
72
|
+
guidelines: z.boolean().default(true),
|
|
73
|
+
skills: z.boolean().default(true),
|
|
74
|
+
mcp: z.boolean().default(true),
|
|
75
|
+
architecture: z.boolean().default(true),
|
|
76
|
+
hooks: z.boolean().default(false)
|
|
77
|
+
});
|
|
78
|
+
var auditSchema = z.object({
|
|
79
|
+
exclude: z.array(z.string()).default([]),
|
|
80
|
+
rules: z.record(z.string(), auditSeveritySchema).default({}),
|
|
81
|
+
ruleOptions: z.record(z.string(), z.record(z.string(), z.unknown())).default({})
|
|
82
|
+
}).default(defaultAudit);
|
|
83
|
+
var nodeBoostConfigSchema = z.object({
|
|
84
|
+
$schema: z.string().optional(),
|
|
85
|
+
version: z.literal(1),
|
|
86
|
+
generatedWith: z.string().min(1),
|
|
87
|
+
stack: stackNameSchema,
|
|
88
|
+
agents: z.array(agentNameSchema).default([]),
|
|
89
|
+
features: featuresSchema.default(defaultFeatures),
|
|
90
|
+
architectures: z.array(architectureEntrySchema).default([]),
|
|
91
|
+
audit: auditSchema
|
|
92
|
+
});
|
|
93
|
+
function parseNodeBoostConfig(input) {
|
|
94
|
+
return nodeBoostConfigSchema.parse(input);
|
|
95
|
+
}
|
|
96
|
+
function normalizeArchitectures(config) {
|
|
97
|
+
return config.architectures.map((architecture) => {
|
|
98
|
+
if (typeof architecture === "string") {
|
|
99
|
+
return {
|
|
100
|
+
name: architecture,
|
|
101
|
+
options: architecture === "feature-modules" ? { boundary: "public-api" } : {}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (architecture.name === "feature-modules") {
|
|
105
|
+
return {
|
|
106
|
+
name: architecture.name,
|
|
107
|
+
options: { boundary: architecture.boundary }
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
name: architecture.name,
|
|
112
|
+
options: {}
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/detect/stack.ts
|
|
118
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
119
|
+
import { join as join3 } from "path";
|
|
120
|
+
|
|
121
|
+
// src/detect/package-manager.ts
|
|
122
|
+
import { access, readFile } from "fs/promises";
|
|
123
|
+
import { join } from "path";
|
|
124
|
+
var lockfiles = [
|
|
125
|
+
{ name: "pnpm", file: "pnpm-lock.yaml" },
|
|
126
|
+
{ name: "yarn", file: "yarn.lock" },
|
|
127
|
+
{ name: "bun", file: "bun.lockb" },
|
|
128
|
+
{ name: "bun", file: "bun.lock" },
|
|
129
|
+
{ name: "npm", file: "package-lock.json" }
|
|
130
|
+
];
|
|
131
|
+
async function detectPackageManager(rootDir) {
|
|
132
|
+
const packageJson = await readJsonObject(join(rootDir, "package.json"));
|
|
133
|
+
const declaredPackageManager = parsePackageManager(packageJson);
|
|
134
|
+
for (const lockfile of lockfiles) {
|
|
135
|
+
if (await fileExists(join(rootDir, lockfile.file))) {
|
|
136
|
+
return {
|
|
137
|
+
name: lockfile.name,
|
|
138
|
+
lockfile: lockfile.file,
|
|
139
|
+
version: declaredPackageManager?.name === lockfile.name ? declaredPackageManager.version : null,
|
|
140
|
+
source: "lockfile"
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (declaredPackageManager) {
|
|
145
|
+
return { name: declaredPackageManager.name, lockfile: null, version: declaredPackageManager.version, source: "packageManagerField" };
|
|
146
|
+
}
|
|
147
|
+
return { name: "npm", lockfile: null, version: null, source: "default" };
|
|
148
|
+
}
|
|
149
|
+
async function fileExists(path) {
|
|
150
|
+
try {
|
|
151
|
+
await access(path);
|
|
152
|
+
return true;
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async function readJsonObject(path) {
|
|
158
|
+
try {
|
|
159
|
+
const raw = await readFile(path, "utf8");
|
|
160
|
+
const parsed = JSON.parse(raw);
|
|
161
|
+
return isObject(parsed) ? parsed : null;
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function isPackageManagerName(value) {
|
|
167
|
+
return value === "npm" || value === "pnpm" || value === "yarn" || value === "bun";
|
|
168
|
+
}
|
|
169
|
+
function parsePackageManager(packageJson) {
|
|
170
|
+
const packageManager = typeof packageJson?.packageManager === "string" ? packageJson.packageManager : null;
|
|
171
|
+
const [managerName, version = null] = packageManager?.split("@") ?? [];
|
|
172
|
+
if (isPackageManagerName(managerName)) {
|
|
173
|
+
return { name: managerName, version };
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
function isObject(value) {
|
|
178
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// src/detect/router.ts
|
|
182
|
+
import { access as access2 } from "fs/promises";
|
|
183
|
+
import { join as join2 } from "path";
|
|
184
|
+
async function detectNextRouter(rootDir) {
|
|
185
|
+
const hasRootApp = await directoryExists(join2(rootDir, "app"));
|
|
186
|
+
const hasSrcApp = await directoryExists(join2(rootDir, "src", "app"));
|
|
187
|
+
const hasRootPages = await directoryExists(join2(rootDir, "pages"));
|
|
188
|
+
const hasSrcPages = await directoryExists(join2(rootDir, "src", "pages"));
|
|
189
|
+
if (hasRootApp || hasSrcApp) {
|
|
190
|
+
return { router: "app", srcDir: hasSrcApp };
|
|
191
|
+
}
|
|
192
|
+
if (hasRootPages || hasSrcPages) {
|
|
193
|
+
return { router: "pages", srcDir: hasSrcPages };
|
|
194
|
+
}
|
|
195
|
+
return { router: "unknown", srcDir: false };
|
|
196
|
+
}
|
|
197
|
+
async function directoryExists(path) {
|
|
198
|
+
try {
|
|
199
|
+
await access2(path);
|
|
200
|
+
return true;
|
|
201
|
+
} catch {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// src/detect/stack.ts
|
|
207
|
+
var trackedPackages = [
|
|
208
|
+
"next",
|
|
209
|
+
"react",
|
|
210
|
+
"vite",
|
|
211
|
+
"react-router",
|
|
212
|
+
"typescript",
|
|
213
|
+
"tailwindcss",
|
|
214
|
+
"zod",
|
|
215
|
+
"@tanstack/react-query",
|
|
216
|
+
"zustand",
|
|
217
|
+
"vitest",
|
|
218
|
+
"jest",
|
|
219
|
+
"playwright",
|
|
220
|
+
"eslint",
|
|
221
|
+
"prettier",
|
|
222
|
+
"@biomejs/biome"
|
|
223
|
+
];
|
|
224
|
+
async function detectStack(rootDir) {
|
|
225
|
+
const packageJson = await readPackageJson(rootDir);
|
|
226
|
+
const packageManager = await detectPackageManager(rootDir);
|
|
227
|
+
const packages = await detectPackages(rootDir, packageJson);
|
|
228
|
+
const warnings = [];
|
|
229
|
+
const name = detectStackName(packages);
|
|
230
|
+
const nextRouter = name === "next" ? await detectNextRouter(rootDir) : null;
|
|
231
|
+
const router = nextRouter?.router ?? (name === "vite-react" ? "react-router" : "none");
|
|
232
|
+
const srcDir = nextRouter?.srcDir ?? false;
|
|
233
|
+
const linting = detectLinting(packages);
|
|
234
|
+
if (Object.values(packages).some((pkg) => pkg.source === "range")) {
|
|
235
|
+
warnings.push("node_modules not available for at least one package; using declared version range fallback.");
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
rootDir,
|
|
239
|
+
name,
|
|
240
|
+
router,
|
|
241
|
+
srcDir,
|
|
242
|
+
linting,
|
|
243
|
+
packageManager,
|
|
244
|
+
packages,
|
|
245
|
+
warnings
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
function detectLinting(packages) {
|
|
249
|
+
if (packages["@biomejs/biome"]?.version) {
|
|
250
|
+
return "biome";
|
|
251
|
+
}
|
|
252
|
+
if (packages.eslint?.version && packages.prettier?.version) {
|
|
253
|
+
return "eslint-prettier";
|
|
254
|
+
}
|
|
255
|
+
if (packages.eslint?.version) {
|
|
256
|
+
return "eslint";
|
|
257
|
+
}
|
|
258
|
+
return "none";
|
|
259
|
+
}
|
|
260
|
+
async function detectPackages(rootDir, packageJson) {
|
|
261
|
+
const packages = {};
|
|
262
|
+
for (const packageName of trackedPackages) {
|
|
263
|
+
const declaredRange = findDeclaredRange(packageJson, packageName);
|
|
264
|
+
const installedVersion = await readInstalledVersion(rootDir, packageName);
|
|
265
|
+
const version = installedVersion ?? extractVersionFromRange(declaredRange);
|
|
266
|
+
packages[packageName] = {
|
|
267
|
+
name: packageName,
|
|
268
|
+
declaredRange,
|
|
269
|
+
version,
|
|
270
|
+
major: version ? parseMajor(version) : null,
|
|
271
|
+
source: installedVersion ? "node_modules" : version ? "range" : "missing"
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
return packages;
|
|
275
|
+
}
|
|
276
|
+
function detectStackName(packages) {
|
|
277
|
+
if (packages.next?.version) {
|
|
278
|
+
return "next";
|
|
279
|
+
}
|
|
280
|
+
if (packages.react?.version && packages.vite?.version && packages["react-router"]?.version) {
|
|
281
|
+
return "vite-react";
|
|
282
|
+
}
|
|
283
|
+
if (packages.react?.version) {
|
|
284
|
+
return "react-generic";
|
|
285
|
+
}
|
|
286
|
+
return "unknown";
|
|
287
|
+
}
|
|
288
|
+
async function readPackageJson(rootDir) {
|
|
289
|
+
const raw = await readFile2(join3(rootDir, "package.json"), "utf8");
|
|
290
|
+
const parsed = JSON.parse(raw);
|
|
291
|
+
if (!isObject2(parsed)) {
|
|
292
|
+
throw new Error("package.json must contain an object.");
|
|
293
|
+
}
|
|
294
|
+
return parsed;
|
|
295
|
+
}
|
|
296
|
+
function findDeclaredRange(packageJson, packageName) {
|
|
297
|
+
return packageJson.dependencies?.[packageName] ?? packageJson.devDependencies?.[packageName] ?? packageJson.peerDependencies?.[packageName] ?? packageJson.optionalDependencies?.[packageName] ?? null;
|
|
298
|
+
}
|
|
299
|
+
async function readInstalledVersion(rootDir, packageName) {
|
|
300
|
+
try {
|
|
301
|
+
const raw = await readFile2(join3(rootDir, "node_modules", packageName, "package.json"), "utf8");
|
|
302
|
+
const parsed = JSON.parse(raw);
|
|
303
|
+
if (isObject2(parsed) && typeof parsed.version === "string") {
|
|
304
|
+
return parsed.version;
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
} catch {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
function extractVersionFromRange(range) {
|
|
312
|
+
if (!range) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
const match = range.match(/\d+\.\d+\.\d+|\d+\.\d+|\d+/);
|
|
316
|
+
if (!match) {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
const [major = "0", minor = "0", patch = "0"] = match[0].split(".");
|
|
320
|
+
return `${major}.${minor}.${patch}`;
|
|
321
|
+
}
|
|
322
|
+
function parseMajor(version) {
|
|
323
|
+
const major = Number.parseInt(version.split(".")[0] ?? "", 10);
|
|
324
|
+
return Number.isFinite(major) ? major : null;
|
|
325
|
+
}
|
|
326
|
+
function isObject2(value) {
|
|
327
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// src/audit/rules/helpers.ts
|
|
331
|
+
import { existsSync, readFileSync } from "fs";
|
|
332
|
+
import { dirname, join as join4, normalize } from "path";
|
|
333
|
+
import picomatch from "picomatch";
|
|
334
|
+
function hasUseClientDirective(file) {
|
|
335
|
+
return file.lines.some((line) => {
|
|
336
|
+
const trimmed = line.trim();
|
|
337
|
+
if (!trimmed || trimmed.startsWith("//")) {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
return trimmed === '"use client";' || trimmed === '"use client"' || trimmed === "'use client';" || trimmed === "'use client'";
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
function isClientComponent(file, stackName) {
|
|
344
|
+
return stackName === "vite-react" || hasUseClientDirective(file);
|
|
345
|
+
}
|
|
346
|
+
function isDataLayerFile(file, globs) {
|
|
347
|
+
if (file.path.endsWith("/route.ts") || file.path.endsWith("/route.tsx")) {
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
return picomatch(globs)(file.path);
|
|
351
|
+
}
|
|
352
|
+
function dataLayerGlobs(options) {
|
|
353
|
+
const configured = options.dataLayerGlobs;
|
|
354
|
+
return Array.isArray(configured) && configured.every((item) => typeof item === "string") ? configured : ["**/api/**", "**/server/**", "lib/api/**", "src/lib/api/**"];
|
|
355
|
+
}
|
|
356
|
+
function lineOf(content, pattern) {
|
|
357
|
+
const lines = content.split(/\r?\n/);
|
|
358
|
+
const index = lines.findIndex((line) => pattern.test(line));
|
|
359
|
+
return index >= 0 ? index + 1 : 1;
|
|
360
|
+
}
|
|
361
|
+
function finding(file, rule, code, line, ref) {
|
|
362
|
+
return {
|
|
363
|
+
rule,
|
|
364
|
+
sev: "warn",
|
|
365
|
+
file: file.path,
|
|
366
|
+
line,
|
|
367
|
+
code,
|
|
368
|
+
ref
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
function importSpecs(file) {
|
|
372
|
+
const specs = [];
|
|
373
|
+
const patterns = [
|
|
374
|
+
/^\s*import\s+(?:type\s+)?(?:[^'"]+\s+from\s+)?["']([^"']+)["']/,
|
|
375
|
+
/^\s*export\s+[^'"]+\s+from\s+["']([^"']+)["']/
|
|
376
|
+
];
|
|
377
|
+
file.lines.forEach((line, index) => {
|
|
378
|
+
for (const pattern of patterns) {
|
|
379
|
+
const match = line.match(pattern);
|
|
380
|
+
if (match?.[1]) {
|
|
381
|
+
specs.push({ spec: match[1], line: index + 1 });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
return specs;
|
|
386
|
+
}
|
|
387
|
+
function resolveImportPath(file, spec) {
|
|
388
|
+
if (spec.startsWith(".")) {
|
|
389
|
+
return toPosix(normalize(join4(dirname(file.path), spec)));
|
|
390
|
+
}
|
|
391
|
+
return spec.replace(/^@\/?/, "").replace(/^~\/?/, "");
|
|
392
|
+
}
|
|
393
|
+
function isConfigFile(path) {
|
|
394
|
+
return /(^|\/)[^/]*\.config\.[cm]?[jt]sx?$/.test(path);
|
|
395
|
+
}
|
|
396
|
+
function hasGeneratedClientDependency(rootDir) {
|
|
397
|
+
try {
|
|
398
|
+
const packageJson = JSON.parse(existsSync(join4(rootDir, "package.json")) ? readFileSync(join4(rootDir, "package.json"), "utf8") : "{}");
|
|
399
|
+
return Boolean(packageJson.devDependencies?.orval || packageJson.devDependencies?.["openapi-typescript"]);
|
|
400
|
+
} catch {
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
function toPosix(path) {
|
|
405
|
+
return path.replaceAll("\\", "/");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// src/audit/rules/data-access-layer.ts
|
|
409
|
+
var networkPattern = /\b(fetch\s*\(|axios\.|ky\s*\(|ky\.)/;
|
|
410
|
+
var dataAccessLayerRules = [
|
|
411
|
+
{
|
|
412
|
+
id: "NB-ARCH-005",
|
|
413
|
+
code: "fetch-in-client-component",
|
|
414
|
+
architecture: "data-access-layer",
|
|
415
|
+
defaultSeverity: "err",
|
|
416
|
+
stacks: ["next", "vite-react"],
|
|
417
|
+
kind: "line",
|
|
418
|
+
check(context) {
|
|
419
|
+
const globs = dataLayerGlobs(context.ruleOptions);
|
|
420
|
+
return context.files.flatMap((file) => {
|
|
421
|
+
if (!isClientComponent(file, context.stack.name) || isDataLayerFile(file, globs) || isQueryHook(file)) {
|
|
422
|
+
return [];
|
|
423
|
+
}
|
|
424
|
+
return networkFindings(file, "NB-ARCH-005", "fetch-in-client-component");
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
id: "NB-ARCH-006",
|
|
430
|
+
code: "raw-fetch-in-rsc",
|
|
431
|
+
architecture: "data-access-layer",
|
|
432
|
+
defaultSeverity: "warn",
|
|
433
|
+
stacks: ["next"],
|
|
434
|
+
kind: "line",
|
|
435
|
+
check(context) {
|
|
436
|
+
const globs = dataLayerGlobs(context.ruleOptions);
|
|
437
|
+
return context.files.flatMap((file) => {
|
|
438
|
+
if (isClientComponent(file, context.stack.name) || isDataLayerFile(file, globs)) {
|
|
439
|
+
return [];
|
|
440
|
+
}
|
|
441
|
+
return networkFindings(file, "NB-ARCH-006", "raw-fetch-in-rsc").filter((item) => item.code === "raw-fetch-in-rsc");
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
];
|
|
446
|
+
function networkFindings(file, rule, code) {
|
|
447
|
+
return file.lines.flatMap((line, index) => networkPattern.test(line) ? [finding(file, rule, code, index + 1)] : []);
|
|
448
|
+
}
|
|
449
|
+
function isQueryHook(file) {
|
|
450
|
+
return /(^|\/)use[A-Z][^/]*\.tsx?$/.test(file.path) && /\buse(Query|Mutation)\s*\(/.test(file.content);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// src/audit/rules/error-loading-boundaries.ts
|
|
454
|
+
import { dirname as dirname2 } from "path";
|
|
455
|
+
var errorLoadingBoundaryRules = [
|
|
456
|
+
{
|
|
457
|
+
id: "NB-ARCH-010",
|
|
458
|
+
code: "segment-without-boundaries",
|
|
459
|
+
architecture: "error-loading-boundaries",
|
|
460
|
+
defaultSeverity: "warn",
|
|
461
|
+
stacks: ["next"],
|
|
462
|
+
kind: "project",
|
|
463
|
+
check(context) {
|
|
464
|
+
return context.files.flatMap((file) => {
|
|
465
|
+
if (!/(^|\/)app\/.+\/page\.tsx?$/.test(file.path) || !/\b(async|await)\b/.test(file.content)) {
|
|
466
|
+
return [];
|
|
467
|
+
}
|
|
468
|
+
return hasBoundaryInBranch(file.path, context.allPaths) ? [] : [finding(file, "NB-ARCH-010", "segment-without-boundaries", 1)];
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
];
|
|
473
|
+
function hasBoundaryInBranch(pagePath, allPaths) {
|
|
474
|
+
let current = dirname2(pagePath);
|
|
475
|
+
while (current.includes("/app/") || current.endsWith("/app") || current === "app" || current === "src/app") {
|
|
476
|
+
for (const name of ["loading.tsx", "loading.ts", "loading.jsx", "loading.js", "error.tsx", "error.ts", "error.jsx", "error.js"]) {
|
|
477
|
+
if (allPaths.has(`${current}/${name}`)) {
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (current.endsWith("/app") || current === "app" || current === "src/app") {
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
current = dirname2(current);
|
|
485
|
+
}
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// src/audit/rules/feature-modules.ts
|
|
490
|
+
import { existsSync as existsSync2 } from "fs";
|
|
491
|
+
import { join as join5 } from "path";
|
|
492
|
+
var featureModuleRules = [
|
|
493
|
+
{
|
|
494
|
+
id: "NB-ARCH-001",
|
|
495
|
+
code: "cross-feature-deep-import",
|
|
496
|
+
architecture: "feature-modules",
|
|
497
|
+
defaultSeverity: "err",
|
|
498
|
+
stacks: ["next", "vite-react"],
|
|
499
|
+
kind: "line",
|
|
500
|
+
check(context) {
|
|
501
|
+
const featuresDir = typeof context.ruleOptions.featuresDir === "string" ? context.ruleOptions.featuresDir : "src/features";
|
|
502
|
+
const normalizedFeaturesDir = featuresDir.replace(/\/$/, "");
|
|
503
|
+
if (!existsSync2(join5(context.rootDir, normalizedFeaturesDir))) {
|
|
504
|
+
return [];
|
|
505
|
+
}
|
|
506
|
+
const boundary = context.architectureOptions.boundary === "forbid" ? "forbid" : "public-api";
|
|
507
|
+
return context.files.flatMap((file) => {
|
|
508
|
+
const sourceFeature = featureName(file.path, normalizedFeaturesDir);
|
|
509
|
+
if (!sourceFeature) {
|
|
510
|
+
return [];
|
|
511
|
+
}
|
|
512
|
+
return importSpecs(file).flatMap(({ spec, line }) => {
|
|
513
|
+
const targetPath = resolveImportPath(file, spec);
|
|
514
|
+
const targetFeature = featureName(targetPath, normalizedFeaturesDir);
|
|
515
|
+
if (!targetFeature || targetFeature === sourceFeature) {
|
|
516
|
+
return [];
|
|
517
|
+
}
|
|
518
|
+
const suffix = targetPath.slice(`${normalizedFeaturesDir}/${targetFeature}/`.length);
|
|
519
|
+
const allowedPublicApi = boundary === "public-api" && (suffix === "index" || suffix === "index.ts" || suffix === "index.tsx");
|
|
520
|
+
return allowedPublicApi ? [] : [finding(file, "NB-ARCH-001", "cross-feature-deep-import", line, targetPath)];
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
},
|
|
525
|
+
{
|
|
526
|
+
id: "NB-ARCH-002",
|
|
527
|
+
code: "feature-imports-app",
|
|
528
|
+
architecture: "feature-modules",
|
|
529
|
+
defaultSeverity: "err",
|
|
530
|
+
stacks: ["next", "vite-react"],
|
|
531
|
+
kind: "line",
|
|
532
|
+
check(context) {
|
|
533
|
+
const featuresDir = typeof context.ruleOptions.featuresDir === "string" ? context.ruleOptions.featuresDir : "src/features";
|
|
534
|
+
return context.files.flatMap((file) => {
|
|
535
|
+
if (!featureName(file.path, featuresDir)) {
|
|
536
|
+
return [];
|
|
537
|
+
}
|
|
538
|
+
return importSpecs(file).flatMap(({ spec, line }) => {
|
|
539
|
+
const target = resolveImportPath(file, spec);
|
|
540
|
+
return /^(src\/)?(app|routes)(\/|$)/.test(target) ? [finding(file, "NB-ARCH-002", "feature-imports-app", line, target)] : [];
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
];
|
|
546
|
+
function featureName(path, featuresDir) {
|
|
547
|
+
const normalized = featuresDir.replace(/\/$/, "");
|
|
548
|
+
const prefix = `${normalized}/`;
|
|
549
|
+
if (!path.startsWith(prefix)) {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
return path.slice(prefix.length).split("/")[0] ?? null;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// src/audit/rules/modern-typescript.ts
|
|
556
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
557
|
+
import { dirname as dirname3, join as join6, resolve } from "path";
|
|
558
|
+
var modernTypeScriptRules = [
|
|
559
|
+
{
|
|
560
|
+
id: "NB-ARCH-013",
|
|
561
|
+
code: "tsconfig-not-strict",
|
|
562
|
+
architecture: "modern-typescript",
|
|
563
|
+
defaultSeverity: "warn",
|
|
564
|
+
stacks: ["next", "vite-react"],
|
|
565
|
+
kind: "project",
|
|
566
|
+
check(context) {
|
|
567
|
+
return tsconfigStrict(context.rootDir) ? [] : [{
|
|
568
|
+
rule: "NB-ARCH-013",
|
|
569
|
+
sev: "warn",
|
|
570
|
+
file: "tsconfig.json",
|
|
571
|
+
line: 1,
|
|
572
|
+
code: "tsconfig-not-strict"
|
|
573
|
+
}];
|
|
574
|
+
}
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
id: "NB-ARCH-014",
|
|
578
|
+
code: "explicit-any",
|
|
579
|
+
architecture: "modern-typescript",
|
|
580
|
+
defaultSeverity: "warn",
|
|
581
|
+
stacks: ["next", "vite-react"],
|
|
582
|
+
kind: "line",
|
|
583
|
+
check(context) {
|
|
584
|
+
return context.files.flatMap((file) => {
|
|
585
|
+
if (/(\.d\.ts|\.test\.[jt]sx?|\.spec\.[jt]sx?)$/.test(file.path) || file.path.startsWith("tests/")) {
|
|
586
|
+
return [];
|
|
587
|
+
}
|
|
588
|
+
return file.lines.flatMap(
|
|
589
|
+
(line, index) => /(:\s*any\b|as\s+any\b|<any>)/.test(line) ? [finding(file, "NB-ARCH-014", "explicit-any", index + 1)] : []
|
|
590
|
+
);
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
];
|
|
595
|
+
function tsconfigStrict(rootDir) {
|
|
596
|
+
const visited = /* @__PURE__ */ new Set();
|
|
597
|
+
return readStrictFromTsconfig(join6(rootDir, "tsconfig.json"), visited) === true;
|
|
598
|
+
}
|
|
599
|
+
function readStrictFromTsconfig(path, visited) {
|
|
600
|
+
if (visited.has(path) || !existsSync3(path)) {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
visited.add(path);
|
|
604
|
+
const parsed = JSON.parse(stripJsonComments(readFileSync2(path, "utf8")));
|
|
605
|
+
if (parsed.compilerOptions?.strict !== void 0) {
|
|
606
|
+
return parsed.compilerOptions.strict;
|
|
607
|
+
}
|
|
608
|
+
if (!parsed.extends) {
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
if (parsed.extends.startsWith(".")) {
|
|
612
|
+
return readStrictFromTsconfig(resolve(dirname3(path), parsed.extends), visited);
|
|
613
|
+
}
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
function stripJsonComments(value) {
|
|
617
|
+
return value.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// src/audit/rules/secure-by-default.ts
|
|
621
|
+
var secureByDefaultRules = [
|
|
622
|
+
{
|
|
623
|
+
id: "NB-ARCH-011",
|
|
624
|
+
code: "unsanitized-html",
|
|
625
|
+
architecture: "secure-by-default",
|
|
626
|
+
defaultSeverity: "err",
|
|
627
|
+
stacks: ["next", "vite-react"],
|
|
628
|
+
kind: "line",
|
|
629
|
+
check(context) {
|
|
630
|
+
return context.files.flatMap(
|
|
631
|
+
(file) => file.lines.flatMap((line, index) => {
|
|
632
|
+
const match = line.match(/dangerouslySetInnerHTML=\{\{\s*__html:\s*([^}]+)\s*\}\}/);
|
|
633
|
+
if (!match) {
|
|
634
|
+
return [];
|
|
635
|
+
}
|
|
636
|
+
const value = match[1]?.trim() ?? "";
|
|
637
|
+
const safe = /sanitize\s*\(/i.test(value) || /^["'`]/.test(value);
|
|
638
|
+
return safe ? [] : [finding(file, "NB-ARCH-011", "unsanitized-html", index + 1)];
|
|
639
|
+
})
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
},
|
|
643
|
+
{
|
|
644
|
+
id: "NB-ARCH-012",
|
|
645
|
+
code: "public-env-secret-name",
|
|
646
|
+
architecture: "secure-by-default",
|
|
647
|
+
defaultSeverity: "warn",
|
|
648
|
+
stacks: ["next", "vite-react"],
|
|
649
|
+
kind: "line",
|
|
650
|
+
check(context) {
|
|
651
|
+
return context.files.flatMap(
|
|
652
|
+
(file) => file.lines.flatMap((line, index) => {
|
|
653
|
+
const names = [...line.matchAll(/\b((?:NEXT_PUBLIC|VITE)_[A-Z0-9_]+)/g)].map((match) => match[1] ?? "");
|
|
654
|
+
return names.filter((name) => /(SECRET|TOKEN|PRIVATE|PASSWORD|_KEY$)/.test(name)).map((name) => finding(file, "NB-ARCH-012", "public-env-secret-name", index + 1, name));
|
|
655
|
+
})
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
];
|
|
660
|
+
|
|
661
|
+
// src/audit/rules/server-first-components.ts
|
|
662
|
+
var serverFirstComponentRules = [
|
|
663
|
+
{
|
|
664
|
+
id: "NB-ARCH-003",
|
|
665
|
+
code: "use-client-in-entry",
|
|
666
|
+
architecture: "server-first-components",
|
|
667
|
+
defaultSeverity: "err",
|
|
668
|
+
stacks: ["next"],
|
|
669
|
+
kind: "line",
|
|
670
|
+
check(context) {
|
|
671
|
+
return context.files.flatMap((file) => {
|
|
672
|
+
if (!hasUseClientDirective(file) || !/(^|\/)(page|layout)\.tsx?$/.test(file.path)) {
|
|
673
|
+
return [];
|
|
674
|
+
}
|
|
675
|
+
return [finding(file, "NB-ARCH-003", "use-client-in-entry", lineOf(file.content, /['"]use client['"]/))];
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
},
|
|
679
|
+
{
|
|
680
|
+
id: "NB-ARCH-004",
|
|
681
|
+
code: "needless-use-client",
|
|
682
|
+
architecture: "server-first-components",
|
|
683
|
+
defaultSeverity: "warn",
|
|
684
|
+
stacks: ["next"],
|
|
685
|
+
kind: "line",
|
|
686
|
+
check(context) {
|
|
687
|
+
return context.files.flatMap((file) => {
|
|
688
|
+
if (!hasUseClientDirective(file)) {
|
|
689
|
+
return [];
|
|
690
|
+
}
|
|
691
|
+
const withoutDirective = file.lines.filter((line) => !line.includes("use client")).join("\n");
|
|
692
|
+
const needsClient = /\buse[A-Z]\w*\s*\(/.test(withoutDirective) || /\bon[A-Z]\w*\s*=/.test(withoutDirective) || /\b(window|document)\./.test(withoutDirective);
|
|
693
|
+
return needsClient ? [] : [finding(file, "NB-ARCH-004", "needless-use-client", lineOf(file.content, /['"]use client['"]/))];
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
];
|
|
698
|
+
|
|
699
|
+
// src/audit/rules/state-management.ts
|
|
700
|
+
var stateManagementRules = [
|
|
701
|
+
{
|
|
702
|
+
id: "NB-ARCH-009",
|
|
703
|
+
code: "server-state-in-store",
|
|
704
|
+
architecture: "state-management",
|
|
705
|
+
defaultSeverity: "warn",
|
|
706
|
+
stacks: ["next", "vite-react"],
|
|
707
|
+
kind: "ast",
|
|
708
|
+
check(context) {
|
|
709
|
+
const globs = dataLayerGlobs(context.ruleOptions);
|
|
710
|
+
return context.files.flatMap((file) => {
|
|
711
|
+
if (isDataLayerFile(file, globs)) {
|
|
712
|
+
return [];
|
|
713
|
+
}
|
|
714
|
+
const hasServerRead = /\bawait\s+(?:fetch\s*\(|\w+\.(?:get|post|list|fetch|load)\s*\()/.test(file.content);
|
|
715
|
+
if (!hasServerRead) {
|
|
716
|
+
return [];
|
|
717
|
+
}
|
|
718
|
+
return file.lines.flatMap(
|
|
719
|
+
(line, index) => /\b(use[A-Z]\w*Store\.setState|dispatch|set)\s*\(/.test(line) ? [finding(file, "NB-ARCH-009", "server-state-in-store", index + 1)] : []
|
|
720
|
+
);
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
];
|
|
725
|
+
|
|
726
|
+
// src/audit/rules/typed-contracts.ts
|
|
727
|
+
var typedContractRules = [
|
|
728
|
+
{
|
|
729
|
+
id: "NB-ARCH-007",
|
|
730
|
+
code: "unvalidated-boundary",
|
|
731
|
+
architecture: "typed-contracts",
|
|
732
|
+
defaultSeverity: "warn",
|
|
733
|
+
stacks: ["next", "vite-react"],
|
|
734
|
+
kind: "line",
|
|
735
|
+
check(context) {
|
|
736
|
+
if (hasGeneratedClientDependency(context.rootDir)) {
|
|
737
|
+
return [];
|
|
738
|
+
}
|
|
739
|
+
const globs = dataLayerGlobs(context.ruleOptions);
|
|
740
|
+
return context.files.flatMap((file) => {
|
|
741
|
+
if (!isDataLayerFile(file, globs) || /\.s(afe)?parse\s*\(/.test(file.content)) {
|
|
742
|
+
return [];
|
|
743
|
+
}
|
|
744
|
+
return file.lines.flatMap(
|
|
745
|
+
(line, index) => /(=\s*await\s+\w+\.json\s*\(|=\s*JSON\.parse\s*\(|as\s+\w+.*await\s+\w+\.json\s*\()/.test(line) ? [finding(file, "NB-ARCH-007", "unvalidated-boundary", index + 1)] : []
|
|
746
|
+
);
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
},
|
|
750
|
+
{
|
|
751
|
+
id: "NB-ARCH-008",
|
|
752
|
+
code: "env-outside-env-file",
|
|
753
|
+
architecture: "typed-contracts",
|
|
754
|
+
defaultSeverity: "warn",
|
|
755
|
+
stacks: ["next", "vite-react"],
|
|
756
|
+
kind: "line",
|
|
757
|
+
check(context) {
|
|
758
|
+
const envFiles = envFileGlobs(context.ruleOptions);
|
|
759
|
+
return context.files.flatMap((file) => {
|
|
760
|
+
if (isConfigFile(file.path) || envFiles.some((envFile) => file.path.endsWith(envFile))) {
|
|
761
|
+
return [];
|
|
762
|
+
}
|
|
763
|
+
return file.lines.flatMap((line, index) => {
|
|
764
|
+
const names = [...line.matchAll(/(?:process\.env|import\.meta\.env)\.([A-Z0-9_]+)/g)].map((match) => match[1] ?? "");
|
|
765
|
+
return names.filter((name) => name !== "NODE_ENV").map((name) => finding(file, "NB-ARCH-008", "env-outside-env-file", index + 1, name));
|
|
766
|
+
});
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
];
|
|
771
|
+
function envFileGlobs(options) {
|
|
772
|
+
const configured = options.envFiles;
|
|
773
|
+
return Array.isArray(configured) && configured.every((item) => typeof item === "string") ? configured : ["env.ts", "env.mjs"];
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// src/audit/registry.ts
|
|
777
|
+
var auditRules = [
|
|
778
|
+
...featureModuleRules,
|
|
779
|
+
...serverFirstComponentRules,
|
|
780
|
+
...dataAccessLayerRules,
|
|
781
|
+
...typedContractRules,
|
|
782
|
+
...stateManagementRules,
|
|
783
|
+
...errorLoadingBoundaryRules,
|
|
784
|
+
...secureByDefaultRules,
|
|
785
|
+
...modernTypeScriptRules
|
|
786
|
+
];
|
|
787
|
+
var guidelineByArchitecture = {
|
|
788
|
+
"feature-modules": ".ai/guidelines/architectures/feature-modules.md",
|
|
789
|
+
"server-first-components": ".ai/guidelines/architectures/server-first-components.md",
|
|
790
|
+
"data-access-layer": ".ai/guidelines/architectures/data-access-layer.md",
|
|
791
|
+
"typed-contracts": ".ai/guidelines/architectures/typed-contracts.md",
|
|
792
|
+
"state-management": ".ai/guidelines/architectures/state-management.md",
|
|
793
|
+
"custom-hooks": ".ai/guidelines/architectures/custom-hooks.md",
|
|
794
|
+
"component-composition": ".ai/guidelines/architectures/component-composition.md",
|
|
795
|
+
"styling-tailwind": ".ai/guidelines/architectures/styling-tailwind.md",
|
|
796
|
+
"testing-strategy": ".ai/guidelines/architectures/testing-strategy.md",
|
|
797
|
+
"error-loading-boundaries": ".ai/guidelines/architectures/error-loading-boundaries.md",
|
|
798
|
+
"secure-by-default": ".ai/guidelines/architectures/secure-by-default.md",
|
|
799
|
+
"modern-typescript": ".ai/guidelines/architectures/modern-typescript.md",
|
|
800
|
+
"ui-states": ".ai/guidelines/architectures/ui-states.md"
|
|
801
|
+
};
|
|
802
|
+
var explainEntries = new Map(
|
|
803
|
+
auditRules.map((rule) => [
|
|
804
|
+
rule.id,
|
|
805
|
+
{
|
|
806
|
+
rule: rule.id,
|
|
807
|
+
code: rule.code,
|
|
808
|
+
severity: rule.defaultSeverity,
|
|
809
|
+
architecture: rule.architecture,
|
|
810
|
+
description: describeRule(rule.id),
|
|
811
|
+
rationale: "This check enforces the selected node-boost architecture guideline and keeps generated agent feedback actionable.",
|
|
812
|
+
fix: fixRule(rule.id),
|
|
813
|
+
guideline: guidelineByArchitecture[rule.architecture]
|
|
814
|
+
}
|
|
815
|
+
])
|
|
816
|
+
);
|
|
817
|
+
function explainFinding(ruleId) {
|
|
818
|
+
return explainEntries.get(ruleId) ?? null;
|
|
819
|
+
}
|
|
820
|
+
function describeRule(ruleId) {
|
|
821
|
+
const descriptions = {
|
|
822
|
+
"NB-ARCH-001": "Feature modules must not deep-import internals from other features.",
|
|
823
|
+
"NB-ARCH-002": "Feature modules must not depend on app/router entry layers.",
|
|
824
|
+
"NB-ARCH-003": "Next page and layout entries should stay server-first.",
|
|
825
|
+
"NB-ARCH-004": "Client directives should be present only when the file needs client-only behavior.",
|
|
826
|
+
"NB-ARCH-005": "Client components should call a data layer or query hook instead of raw network clients.",
|
|
827
|
+
"NB-ARCH-006": "Server Components should keep raw fetch calls in a data layer.",
|
|
828
|
+
"NB-ARCH-007": "Untrusted JSON at data boundaries should be validated.",
|
|
829
|
+
"NB-ARCH-008": "Environment access should be centralized in env files.",
|
|
830
|
+
"NB-ARCH-009": "Server data should not be pushed directly into client stores.",
|
|
831
|
+
"NB-ARCH-010": "Async Next app segments need loading or error boundaries.",
|
|
832
|
+
"NB-ARCH-011": "Raw HTML injection must be sanitized or static.",
|
|
833
|
+
"NB-ARCH-012": "Public env names must not look like secrets.",
|
|
834
|
+
"NB-ARCH-013": "TypeScript projects should run with strict mode.",
|
|
835
|
+
"NB-ARCH-014": "Source code should avoid explicit any outside tests and declarations."
|
|
836
|
+
};
|
|
837
|
+
return descriptions[ruleId] ?? "Unknown node-boost audit rule.";
|
|
838
|
+
}
|
|
839
|
+
function fixRule(ruleId) {
|
|
840
|
+
const fixes = {
|
|
841
|
+
"NB-ARCH-001": "Import from the feature public index or move shared code to a shared module.",
|
|
842
|
+
"NB-ARCH-002": "Invert the dependency: app imports features, features do not import app.",
|
|
843
|
+
"NB-ARCH-003": "Move interactive code to a child client component.",
|
|
844
|
+
"NB-ARCH-004": "Remove the directive or add a real client-only boundary around interactive code.",
|
|
845
|
+
"NB-ARCH-005": "Move the request to an API/data module or wrap it in a query hook.",
|
|
846
|
+
"NB-ARCH-006": "Move raw fetch usage to the configured data layer.",
|
|
847
|
+
"NB-ARCH-007": "Parse the response with a schema, or rely on a generated typed client.",
|
|
848
|
+
"NB-ARCH-008": "Read the env var in env.ts/env.mjs and import the typed value.",
|
|
849
|
+
"NB-ARCH-009": "Keep server state in a query/cache layer and store only UI state.",
|
|
850
|
+
"NB-ARCH-010": "Add loading.tsx or error.tsx in the segment branch.",
|
|
851
|
+
"NB-ARCH-011": "Sanitize the HTML value before passing it to dangerouslySetInnerHTML.",
|
|
852
|
+
"NB-ARCH-012": "Rename the public env var so it does not imply secret material.",
|
|
853
|
+
"NB-ARCH-013": "Set compilerOptions.strict to true, directly or through an extends chain.",
|
|
854
|
+
"NB-ARCH-014": "Replace any with a concrete, unknown, or generic type."
|
|
855
|
+
};
|
|
856
|
+
return fixes[ruleId] ?? "Open the linked guideline and apply the recommended architecture boundary.";
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// src/audit/scope.ts
|
|
860
|
+
import { execFile } from "child_process";
|
|
861
|
+
import { realpath, readdir } from "fs/promises";
|
|
862
|
+
import { join as join7, relative } from "path";
|
|
863
|
+
import { promisify } from "util";
|
|
864
|
+
import picomatch2 from "picomatch";
|
|
865
|
+
var execFileAsync = promisify(execFile);
|
|
866
|
+
var sourceFilePattern = /\.(tsx?|jsx?)$/;
|
|
867
|
+
var defaultExcludes = ["node_modules/**", "dist/**", ".next/**", "coverage/**"];
|
|
868
|
+
async function resolveAuditScope(options) {
|
|
869
|
+
const warnings = [];
|
|
870
|
+
const rawFiles = await resolveRawFiles(options, warnings);
|
|
871
|
+
const files = filterSourceFiles(rawFiles, options.config.audit.exclude);
|
|
872
|
+
return {
|
|
873
|
+
mode: options.mode,
|
|
874
|
+
files,
|
|
875
|
+
warnings
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
async function resolveRawFiles(options, warnings) {
|
|
879
|
+
if (options.mode === "paths") {
|
|
880
|
+
return options.paths ?? [];
|
|
881
|
+
}
|
|
882
|
+
if (options.mode === "all") {
|
|
883
|
+
return walkFiles(options.rootDir);
|
|
884
|
+
}
|
|
885
|
+
if (options.mode === "base") {
|
|
886
|
+
try {
|
|
887
|
+
const gitRoot = await findGitRoot(options.rootDir);
|
|
888
|
+
const mergeBase = (await execGit(gitRoot, ["merge-base", options.base ?? "main", "HEAD"])).trim();
|
|
889
|
+
const diff = await execGit(gitRoot, ["diff", "--name-only", mergeBase, "HEAD"]);
|
|
890
|
+
return gitPathsToProjectRelative(gitRoot, options.rootDir, splitLines(diff));
|
|
891
|
+
} catch {
|
|
892
|
+
warnings.push(metaWarning("NB-META-004", "git-base-fallback-all"));
|
|
893
|
+
return walkFiles(options.rootDir);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
try {
|
|
897
|
+
const gitRoot = await findGitRoot(options.rootDir);
|
|
898
|
+
const tracked = await execGit(gitRoot, ["diff", "--name-only", "HEAD"]);
|
|
899
|
+
const untracked = await execGit(gitRoot, ["ls-files", "--others", "--exclude-standard"]);
|
|
900
|
+
return gitPathsToProjectRelative(gitRoot, options.rootDir, [...splitLines(tracked), ...splitLines(untracked)]);
|
|
901
|
+
} catch {
|
|
902
|
+
warnings.push(metaWarning("NB-META-004", "git-changed-fallback-all"));
|
|
903
|
+
return walkFiles(options.rootDir);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
function filterSourceFiles(files, configuredExcludes) {
|
|
907
|
+
const excludes = picomatch2([...defaultExcludes, ...configuredExcludes]);
|
|
908
|
+
return [...new Set(files.map(toPosix2))].filter((file) => sourceFilePattern.test(file)).filter((file) => !excludes(file)).sort((a, b) => a.localeCompare(b));
|
|
909
|
+
}
|
|
910
|
+
async function walkFiles(rootDir, dir = rootDir) {
|
|
911
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
912
|
+
const files = await Promise.all(
|
|
913
|
+
entries.map(async (entry) => {
|
|
914
|
+
const absolutePath = join7(dir, entry.name);
|
|
915
|
+
const rel = toPosix2(relative(rootDir, absolutePath));
|
|
916
|
+
if (entry.isDirectory()) {
|
|
917
|
+
if (defaultExcludes.some((pattern) => picomatch2.isMatch(`${rel}/`, pattern))) {
|
|
918
|
+
return [];
|
|
919
|
+
}
|
|
920
|
+
return walkFiles(rootDir, absolutePath);
|
|
921
|
+
}
|
|
922
|
+
return entry.isFile() ? [rel] : [];
|
|
923
|
+
})
|
|
924
|
+
);
|
|
925
|
+
return files.flat();
|
|
926
|
+
}
|
|
927
|
+
async function execGit(cwd, args) {
|
|
928
|
+
const { stdout } = await execFileAsync("git", args, { cwd });
|
|
929
|
+
return stdout;
|
|
930
|
+
}
|
|
931
|
+
async function findGitRoot(rootDir) {
|
|
932
|
+
return realpath((await execGit(rootDir, ["rev-parse", "--show-toplevel"])).trim());
|
|
933
|
+
}
|
|
934
|
+
async function gitPathsToProjectRelative(gitRootInput, rootDir, files) {
|
|
935
|
+
const gitRoot = await realpath(gitRootInput);
|
|
936
|
+
const projectRoot = await realpath(rootDir);
|
|
937
|
+
const projectPrefix = toPosix2(relative(gitRoot, projectRoot));
|
|
938
|
+
if (!projectPrefix) {
|
|
939
|
+
return files;
|
|
940
|
+
}
|
|
941
|
+
return files.map(toPosix2).filter((file) => file === projectPrefix || file.startsWith(`${projectPrefix}/`)).map((file) => file.slice(projectPrefix.length).replace(/^\//, ""));
|
|
942
|
+
}
|
|
943
|
+
function splitLines(value) {
|
|
944
|
+
return value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
945
|
+
}
|
|
946
|
+
function toPosix2(path) {
|
|
947
|
+
return path.replaceAll("\\", "/");
|
|
948
|
+
}
|
|
949
|
+
function metaWarning(rule, code) {
|
|
950
|
+
return {
|
|
951
|
+
rule,
|
|
952
|
+
sev: "warn",
|
|
953
|
+
file: "<project>",
|
|
954
|
+
line: 1,
|
|
955
|
+
code
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// src/audit/suppression.ts
|
|
960
|
+
var suppressionPattern = /nb-disable\s+(NB-[A-Z]+-\d{3})\s*(?:--\s*(.+))?/;
|
|
961
|
+
function buildSuppressionIndex(files) {
|
|
962
|
+
const lineSuppressions = /* @__PURE__ */ new Map();
|
|
963
|
+
const fileSuppressions = /* @__PURE__ */ new Map();
|
|
964
|
+
const metaFindings = [];
|
|
965
|
+
for (const file of files) {
|
|
966
|
+
let beforeImports = true;
|
|
967
|
+
file.lines.forEach((line, index) => {
|
|
968
|
+
const lineNo = index + 1;
|
|
969
|
+
const trimmed = line.trim();
|
|
970
|
+
const match = trimmed.match(suppressionPattern);
|
|
971
|
+
if (match) {
|
|
972
|
+
const [, rule = "", reason = ""] = match;
|
|
973
|
+
if (!reason.trim()) {
|
|
974
|
+
metaFindings.push({
|
|
975
|
+
rule: "NB-META-001",
|
|
976
|
+
sev: "warn",
|
|
977
|
+
file: file.path,
|
|
978
|
+
line: lineNo,
|
|
979
|
+
code: "suppression-without-reason"
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
if (beforeImports) {
|
|
983
|
+
addSuppression(fileSuppressions, file.path, rule);
|
|
984
|
+
}
|
|
985
|
+
addSuppression(lineSuppressions, lineKey(file.path, lineNo), rule);
|
|
986
|
+
addSuppression(lineSuppressions, lineKey(file.path, lineNo + 1), rule);
|
|
987
|
+
}
|
|
988
|
+
if (trimmed && !trimmed.startsWith("//") && !trimmed.startsWith("/*") && !trimmed.startsWith("*")) {
|
|
989
|
+
beforeImports = !trimmed.startsWith("import ");
|
|
990
|
+
}
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
return {
|
|
994
|
+
metaFindings,
|
|
995
|
+
suppresses(file, line, rule) {
|
|
996
|
+
return Boolean(fileSuppressions.get(file)?.has(rule) || lineSuppressions.get(lineKey(file, line))?.has(rule));
|
|
997
|
+
}
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
function addSuppression(map, key, rule) {
|
|
1001
|
+
const current = map.get(key) ?? /* @__PURE__ */ new Set();
|
|
1002
|
+
current.add(rule);
|
|
1003
|
+
map.set(key, current);
|
|
1004
|
+
}
|
|
1005
|
+
function lineKey(file, line) {
|
|
1006
|
+
return `${file}:${line}`;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// src/audit/engine.ts
|
|
1010
|
+
var NodeBoostConfigMissingError = class extends Error {
|
|
1011
|
+
constructor() {
|
|
1012
|
+
super("No node-boost.json found \u2014 run node-boost install first.");
|
|
1013
|
+
this.name = "NodeBoostConfigMissingError";
|
|
1014
|
+
}
|
|
1015
|
+
};
|
|
1016
|
+
function isNodeBoostConfigMissingError(error) {
|
|
1017
|
+
return error instanceof NodeBoostConfigMissingError;
|
|
1018
|
+
}
|
|
1019
|
+
async function runAudit(options = {}) {
|
|
1020
|
+
const started = performance.now();
|
|
1021
|
+
const rootDir = options.rootDir ?? process.cwd();
|
|
1022
|
+
const config = await readConfig(rootDir);
|
|
1023
|
+
const stack = await detectStack(rootDir);
|
|
1024
|
+
const scope = await resolveAuditScope({
|
|
1025
|
+
rootDir,
|
|
1026
|
+
config,
|
|
1027
|
+
mode: options.mode ?? "all",
|
|
1028
|
+
base: options.base,
|
|
1029
|
+
paths: options.paths
|
|
1030
|
+
});
|
|
1031
|
+
const parseWarnings = [];
|
|
1032
|
+
const files = await readAuditFiles(rootDir, scope.files, parseWarnings);
|
|
1033
|
+
const suppressionIndex = buildSuppressionIndex(files);
|
|
1034
|
+
const enabledArchitectures = new Map(normalizeArchitectures(config).map((architecture) => [architecture.name, architecture.options]));
|
|
1035
|
+
const findings = [...scope.warnings, ...parseWarnings, ...suppressionIndex.metaFindings];
|
|
1036
|
+
let suppressed = 0;
|
|
1037
|
+
for (const rule of auditRules) {
|
|
1038
|
+
const severity = config.audit.rules[rule.id] ?? rule.defaultSeverity;
|
|
1039
|
+
if (severity === "off" || !enabledArchitectures.has(rule.architecture) || !rule.stacks.includes(stack.name)) {
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
const rawFindings = rule.check({
|
|
1043
|
+
rootDir,
|
|
1044
|
+
stack,
|
|
1045
|
+
config,
|
|
1046
|
+
files,
|
|
1047
|
+
allPaths: new Set(scope.files),
|
|
1048
|
+
rule,
|
|
1049
|
+
severity,
|
|
1050
|
+
architectureOptions: enabledArchitectures.get(rule.architecture) ?? {},
|
|
1051
|
+
ruleOptions: config.audit.ruleOptions[rule.id] ?? {}
|
|
1052
|
+
});
|
|
1053
|
+
for (const finding2 of rawFindings) {
|
|
1054
|
+
const normalized = { ...finding2, sev: severity };
|
|
1055
|
+
if (suppressionIndex.suppresses(normalized.file, normalized.line, normalized.rule)) {
|
|
1056
|
+
suppressed += 1;
|
|
1057
|
+
} else {
|
|
1058
|
+
findings.push(normalized);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
const err = findings.filter((finding2) => finding2.sev === "err").length;
|
|
1063
|
+
const warn = findings.filter((finding2) => finding2.sev === "warn").length;
|
|
1064
|
+
return {
|
|
1065
|
+
v: 1,
|
|
1066
|
+
ok: err === 0,
|
|
1067
|
+
cmd: "audit",
|
|
1068
|
+
scope: scope.mode,
|
|
1069
|
+
err,
|
|
1070
|
+
warn,
|
|
1071
|
+
scanned: files.filter((file) => !file.skipped).length,
|
|
1072
|
+
skipped: files.filter((file) => file.skipped).length,
|
|
1073
|
+
suppressed,
|
|
1074
|
+
elapsedMs: Math.round(performance.now() - started),
|
|
1075
|
+
findings: findings.sort(compareFindings)
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
async function readConfig(rootDir) {
|
|
1079
|
+
const configPath = join8(rootDir, "node-boost.json");
|
|
1080
|
+
const raw = await readFile3(configPath, "utf8").catch((error) => {
|
|
1081
|
+
if (isFileNotFoundError(error)) {
|
|
1082
|
+
throw new NodeBoostConfigMissingError();
|
|
1083
|
+
}
|
|
1084
|
+
throw error;
|
|
1085
|
+
});
|
|
1086
|
+
return parseNodeBoostConfig(JSON.parse(raw));
|
|
1087
|
+
}
|
|
1088
|
+
function isFileNotFoundError(error) {
|
|
1089
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
1090
|
+
}
|
|
1091
|
+
async function readAuditFiles(rootDir, files, parseWarnings) {
|
|
1092
|
+
const project = new Project({
|
|
1093
|
+
compilerOptions: {
|
|
1094
|
+
allowJs: true,
|
|
1095
|
+
checkJs: false,
|
|
1096
|
+
jsx: 4,
|
|
1097
|
+
skipLibCheck: true
|
|
1098
|
+
},
|
|
1099
|
+
skipAddingFilesFromTsConfig: true
|
|
1100
|
+
});
|
|
1101
|
+
return Promise.all(
|
|
1102
|
+
files.map(async (file) => {
|
|
1103
|
+
const absolutePath = join8(rootDir, file);
|
|
1104
|
+
const content = await readFile3(absolutePath, "utf8");
|
|
1105
|
+
const started = performance.now();
|
|
1106
|
+
const sourceFile = project.addSourceFileAtPath(absolutePath);
|
|
1107
|
+
const diagnostics = sourceFile.getPreEmitDiagnostics().filter((diagnostic) => diagnostic.getCategory() === DiagnosticCategory.Error && diagnostic.getCode() < 2e3);
|
|
1108
|
+
const elapsed = performance.now() - started;
|
|
1109
|
+
const skipped = diagnostics.length > 0 || elapsed > 5e3;
|
|
1110
|
+
if (diagnostics.length > 0) {
|
|
1111
|
+
parseWarnings.push({
|
|
1112
|
+
rule: "NB-META-002",
|
|
1113
|
+
sev: "warn",
|
|
1114
|
+
file,
|
|
1115
|
+
line: diagnostics[0]?.getLineNumber() ?? 1,
|
|
1116
|
+
code: "parse-error"
|
|
1117
|
+
});
|
|
1118
|
+
} else if (elapsed > 5e3) {
|
|
1119
|
+
parseWarnings.push({
|
|
1120
|
+
rule: "NB-META-003",
|
|
1121
|
+
sev: "warn",
|
|
1122
|
+
file,
|
|
1123
|
+
line: 1,
|
|
1124
|
+
code: "parse-timeout"
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
return {
|
|
1128
|
+
path: file,
|
|
1129
|
+
absolutePath,
|
|
1130
|
+
content,
|
|
1131
|
+
lines: content.split(/\r?\n/),
|
|
1132
|
+
sourceFile: skipped ? null : sourceFile,
|
|
1133
|
+
skipped
|
|
1134
|
+
};
|
|
1135
|
+
})
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
1138
|
+
function compareFindings(a, b) {
|
|
1139
|
+
return a.file.localeCompare(b.file) || a.line - b.line || a.rule.localeCompare(b.rule);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// src/audit/reporters/agent.ts
|
|
1143
|
+
function renderAgentReport(result) {
|
|
1144
|
+
return `${JSON.stringify({
|
|
1145
|
+
v: result.v,
|
|
1146
|
+
ok: result.ok,
|
|
1147
|
+
cmd: result.cmd,
|
|
1148
|
+
scope: result.scope,
|
|
1149
|
+
err: result.err,
|
|
1150
|
+
warn: result.warn,
|
|
1151
|
+
scanned: result.scanned,
|
|
1152
|
+
skipped: result.skipped,
|
|
1153
|
+
findings: result.findings
|
|
1154
|
+
})}
|
|
1155
|
+
`;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// src/audit/reporters/human.ts
|
|
1159
|
+
function renderHumanReport(result) {
|
|
1160
|
+
const lines = result.findings.map((finding2) => {
|
|
1161
|
+
const ref = finding2.ref ? ` -> ${finding2.ref}` : "";
|
|
1162
|
+
return `${finding2.file}:${finding2.line} | ${finding2.rule} | ${finding2.sev} | ${finding2.code}${ref}`;
|
|
1163
|
+
});
|
|
1164
|
+
lines.push(
|
|
1165
|
+
`summary | scanned=${result.scanned} skipped=${result.skipped} suppressed=${result.suppressed} err=${result.err} warn=${result.warn} time=${result.elapsedMs}ms`
|
|
1166
|
+
);
|
|
1167
|
+
return `${lines.join("\n")}
|
|
1168
|
+
`;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// src/cli/errors.ts
|
|
1172
|
+
function handleCliError(error) {
|
|
1173
|
+
if (!isNodeBoostConfigMissingError(error)) {
|
|
1174
|
+
return false;
|
|
1175
|
+
}
|
|
1176
|
+
process.stderr.write(`${error.message}
|
|
1177
|
+
`);
|
|
1178
|
+
process.exitCode = 1;
|
|
1179
|
+
return true;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// src/cli/commands/audit.ts
|
|
1183
|
+
var auditCommand = defineCommand({
|
|
1184
|
+
meta: {
|
|
1185
|
+
name: "audit",
|
|
1186
|
+
description: "Audit the project against enabled node-boost architecture rules."
|
|
1187
|
+
},
|
|
1188
|
+
args: {
|
|
1189
|
+
all: { type: "boolean", description: "Audit all source files.", default: false },
|
|
1190
|
+
changed: { type: "boolean", description: "Audit changed and untracked source files.", default: false },
|
|
1191
|
+
base: { type: "string", description: "Audit files changed since merge-base with this ref.", required: false },
|
|
1192
|
+
agent: { type: "boolean", description: "Print compact machine-readable JSON.", default: false }
|
|
1193
|
+
},
|
|
1194
|
+
async run({ args }) {
|
|
1195
|
+
try {
|
|
1196
|
+
const paths = Array.isArray(args._) ? args._.map(String) : [];
|
|
1197
|
+
const result = await runAudit({
|
|
1198
|
+
mode: paths.length > 0 ? "paths" : args.base ? "base" : args.changed ? "changed" : "all",
|
|
1199
|
+
base: args.base,
|
|
1200
|
+
paths
|
|
1201
|
+
});
|
|
1202
|
+
process.stdout.write(args.agent ? renderAgentReport(result) : renderHumanReport(result));
|
|
1203
|
+
process.exitCode = result.err > 0 ? 1 : 0;
|
|
1204
|
+
} catch (error) {
|
|
1205
|
+
if (handleCliError(error)) {
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
throw error;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
// src/cli/commands/doctor.ts
|
|
1214
|
+
import { defineCommand as defineCommand2 } from "citty";
|
|
1215
|
+
|
|
1216
|
+
// package.json
|
|
1217
|
+
var package_default = {
|
|
1218
|
+
name: "@node-boost/node-boost",
|
|
1219
|
+
version: "0.1.0",
|
|
1220
|
+
description: "CLI and MCP guidance layer for Node/React projects.",
|
|
1221
|
+
type: "module",
|
|
1222
|
+
license: "MIT",
|
|
1223
|
+
author: "Gracjan Kubicki",
|
|
1224
|
+
repository: {
|
|
1225
|
+
type: "git",
|
|
1226
|
+
url: "git+https://github.com/gracjankubicki/node-boost.git"
|
|
1227
|
+
},
|
|
1228
|
+
bugs: {
|
|
1229
|
+
url: "https://github.com/gracjankubicki/node-boost/issues"
|
|
1230
|
+
},
|
|
1231
|
+
homepage: "https://github.com/gracjankubicki/node-boost#readme",
|
|
1232
|
+
publishConfig: {
|
|
1233
|
+
access: "public"
|
|
1234
|
+
},
|
|
1235
|
+
keywords: [
|
|
1236
|
+
"node",
|
|
1237
|
+
"react",
|
|
1238
|
+
"nextjs",
|
|
1239
|
+
"vite",
|
|
1240
|
+
"codex",
|
|
1241
|
+
"claude-code",
|
|
1242
|
+
"cursor",
|
|
1243
|
+
"mcp",
|
|
1244
|
+
"architecture",
|
|
1245
|
+
"audit",
|
|
1246
|
+
"agent-skills"
|
|
1247
|
+
],
|
|
1248
|
+
main: "./dist/index.js",
|
|
1249
|
+
exports: {
|
|
1250
|
+
".": {
|
|
1251
|
+
types: "./dist/index.d.ts",
|
|
1252
|
+
import: "./dist/index.js"
|
|
1253
|
+
}
|
|
1254
|
+
},
|
|
1255
|
+
bin: {
|
|
1256
|
+
"node-boost": "dist/cli.js"
|
|
1257
|
+
},
|
|
1258
|
+
types: "./dist/index.d.ts",
|
|
1259
|
+
files: [
|
|
1260
|
+
"dist",
|
|
1261
|
+
"resources",
|
|
1262
|
+
"schema.json",
|
|
1263
|
+
"CHANGELOG.md",
|
|
1264
|
+
"LICENSE.md",
|
|
1265
|
+
"README.md"
|
|
1266
|
+
],
|
|
1267
|
+
engines: {
|
|
1268
|
+
node: ">=20"
|
|
1269
|
+
},
|
|
1270
|
+
scripts: {
|
|
1271
|
+
build: "npm run build:schema && tsup",
|
|
1272
|
+
"build:schema": "tsx scripts/generate-schema.ts",
|
|
1273
|
+
typecheck: "tsc --noEmit",
|
|
1274
|
+
lint: "eslint .",
|
|
1275
|
+
test: "vitest run",
|
|
1276
|
+
check: "npm run typecheck && npm run lint && npm test && npm run build"
|
|
1277
|
+
},
|
|
1278
|
+
dependencies: {
|
|
1279
|
+
"@clack/prompts": "^1.7.0",
|
|
1280
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
1281
|
+
citty: "^0.2.2",
|
|
1282
|
+
picomatch: "^4.0.5",
|
|
1283
|
+
"smol-toml": "^1.7.0",
|
|
1284
|
+
"ts-morph": "^28.0.0",
|
|
1285
|
+
zod: "^4.4.3"
|
|
1286
|
+
},
|
|
1287
|
+
devDependencies: {
|
|
1288
|
+
"@eslint/js": "^10.0.1",
|
|
1289
|
+
"@types/node": "^26.1.1",
|
|
1290
|
+
"@types/picomatch": "^4.0.3",
|
|
1291
|
+
eslint: "^10.6.0",
|
|
1292
|
+
tsup: "^8.5.1",
|
|
1293
|
+
tsx: "^4.23.0",
|
|
1294
|
+
typescript: "^6.0.3",
|
|
1295
|
+
"typescript-eslint": "^8.63.0",
|
|
1296
|
+
vitest: "^4.1.10"
|
|
1297
|
+
}
|
|
1298
|
+
};
|
|
1299
|
+
|
|
1300
|
+
// src/mcp/tools/doctor.ts
|
|
1301
|
+
import { readdir as readdir3, readFile as readFile7 } from "fs/promises";
|
|
1302
|
+
import { join as join16, relative as relative4 } from "path";
|
|
1303
|
+
|
|
1304
|
+
// src/install/orchestrator.ts
|
|
1305
|
+
import { cancel, confirm, intro, isCancel, multiselect, outro, select } from "@clack/prompts";
|
|
1306
|
+
import { mkdir, readFile as readFile5, writeFile } from "fs/promises";
|
|
1307
|
+
import { dirname as dirname5, isAbsolute, join as join14 } from "path";
|
|
1308
|
+
import { ZodError } from "zod";
|
|
1309
|
+
|
|
1310
|
+
// src/agents/agent.ts
|
|
1311
|
+
function createMcpCommand(packageManager) {
|
|
1312
|
+
return createPackageCommand(packageManager, ["mcp"]);
|
|
1313
|
+
}
|
|
1314
|
+
function createHookCommand(packageManager, agent) {
|
|
1315
|
+
return createPackageCommand(packageManager, ["guard", "--hook", agent]);
|
|
1316
|
+
}
|
|
1317
|
+
function createPackageCommand(packageManager, nodeBoostArgs) {
|
|
1318
|
+
if (packageManager === "yarn") {
|
|
1319
|
+
return { command: "yarn", args: ["node-boost", ...nodeBoostArgs] };
|
|
1320
|
+
}
|
|
1321
|
+
if (packageManager === "bun") {
|
|
1322
|
+
return { command: "bunx", args: ["node-boost", ...nodeBoostArgs] };
|
|
1323
|
+
}
|
|
1324
|
+
return { command: packageManager, args: ["exec", "node-boost", ...nodeBoostArgs] };
|
|
1325
|
+
}
|
|
1326
|
+
function formatMcpCommand(command) {
|
|
1327
|
+
return [command.command, ...command.args].join(" ");
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// src/agents/merge-hooks.ts
|
|
1331
|
+
function mergeClaudeCodeHooks(existingContent, command) {
|
|
1332
|
+
const root = parseObject(existingContent);
|
|
1333
|
+
const hooks = asObject(root.hooks);
|
|
1334
|
+
const stop = asArray(hooks.Stop);
|
|
1335
|
+
hooks.Stop = appendCommandHook(stop, commandString(command), true);
|
|
1336
|
+
root.hooks = hooks;
|
|
1337
|
+
return `${JSON.stringify(sortJson(root), null, 2)}
|
|
1338
|
+
`;
|
|
1339
|
+
}
|
|
1340
|
+
function mergeCodexHooks(existingContent, command) {
|
|
1341
|
+
const root = parseObject(existingContent);
|
|
1342
|
+
const hooks = asObject(root.hooks);
|
|
1343
|
+
const stop = asArray(hooks.Stop);
|
|
1344
|
+
hooks.Stop = appendCommandHook(stop, commandString(command), true);
|
|
1345
|
+
root.hooks = hooks;
|
|
1346
|
+
return `${JSON.stringify(sortJson(root), null, 2)}
|
|
1347
|
+
`;
|
|
1348
|
+
}
|
|
1349
|
+
function mergeCursorHooks(existingContent, command) {
|
|
1350
|
+
const root = parseObject(existingContent);
|
|
1351
|
+
const hooks = asObject(root.hooks);
|
|
1352
|
+
const stop = asArray(hooks.stop);
|
|
1353
|
+
const commandValue = commandString(command);
|
|
1354
|
+
hooks.stop = stop.some((entry) => isObject3(entry) && entry.command === commandValue) ? stop : [...stop, { command: commandValue }];
|
|
1355
|
+
root.version = typeof root.version === "number" ? root.version : 1;
|
|
1356
|
+
root.hooks = hooks;
|
|
1357
|
+
return `${JSON.stringify(sortJson(root), null, 2)}
|
|
1358
|
+
`;
|
|
1359
|
+
}
|
|
1360
|
+
function appendCommandHook(entries, command, includeType) {
|
|
1361
|
+
if (entries.some((entry) => hookGroupHasCommand(entry, command))) {
|
|
1362
|
+
return entries;
|
|
1363
|
+
}
|
|
1364
|
+
const hook = includeType ? { type: "command", command, timeout: 30 } : { command };
|
|
1365
|
+
return [...entries, { hooks: [hook] }];
|
|
1366
|
+
}
|
|
1367
|
+
function hookGroupHasCommand(entry, command) {
|
|
1368
|
+
return isObject3(entry) && Array.isArray(entry.hooks) && entry.hooks.some((hook) => isObject3(hook) && hook.command === command);
|
|
1369
|
+
}
|
|
1370
|
+
function commandString(command) {
|
|
1371
|
+
return [command.command, ...command.args].join(" ");
|
|
1372
|
+
}
|
|
1373
|
+
function parseObject(content) {
|
|
1374
|
+
const parsed = content?.trim() ? JSON.parse(content) : {};
|
|
1375
|
+
return isObject3(parsed) ? parsed : {};
|
|
1376
|
+
}
|
|
1377
|
+
function asObject(value) {
|
|
1378
|
+
return isObject3(value) ? value : {};
|
|
1379
|
+
}
|
|
1380
|
+
function asArray(value) {
|
|
1381
|
+
return Array.isArray(value) ? value : [];
|
|
1382
|
+
}
|
|
1383
|
+
function sortJson(value) {
|
|
1384
|
+
if (Array.isArray(value)) {
|
|
1385
|
+
return value.map(sortJson);
|
|
1386
|
+
}
|
|
1387
|
+
if (!isObject3(value)) {
|
|
1388
|
+
return value;
|
|
1389
|
+
}
|
|
1390
|
+
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, sortJson(item)]));
|
|
1391
|
+
}
|
|
1392
|
+
function isObject3(value) {
|
|
1393
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// src/agents/merge-json.ts
|
|
1397
|
+
function mergeMcpJson(existingContent, command) {
|
|
1398
|
+
const parsed = existingContent?.trim() ? JSON.parse(existingContent) : {};
|
|
1399
|
+
const root = isObject4(parsed) ? parsed : {};
|
|
1400
|
+
const mcpServers = isObject4(root.mcpServers) ? root.mcpServers : {};
|
|
1401
|
+
root.mcpServers = {
|
|
1402
|
+
...mcpServers,
|
|
1403
|
+
"node-boost": {
|
|
1404
|
+
command: command.command,
|
|
1405
|
+
args: command.args
|
|
1406
|
+
}
|
|
1407
|
+
};
|
|
1408
|
+
return `${JSON.stringify(sortJson2(root), null, 2)}
|
|
1409
|
+
`;
|
|
1410
|
+
}
|
|
1411
|
+
function sortJson2(value) {
|
|
1412
|
+
if (Array.isArray(value)) {
|
|
1413
|
+
return value.map(sortJson2);
|
|
1414
|
+
}
|
|
1415
|
+
if (!isObject4(value)) {
|
|
1416
|
+
return value;
|
|
1417
|
+
}
|
|
1418
|
+
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, sortJson2(item)]));
|
|
1419
|
+
}
|
|
1420
|
+
function isObject4(value) {
|
|
1421
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// src/agents/managed-block.ts
|
|
1425
|
+
var managedBlockStart = "<!-- node-boost:start -->";
|
|
1426
|
+
var managedBlockEnd = "<!-- node-boost:end -->";
|
|
1427
|
+
function renderManagedBlock(body) {
|
|
1428
|
+
return `${managedBlockStart}
|
|
1429
|
+
${body.trim()}
|
|
1430
|
+
${managedBlockEnd}
|
|
1431
|
+
`;
|
|
1432
|
+
}
|
|
1433
|
+
function upsertManagedBlock(existingContent, body) {
|
|
1434
|
+
const block = renderManagedBlock(body);
|
|
1435
|
+
if (!existingContent) {
|
|
1436
|
+
return block;
|
|
1437
|
+
}
|
|
1438
|
+
const pattern = new RegExp(`${escapeRegExp(managedBlockStart)}[\\s\\S]*?${escapeRegExp(managedBlockEnd)}\\n?`);
|
|
1439
|
+
if (pattern.test(existingContent)) {
|
|
1440
|
+
return existingContent.replace(pattern, block);
|
|
1441
|
+
}
|
|
1442
|
+
const separator = existingContent.endsWith("\n") ? "\n" : "\n\n";
|
|
1443
|
+
return `${existingContent}${separator}${block}`;
|
|
1444
|
+
}
|
|
1445
|
+
function escapeRegExp(value) {
|
|
1446
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// src/agents/claude-code.ts
|
|
1450
|
+
var claudeCodeAgent = {
|
|
1451
|
+
name: "claude-code",
|
|
1452
|
+
capabilities: {
|
|
1453
|
+
supportsGuidelines: true,
|
|
1454
|
+
supportsSkills: true,
|
|
1455
|
+
supportsMcp: true,
|
|
1456
|
+
supportsHooks: true
|
|
1457
|
+
},
|
|
1458
|
+
render(context) {
|
|
1459
|
+
const body = [
|
|
1460
|
+
"# node-boost",
|
|
1461
|
+
"",
|
|
1462
|
+
`Use ${context.guidelinesIndexPath} as the generated guidance index.`,
|
|
1463
|
+
`Repo skills are available in ${context.skillsIndexPath} and mirrored to .claude/skills.`,
|
|
1464
|
+
`MCP command: ${formatMcpCommand(context.mcpCommand)}.`
|
|
1465
|
+
].join("\n");
|
|
1466
|
+
return [
|
|
1467
|
+
{
|
|
1468
|
+
path: "CLAUDE.md",
|
|
1469
|
+
content: upsertManagedBlock(context.existingContent("CLAUDE.md"), body)
|
|
1470
|
+
},
|
|
1471
|
+
{
|
|
1472
|
+
path: ".mcp.json",
|
|
1473
|
+
content: mergeMcpJson(context.existingContent(".mcp.json"), context.mcpCommand)
|
|
1474
|
+
},
|
|
1475
|
+
{
|
|
1476
|
+
path: ".claude/settings.json",
|
|
1477
|
+
content: mergeClaudeCodeHooks(context.existingContent(".claude/settings.json"), context.hookCommands["claude-code"])
|
|
1478
|
+
},
|
|
1479
|
+
...context.selectedSkills.map((skill) => ({
|
|
1480
|
+
path: skill.outputPath.replace(/^\.ai\/skills\//, ".claude/skills/"),
|
|
1481
|
+
content: context.skillContentByOutputPath.get(skill.outputPath) ?? ""
|
|
1482
|
+
}))
|
|
1483
|
+
];
|
|
1484
|
+
}
|
|
1485
|
+
};
|
|
1486
|
+
|
|
1487
|
+
// src/agents/merge-toml.ts
|
|
1488
|
+
import { parse, stringify } from "smol-toml";
|
|
1489
|
+
function mergeCodexMcpToml(existingContent, command) {
|
|
1490
|
+
const parsed = existingContent?.trim() ? parse(existingContent) : {};
|
|
1491
|
+
const root = isObject5(parsed) ? parsed : {};
|
|
1492
|
+
const mcpServers = isObject5(root.mcp_servers) ? root.mcp_servers : {};
|
|
1493
|
+
root.mcp_servers = {
|
|
1494
|
+
...mcpServers,
|
|
1495
|
+
"node-boost": {
|
|
1496
|
+
command: command.command,
|
|
1497
|
+
args: command.args
|
|
1498
|
+
}
|
|
1499
|
+
};
|
|
1500
|
+
return `${stringify(sortToml(root)).trim()}
|
|
1501
|
+
`;
|
|
1502
|
+
}
|
|
1503
|
+
function sortToml(value) {
|
|
1504
|
+
if (Array.isArray(value)) {
|
|
1505
|
+
return value.map(sortToml);
|
|
1506
|
+
}
|
|
1507
|
+
if (!isObject5(value)) {
|
|
1508
|
+
return value;
|
|
1509
|
+
}
|
|
1510
|
+
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, sortToml(item)]));
|
|
1511
|
+
}
|
|
1512
|
+
function isObject5(value) {
|
|
1513
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// src/agents/codex.ts
|
|
1517
|
+
var codexAgent = {
|
|
1518
|
+
name: "codex",
|
|
1519
|
+
capabilities: {
|
|
1520
|
+
supportsGuidelines: true,
|
|
1521
|
+
supportsSkills: true,
|
|
1522
|
+
supportsMcp: true,
|
|
1523
|
+
supportsHooks: true
|
|
1524
|
+
},
|
|
1525
|
+
render(context) {
|
|
1526
|
+
const body = [
|
|
1527
|
+
"# node-boost",
|
|
1528
|
+
"",
|
|
1529
|
+
`Use ${context.guidelinesIndexPath} as the generated guidance index.`,
|
|
1530
|
+
`Repo skills are installed in .agents/skills and source skills are indexed in ${context.skillsIndexPath}.`,
|
|
1531
|
+
`MCP command: ${formatMcpCommand(context.mcpCommand)}.`
|
|
1532
|
+
].join("\n");
|
|
1533
|
+
return [
|
|
1534
|
+
{
|
|
1535
|
+
path: "AGENTS.md",
|
|
1536
|
+
content: upsertManagedBlock(context.existingContent("AGENTS.md"), body)
|
|
1537
|
+
},
|
|
1538
|
+
{
|
|
1539
|
+
path: ".codex/config.toml",
|
|
1540
|
+
content: mergeCodexMcpToml(context.existingContent(".codex/config.toml"), context.mcpCommand)
|
|
1541
|
+
},
|
|
1542
|
+
{
|
|
1543
|
+
path: ".codex/hooks.json",
|
|
1544
|
+
content: mergeCodexHooks(context.existingContent(".codex/hooks.json"), context.hookCommands.codex)
|
|
1545
|
+
},
|
|
1546
|
+
...context.selectedSkills.map((skill) => ({
|
|
1547
|
+
path: skill.outputPath.replace(/^\.ai\/skills\//, ".agents/skills/"),
|
|
1548
|
+
content: context.skillContentByOutputPath.get(skill.outputPath) ?? ""
|
|
1549
|
+
}))
|
|
1550
|
+
];
|
|
1551
|
+
}
|
|
1552
|
+
};
|
|
1553
|
+
|
|
1554
|
+
// src/agents/cursor.ts
|
|
1555
|
+
var cursorAgent = {
|
|
1556
|
+
name: "cursor",
|
|
1557
|
+
capabilities: {
|
|
1558
|
+
supportsGuidelines: true,
|
|
1559
|
+
supportsSkills: true,
|
|
1560
|
+
supportsMcp: true,
|
|
1561
|
+
supportsHooks: true
|
|
1562
|
+
},
|
|
1563
|
+
render(context) {
|
|
1564
|
+
return [
|
|
1565
|
+
{
|
|
1566
|
+
path: ".cursor/rules/node-boost.mdc",
|
|
1567
|
+
content: [
|
|
1568
|
+
"---",
|
|
1569
|
+
"alwaysApply: true",
|
|
1570
|
+
"---",
|
|
1571
|
+
"",
|
|
1572
|
+
"# node-boost",
|
|
1573
|
+
"",
|
|
1574
|
+
`Use ${context.guidelinesIndexPath} as the generated guidance index.`,
|
|
1575
|
+
`Skills are indexed in ${context.skillsIndexPath}.`,
|
|
1576
|
+
`MCP command: ${formatMcpCommand(context.mcpCommand)}.`,
|
|
1577
|
+
""
|
|
1578
|
+
].join("\n")
|
|
1579
|
+
},
|
|
1580
|
+
{
|
|
1581
|
+
path: ".cursor/mcp.json",
|
|
1582
|
+
content: mergeMcpJson(context.existingContent(".cursor/mcp.json"), context.mcpCommand)
|
|
1583
|
+
},
|
|
1584
|
+
{
|
|
1585
|
+
path: ".cursor/hooks.json",
|
|
1586
|
+
content: mergeCursorHooks(context.existingContent(".cursor/hooks.json"), context.hookCommands.cursor)
|
|
1587
|
+
}
|
|
1588
|
+
];
|
|
1589
|
+
}
|
|
1590
|
+
};
|
|
1591
|
+
|
|
1592
|
+
// src/agents/index.ts
|
|
1593
|
+
var agentInstallers = {
|
|
1594
|
+
"claude-code": claudeCodeAgent,
|
|
1595
|
+
codex: codexAgent,
|
|
1596
|
+
cursor: cursorAgent
|
|
1597
|
+
};
|
|
1598
|
+
|
|
1599
|
+
// src/compose/overrides.ts
|
|
1600
|
+
import { access as access3 } from "fs/promises";
|
|
1601
|
+
import { join as join9, relative as relative2 } from "path";
|
|
1602
|
+
async function applyResourceOverrides(projectRoot, resources) {
|
|
1603
|
+
return Promise.all(resources.map((resource) => applyResourceOverride(projectRoot, resource)));
|
|
1604
|
+
}
|
|
1605
|
+
async function applyResourceOverride(projectRoot, resource) {
|
|
1606
|
+
const outputRelative = normalize2(relative2(join9(".ai", resource.kind === "guideline" ? "guidelines" : "skills"), resource.outputPath));
|
|
1607
|
+
const overridePath = join9(projectRoot, ".node-boost", resource.kind === "guideline" ? "guidelines" : "skills", outputRelative);
|
|
1608
|
+
try {
|
|
1609
|
+
await access3(overridePath);
|
|
1610
|
+
return {
|
|
1611
|
+
...resource,
|
|
1612
|
+
sourcePath: overridePath
|
|
1613
|
+
};
|
|
1614
|
+
} catch {
|
|
1615
|
+
return resource;
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
function normalize2(path) {
|
|
1619
|
+
return path.split("\\").join("/");
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
// src/compose/guidelines.ts
|
|
1623
|
+
import { join as join11 } from "path";
|
|
1624
|
+
|
|
1625
|
+
// src/compose/resources.ts
|
|
1626
|
+
import { readdir as readdir2 } from "fs/promises";
|
|
1627
|
+
import { join as join10, relative as relative3 } from "path";
|
|
1628
|
+
async function listResourceFiles(rootDir, kind) {
|
|
1629
|
+
const baseDir = join10(rootDir, "resources", "react", kind);
|
|
1630
|
+
const files = await walkMarkdownFiles(baseDir);
|
|
1631
|
+
return files.map((file) => relative3(baseDir, file)).sort((a, b) => a.localeCompare(b));
|
|
1632
|
+
}
|
|
1633
|
+
async function walkMarkdownFiles(dir) {
|
|
1634
|
+
let entries;
|
|
1635
|
+
try {
|
|
1636
|
+
entries = await readdir2(dir, { withFileTypes: true });
|
|
1637
|
+
} catch {
|
|
1638
|
+
return [];
|
|
1639
|
+
}
|
|
1640
|
+
const files = await Promise.all(
|
|
1641
|
+
entries.map(async (entry) => {
|
|
1642
|
+
const fullPath = join10(dir, entry.name);
|
|
1643
|
+
if (entry.isDirectory()) {
|
|
1644
|
+
return walkMarkdownFiles(fullPath);
|
|
1645
|
+
}
|
|
1646
|
+
if (entry.isFile() && (entry.name.endsWith(".md") || entry.name === "SKILL.md")) {
|
|
1647
|
+
return [fullPath];
|
|
1648
|
+
}
|
|
1649
|
+
return [];
|
|
1650
|
+
})
|
|
1651
|
+
);
|
|
1652
|
+
return files.flat();
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
// src/compose/guidelines.ts
|
|
1656
|
+
var packageResourceMap = {
|
|
1657
|
+
react: "react",
|
|
1658
|
+
next: "next",
|
|
1659
|
+
vite: "vite",
|
|
1660
|
+
"react-router": "react-router",
|
|
1661
|
+
zod: "zod",
|
|
1662
|
+
"@tanstack/react-query": "react-query",
|
|
1663
|
+
zustand: "zustand",
|
|
1664
|
+
typescript: "typescript",
|
|
1665
|
+
tailwindcss: "tailwindcss",
|
|
1666
|
+
vitest: "testing",
|
|
1667
|
+
playwright: "testing"
|
|
1668
|
+
};
|
|
1669
|
+
async function composeGuidelines(rootDir, stack, architectures = []) {
|
|
1670
|
+
const availableFiles = await listResourceFiles(rootDir, "guidelines");
|
|
1671
|
+
const selected = /* @__PURE__ */ new Set(["core.md"]);
|
|
1672
|
+
for (const [packageName, resourceName] of Object.entries(packageResourceMap)) {
|
|
1673
|
+
const packageInfo = stack.packages[packageName];
|
|
1674
|
+
if (!packageInfo?.version) {
|
|
1675
|
+
continue;
|
|
1676
|
+
}
|
|
1677
|
+
addIfAvailable(selected, availableFiles, `${resourceName}/core.md`);
|
|
1678
|
+
if (packageInfo.major !== null) {
|
|
1679
|
+
addIfAvailable(selected, availableFiles, `${resourceName}/${packageInfo.major}.md`);
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
if (stack.packages.vitest?.version) {
|
|
1683
|
+
addIfAvailable(selected, availableFiles, "testing/vitest.md");
|
|
1684
|
+
}
|
|
1685
|
+
if (stack.packages.playwright?.version) {
|
|
1686
|
+
addIfAvailable(selected, availableFiles, "testing/playwright.md");
|
|
1687
|
+
}
|
|
1688
|
+
const packageGuidelines = [...selected].sort((a, b) => a.localeCompare(b)).map((file) => ({
|
|
1689
|
+
kind: "guideline",
|
|
1690
|
+
sourcePath: join11("resources", "react", "guidelines", file),
|
|
1691
|
+
outputPath: join11(".ai", "guidelines", file)
|
|
1692
|
+
}));
|
|
1693
|
+
const architectureGuidelines = architectures.map((architecture) => ({
|
|
1694
|
+
kind: "guideline",
|
|
1695
|
+
sourcePath: join11("resources", "react", "architectures", architecture.name, architectureVariantPath(architecture)),
|
|
1696
|
+
outputPath: join11(".ai", "guidelines", "architectures", `${architecture.name}.md`)
|
|
1697
|
+
}));
|
|
1698
|
+
return [...packageGuidelines, ...architectureGuidelines].sort((a, b) => a.outputPath.localeCompare(b.outputPath));
|
|
1699
|
+
}
|
|
1700
|
+
function addIfAvailable(selected, availableFiles, file) {
|
|
1701
|
+
if (availableFiles.includes(file)) {
|
|
1702
|
+
selected.add(file);
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
function architectureVariantPath(architecture) {
|
|
1706
|
+
if (architecture.name === "feature-modules" && typeof architecture.options.boundary === "string") {
|
|
1707
|
+
return join11("variants", `${architecture.options.boundary}.md`);
|
|
1708
|
+
}
|
|
1709
|
+
return "guideline.md";
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
// src/compose/skills.ts
|
|
1713
|
+
import { join as join12 } from "path";
|
|
1714
|
+
async function composeSkills(rootDir, stack, architectures = []) {
|
|
1715
|
+
const availableFiles = await listResourceFiles(rootDir, "skills");
|
|
1716
|
+
const selected = /* @__PURE__ */ new Set();
|
|
1717
|
+
if (stack.packages.react?.version) {
|
|
1718
|
+
selected.add("react-development/SKILL.md");
|
|
1719
|
+
}
|
|
1720
|
+
if (stack.name === "next") {
|
|
1721
|
+
selected.add("next-development/SKILL.md");
|
|
1722
|
+
}
|
|
1723
|
+
if (stack.name === "vite-react") {
|
|
1724
|
+
selected.add("spa-routing/SKILL.md");
|
|
1725
|
+
}
|
|
1726
|
+
if (stack.packages.tailwindcss?.version) {
|
|
1727
|
+
selected.add("tailwindcss-development/SKILL.md");
|
|
1728
|
+
}
|
|
1729
|
+
if (stack.packages.vitest?.version || stack.packages.playwright?.version) {
|
|
1730
|
+
selected.add("testing-frontend/SKILL.md");
|
|
1731
|
+
}
|
|
1732
|
+
const packageSkills = [...selected].filter((file) => availableFiles.includes(file)).sort((a, b) => a.localeCompare(b)).map((file) => ({
|
|
1733
|
+
kind: "skill",
|
|
1734
|
+
sourcePath: join12("resources", "react", "skills", file),
|
|
1735
|
+
outputPath: join12(".ai", "skills", file)
|
|
1736
|
+
}));
|
|
1737
|
+
const architectureSkills = architectures.map((architecture) => ({
|
|
1738
|
+
kind: "skill",
|
|
1739
|
+
sourcePath: join12("resources", "react", "architectures", architecture.name, "skill", "SKILL.md"),
|
|
1740
|
+
outputPath: join12(".ai", "skills", architecture.name, "SKILL.md")
|
|
1741
|
+
}));
|
|
1742
|
+
return [...packageSkills, ...architectureSkills].sort((a, b) => a.outputPath.localeCompare(b.outputPath));
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
// src/compose/index-file.ts
|
|
1746
|
+
function renderGuidelinesIndex(stack, architectures, guidelines) {
|
|
1747
|
+
const packages = Object.values(stack.packages).filter((pkg) => pkg.version).sort((a, b) => a.name.localeCompare(b.name)).map((pkg) => `- ${pkg.name}: ${pkg.version} (${pkg.source})`);
|
|
1748
|
+
const architectureLines = architectures.map((architecture) => {
|
|
1749
|
+
const suffix = architecture.name === "feature-modules" ? ` (${featureModulesBoundary(architecture)})` : "";
|
|
1750
|
+
return `- ${architecture.name}${suffix}`;
|
|
1751
|
+
});
|
|
1752
|
+
const fileLines = guidelines.map((guideline) => `- ${guideline.outputPath}: ${sourceLabel(guideline.sourcePath)}`).sort((a, b) => a.localeCompare(b));
|
|
1753
|
+
return [
|
|
1754
|
+
"# node-boost guidelines",
|
|
1755
|
+
"",
|
|
1756
|
+
"## Stack",
|
|
1757
|
+
"",
|
|
1758
|
+
`- name: ${stack.name}`,
|
|
1759
|
+
`- router: ${stack.router}`,
|
|
1760
|
+
`- package manager: ${stack.packageManager.name}`,
|
|
1761
|
+
`- linting: ${stack.linting}`,
|
|
1762
|
+
"",
|
|
1763
|
+
"## Packages",
|
|
1764
|
+
"",
|
|
1765
|
+
...packages.length ? packages : ["- none detected"],
|
|
1766
|
+
"",
|
|
1767
|
+
"## Architectures",
|
|
1768
|
+
"",
|
|
1769
|
+
...architectureLines.length ? architectureLines : ["- none selected"],
|
|
1770
|
+
"",
|
|
1771
|
+
"## Files",
|
|
1772
|
+
"",
|
|
1773
|
+
...fileLines,
|
|
1774
|
+
""
|
|
1775
|
+
].join("\n");
|
|
1776
|
+
}
|
|
1777
|
+
function sourceLabel(sourcePath) {
|
|
1778
|
+
return sourcePath.includes(".node-boost") ? "project override" : "node-boost built-in";
|
|
1779
|
+
}
|
|
1780
|
+
function featureModulesBoundary(architecture) {
|
|
1781
|
+
return typeof architecture.options.boundary === "string" ? architecture.options.boundary : "public-api";
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// src/stacks/architectures.ts
|
|
1785
|
+
var commonArchitectures = [
|
|
1786
|
+
"feature-modules",
|
|
1787
|
+
"data-access-layer",
|
|
1788
|
+
"typed-contracts",
|
|
1789
|
+
"state-management",
|
|
1790
|
+
"custom-hooks",
|
|
1791
|
+
"component-composition",
|
|
1792
|
+
"testing-strategy",
|
|
1793
|
+
"secure-by-default",
|
|
1794
|
+
"modern-typescript",
|
|
1795
|
+
"ui-states"
|
|
1796
|
+
];
|
|
1797
|
+
var nextOnlyArchitectures = [
|
|
1798
|
+
"server-first-components",
|
|
1799
|
+
"error-loading-boundaries"
|
|
1800
|
+
];
|
|
1801
|
+
var tailwindArchitecture = "styling-tailwind";
|
|
1802
|
+
function sortArchitectures(architectures) {
|
|
1803
|
+
return [...architectures].sort((a, b) => a.localeCompare(b));
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
// src/stacks/next.ts
|
|
1807
|
+
var nextStackAdapter = {
|
|
1808
|
+
name: "next",
|
|
1809
|
+
label: "Next.js",
|
|
1810
|
+
supports(stack) {
|
|
1811
|
+
return stack.name === "next";
|
|
1812
|
+
},
|
|
1813
|
+
applicableArchitectures(stack) {
|
|
1814
|
+
const architectures = [...commonArchitectures, ...nextOnlyArchitectures];
|
|
1815
|
+
if (hasPackage(stack, "tailwindcss")) {
|
|
1816
|
+
architectures.push(tailwindArchitecture);
|
|
1817
|
+
}
|
|
1818
|
+
return sortArchitectures(architectures);
|
|
1819
|
+
},
|
|
1820
|
+
recommendedArchitectures(stack) {
|
|
1821
|
+
return this.applicableArchitectures(stack);
|
|
1822
|
+
}
|
|
1823
|
+
};
|
|
1824
|
+
|
|
1825
|
+
// src/stacks/vite-react.ts
|
|
1826
|
+
var viteReactStackAdapter = {
|
|
1827
|
+
name: "vite-react",
|
|
1828
|
+
label: "Vite React",
|
|
1829
|
+
supports(stack) {
|
|
1830
|
+
return stack.name === "vite-react";
|
|
1831
|
+
},
|
|
1832
|
+
applicableArchitectures(stack) {
|
|
1833
|
+
const architectures = [...commonArchitectures];
|
|
1834
|
+
if (hasPackage(stack, "tailwindcss")) {
|
|
1835
|
+
architectures.push(tailwindArchitecture);
|
|
1836
|
+
}
|
|
1837
|
+
return sortArchitectures(architectures);
|
|
1838
|
+
},
|
|
1839
|
+
recommendedArchitectures(stack) {
|
|
1840
|
+
return this.applicableArchitectures(stack);
|
|
1841
|
+
}
|
|
1842
|
+
};
|
|
1843
|
+
|
|
1844
|
+
// src/stacks/adapter.ts
|
|
1845
|
+
var stackAdapters = [nextStackAdapter, viteReactStackAdapter];
|
|
1846
|
+
function getStackAdapter(stack) {
|
|
1847
|
+
return stackAdapters.find((adapter) => adapter.supports(stack)) ?? null;
|
|
1848
|
+
}
|
|
1849
|
+
function hasPackage(stack, packageName) {
|
|
1850
|
+
return Boolean(stack.packages[packageName]?.version);
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
// src/install/project.ts
|
|
1854
|
+
import { access as access4, readFile as readFile4 } from "fs/promises";
|
|
1855
|
+
import { dirname as dirname4, join as join13 } from "path";
|
|
1856
|
+
import { fileURLToPath } from "url";
|
|
1857
|
+
async function findNearestPackageRoot(cwd) {
|
|
1858
|
+
let current = cwd;
|
|
1859
|
+
while (true) {
|
|
1860
|
+
if (await pathExists(join13(current, "package.json"))) {
|
|
1861
|
+
return current;
|
|
1862
|
+
}
|
|
1863
|
+
const parent = dirname4(current);
|
|
1864
|
+
if (parent === current) {
|
|
1865
|
+
return null;
|
|
1866
|
+
}
|
|
1867
|
+
current = parent;
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
async function readPackageJson2(rootDir) {
|
|
1871
|
+
const raw = await readFile4(join13(rootDir, "package.json"), "utf8");
|
|
1872
|
+
const parsed = JSON.parse(raw);
|
|
1873
|
+
if (!isObject6(parsed)) {
|
|
1874
|
+
throw new Error("package.json must contain an object.");
|
|
1875
|
+
}
|
|
1876
|
+
return parsed;
|
|
1877
|
+
}
|
|
1878
|
+
async function isWorkspaceRoot(rootDir) {
|
|
1879
|
+
const packageJson = await readPackageJson2(rootDir);
|
|
1880
|
+
return Boolean(packageJson.workspaces) || await pathExists(join13(rootDir, "pnpm-workspace.yaml"));
|
|
1881
|
+
}
|
|
1882
|
+
async function resolveDefaultPackageRoot(importMetaUrl) {
|
|
1883
|
+
let current = dirname4(fileURLToPath(importMetaUrl));
|
|
1884
|
+
while (true) {
|
|
1885
|
+
if (await pathExists(join13(current, "resources", "react"))) {
|
|
1886
|
+
return current;
|
|
1887
|
+
}
|
|
1888
|
+
const parent = dirname4(current);
|
|
1889
|
+
if (parent === current) {
|
|
1890
|
+
return process.cwd();
|
|
1891
|
+
}
|
|
1892
|
+
current = parent;
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
async function pathExists(path) {
|
|
1896
|
+
try {
|
|
1897
|
+
await access4(path);
|
|
1898
|
+
return true;
|
|
1899
|
+
} catch {
|
|
1900
|
+
return false;
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
function isObject6(value) {
|
|
1904
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
// src/install/orchestrator.ts
|
|
1908
|
+
var allAgents = ["claude-code", "codex", "cursor"];
|
|
1909
|
+
var defaultFeatures2 = {
|
|
1910
|
+
guidelines: true,
|
|
1911
|
+
skills: true,
|
|
1912
|
+
mcp: true,
|
|
1913
|
+
architecture: true,
|
|
1914
|
+
hooks: false
|
|
1915
|
+
};
|
|
1916
|
+
async function runInstall(options = {}) {
|
|
1917
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1918
|
+
const packageRoot = options.packageRoot ?? await resolveDefaultPackageRoot(import.meta.url);
|
|
1919
|
+
const projectRoot = await resolveProjectRoot(cwd);
|
|
1920
|
+
const stack = await detectStack(projectRoot);
|
|
1921
|
+
const config = options.noInteraction ? await createDefaultConfig(packageRoot, projectRoot, stack) : await promptForConfig(packageRoot, projectRoot, stack);
|
|
1922
|
+
const operations = await buildInstallOperations({ packageRoot, projectRoot, stack, config });
|
|
1923
|
+
const applied = await applyFileOperations(projectRoot, operations);
|
|
1924
|
+
if (!options.noInteraction) {
|
|
1925
|
+
outro(renderSummary(applied));
|
|
1926
|
+
}
|
|
1927
|
+
return {
|
|
1928
|
+
projectRoot,
|
|
1929
|
+
stack,
|
|
1930
|
+
config,
|
|
1931
|
+
operations: applied
|
|
1932
|
+
};
|
|
1933
|
+
}
|
|
1934
|
+
async function runUpdate(options = {}) {
|
|
1935
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1936
|
+
const packageRoot = options.packageRoot ?? await resolveDefaultPackageRoot(import.meta.url);
|
|
1937
|
+
const projectRoot = await resolveProjectRoot(cwd);
|
|
1938
|
+
const stack = await detectStack(projectRoot);
|
|
1939
|
+
const configPath = join14(projectRoot, "node-boost.json");
|
|
1940
|
+
const rawConfig = await readFile5(configPath, "utf8");
|
|
1941
|
+
const config = parseConfigWithReadableErrors(JSON.parse(rawConfig));
|
|
1942
|
+
const operations = await buildInstallOperations({ packageRoot, projectRoot, stack, config });
|
|
1943
|
+
return {
|
|
1944
|
+
projectRoot,
|
|
1945
|
+
stack,
|
|
1946
|
+
config,
|
|
1947
|
+
operations: await applyFileOperations(projectRoot, operations)
|
|
1948
|
+
};
|
|
1949
|
+
}
|
|
1950
|
+
async function buildInstallOperations(input) {
|
|
1951
|
+
const architectures = input.config.features.architecture ? normalizeArchitectures(input.config) : [];
|
|
1952
|
+
const selectedGuidelines = input.config.features.guidelines ? await applyResourceOverrides(input.projectRoot, await composeGuidelines(input.packageRoot, input.stack, architectures)) : [];
|
|
1953
|
+
const selectedSkills = input.config.features.skills ? await applyResourceOverrides(input.projectRoot, await composeSkills(input.packageRoot, input.stack, architectures)) : [];
|
|
1954
|
+
const guidelineOperations = await readResourceOperations(input.packageRoot, selectedGuidelines);
|
|
1955
|
+
const skillOperations = await readResourceOperations(input.packageRoot, selectedSkills);
|
|
1956
|
+
const skillContentByOutputPath = new Map(skillOperations.map((operation) => [operation.path, operation.content]));
|
|
1957
|
+
const existingContent = await preloadExisting(input.projectRoot, [
|
|
1958
|
+
"CLAUDE.md",
|
|
1959
|
+
"AGENTS.md",
|
|
1960
|
+
".mcp.json",
|
|
1961
|
+
".claude/settings.json",
|
|
1962
|
+
".codex/hooks.json",
|
|
1963
|
+
".codex/config.toml",
|
|
1964
|
+
".cursor/hooks.json",
|
|
1965
|
+
".cursor/mcp.json"
|
|
1966
|
+
]);
|
|
1967
|
+
const mcpCommand2 = createMcpCommand(input.stack.packageManager.name);
|
|
1968
|
+
const hookCommands = {
|
|
1969
|
+
"claude-code": createHookCommand(input.stack.packageManager.name, "claude-code"),
|
|
1970
|
+
codex: createHookCommand(input.stack.packageManager.name, "codex"),
|
|
1971
|
+
cursor: createHookCommand(input.stack.packageManager.name, "cursor")
|
|
1972
|
+
};
|
|
1973
|
+
const agentOperations = input.config.agents.flatMap(
|
|
1974
|
+
(agentName) => agentInstallers[agentName].render({
|
|
1975
|
+
guidelinesIndexPath: ".ai/guidelines/node-boost.md",
|
|
1976
|
+
skillsIndexPath: ".ai/skills",
|
|
1977
|
+
selectedSkills,
|
|
1978
|
+
existingContent: (path) => existingContent.get(path) ?? null,
|
|
1979
|
+
skillContentByOutputPath,
|
|
1980
|
+
mcpCommand: mcpCommand2,
|
|
1981
|
+
hookCommands
|
|
1982
|
+
}).filter((operation) => keepOperationForFeatures(operation.path, input.config.features))
|
|
1983
|
+
);
|
|
1984
|
+
return dedupeOperations([
|
|
1985
|
+
...guidelineOperations,
|
|
1986
|
+
{
|
|
1987
|
+
path: ".ai/guidelines/node-boost.md",
|
|
1988
|
+
content: renderGuidelinesIndex(input.stack, architectures, selectedGuidelines)
|
|
1989
|
+
},
|
|
1990
|
+
...skillOperations,
|
|
1991
|
+
...agentOperations,
|
|
1992
|
+
{
|
|
1993
|
+
path: "node-boost.json",
|
|
1994
|
+
content: `${JSON.stringify(input.config, null, 2)}
|
|
1995
|
+
`
|
|
1996
|
+
}
|
|
1997
|
+
]);
|
|
1998
|
+
}
|
|
1999
|
+
async function applyFileOperations(projectRoot, operations) {
|
|
2000
|
+
const results = [];
|
|
2001
|
+
for (const operation of operations) {
|
|
2002
|
+
const target = join14(projectRoot, operation.path);
|
|
2003
|
+
const previous = await readOptional(target);
|
|
2004
|
+
const status = previous === null ? "created" : previous === operation.content ? "skipped" : "updated";
|
|
2005
|
+
if (status !== "skipped") {
|
|
2006
|
+
await mkdir(dirname5(target), { recursive: true });
|
|
2007
|
+
await writeFile(target, operation.content, "utf8");
|
|
2008
|
+
}
|
|
2009
|
+
results.push({ ...operation, status });
|
|
2010
|
+
}
|
|
2011
|
+
return results;
|
|
2012
|
+
}
|
|
2013
|
+
async function resolveProjectRoot(cwd) {
|
|
2014
|
+
const projectRoot = await findNearestPackageRoot(cwd);
|
|
2015
|
+
if (!projectRoot) {
|
|
2016
|
+
throw new Error("No package.json found. Run node-boost inside a Node project.");
|
|
2017
|
+
}
|
|
2018
|
+
const stack = await detectStack(projectRoot);
|
|
2019
|
+
if (stack.name === "unknown" && await isWorkspaceRoot(projectRoot)) {
|
|
2020
|
+
throw new Error("Workspace root has no detectable React stack. Run node-boost in an app directory, for example apps/web.");
|
|
2021
|
+
}
|
|
2022
|
+
return projectRoot;
|
|
2023
|
+
}
|
|
2024
|
+
async function createDefaultConfig(packageRoot, projectRoot, stack) {
|
|
2025
|
+
return nodeBoostConfigSchema.parse({
|
|
2026
|
+
$schema: "./schema.json",
|
|
2027
|
+
version: 1,
|
|
2028
|
+
generatedWith: await readPackageVersion(packageRoot),
|
|
2029
|
+
stack: stack.name,
|
|
2030
|
+
agents: allAgents,
|
|
2031
|
+
features: defaultFeatures2,
|
|
2032
|
+
architectures: await defaultArchitectures(projectRoot, stack),
|
|
2033
|
+
audit: {
|
|
2034
|
+
exclude: [],
|
|
2035
|
+
rules: {},
|
|
2036
|
+
ruleOptions: {}
|
|
2037
|
+
}
|
|
2038
|
+
});
|
|
2039
|
+
}
|
|
2040
|
+
async function promptForConfig(packageRoot, projectRoot, stack) {
|
|
2041
|
+
intro(`node-boost install: ${stack.name} / ${stack.packageManager.name}`);
|
|
2042
|
+
const agents = await multiselect({
|
|
2043
|
+
message: "Select agents",
|
|
2044
|
+
options: allAgents.map((agent) => ({ label: agent, value: agent })),
|
|
2045
|
+
initialValues: [...allAgents],
|
|
2046
|
+
required: true
|
|
2047
|
+
});
|
|
2048
|
+
abortIfCancelled(agents);
|
|
2049
|
+
const features = await multiselect({
|
|
2050
|
+
message: "Select features",
|
|
2051
|
+
options: [
|
|
2052
|
+
{ label: "Guidelines", value: "guidelines" },
|
|
2053
|
+
{ label: "Skills", value: "skills" },
|
|
2054
|
+
{ label: "MCP config", value: "mcp" },
|
|
2055
|
+
{ label: "Architecture guidance", value: "architecture" },
|
|
2056
|
+
{ label: "Hooks config flag", value: "hooks" }
|
|
2057
|
+
],
|
|
2058
|
+
initialValues: ["guidelines", "skills", "mcp", "architecture"],
|
|
2059
|
+
required: true
|
|
2060
|
+
});
|
|
2061
|
+
abortIfCancelled(features);
|
|
2062
|
+
const featureConfig = {
|
|
2063
|
+
guidelines: features.includes("guidelines"),
|
|
2064
|
+
skills: features.includes("skills"),
|
|
2065
|
+
mcp: features.includes("mcp"),
|
|
2066
|
+
architecture: features.includes("architecture"),
|
|
2067
|
+
hooks: features.includes("hooks")
|
|
2068
|
+
};
|
|
2069
|
+
const architectures = featureConfig.architecture ? await promptArchitectures(projectRoot, stack) : [];
|
|
2070
|
+
const confirmed = await confirm({
|
|
2071
|
+
message: "Write node-boost files?",
|
|
2072
|
+
initialValue: true
|
|
2073
|
+
});
|
|
2074
|
+
abortIfCancelled(confirmed);
|
|
2075
|
+
if (!confirmed) {
|
|
2076
|
+
cancel("Install cancelled.");
|
|
2077
|
+
process.exitCode = 1;
|
|
2078
|
+
throw new Error("Install cancelled.");
|
|
2079
|
+
}
|
|
2080
|
+
return nodeBoostConfigSchema.parse({
|
|
2081
|
+
$schema: "./schema.json",
|
|
2082
|
+
version: 1,
|
|
2083
|
+
generatedWith: await readPackageVersion(packageRoot),
|
|
2084
|
+
stack: stack.name,
|
|
2085
|
+
agents,
|
|
2086
|
+
features: featureConfig,
|
|
2087
|
+
architectures,
|
|
2088
|
+
audit: {
|
|
2089
|
+
exclude: [],
|
|
2090
|
+
rules: {},
|
|
2091
|
+
ruleOptions: {}
|
|
2092
|
+
}
|
|
2093
|
+
});
|
|
2094
|
+
}
|
|
2095
|
+
async function promptArchitectures(projectRoot, stack) {
|
|
2096
|
+
const adapter = getStackAdapter(stack);
|
|
2097
|
+
const recommended = adapter?.recommendedArchitectures(stack) ?? [];
|
|
2098
|
+
const selected = await multiselect({
|
|
2099
|
+
message: "Select architecture guidance",
|
|
2100
|
+
options: recommended.map((architecture) => ({ label: architecture, value: architecture })),
|
|
2101
|
+
initialValues: recommended,
|
|
2102
|
+
required: false
|
|
2103
|
+
});
|
|
2104
|
+
abortIfCancelled(selected);
|
|
2105
|
+
if (!selected.includes("feature-modules")) {
|
|
2106
|
+
return selected;
|
|
2107
|
+
}
|
|
2108
|
+
const suggestedBoundary = await suggestFeatureModulesBoundary(projectRoot);
|
|
2109
|
+
const boundary = await select({
|
|
2110
|
+
message: "Feature modules boundary",
|
|
2111
|
+
options: [
|
|
2112
|
+
{ label: "Public API imports", value: "public-api", hint: "Use when features already import from each other." },
|
|
2113
|
+
{ label: "Forbid cross-feature imports", value: "forbid", hint: "Use for greenfield or clean module boundaries." }
|
|
2114
|
+
],
|
|
2115
|
+
initialValue: suggestedBoundary
|
|
2116
|
+
});
|
|
2117
|
+
abortIfCancelled(boundary);
|
|
2118
|
+
return selected.map(
|
|
2119
|
+
(architecture) => architecture === "feature-modules" ? { name: "feature-modules", boundary } : architecture
|
|
2120
|
+
);
|
|
2121
|
+
}
|
|
2122
|
+
async function defaultArchitectures(projectRoot, stack) {
|
|
2123
|
+
const adapter = getStackAdapter(stack);
|
|
2124
|
+
const architectures = adapter?.recommendedArchitectures(stack) ?? [];
|
|
2125
|
+
const featureModulesBoundary2 = await suggestFeatureModulesBoundary(projectRoot);
|
|
2126
|
+
return architectures.map(
|
|
2127
|
+
(architecture) => architecture === "feature-modules" ? { name: "feature-modules", boundary: featureModulesBoundary2 } : architecture
|
|
2128
|
+
);
|
|
2129
|
+
}
|
|
2130
|
+
async function suggestFeatureModulesBoundary(projectRoot) {
|
|
2131
|
+
return await pathExists(join14(projectRoot, "features")) || await pathExists(join14(projectRoot, "src", "features")) ? "public-api" : "forbid";
|
|
2132
|
+
}
|
|
2133
|
+
async function readResourceOperations(packageRoot, resources) {
|
|
2134
|
+
return Promise.all(
|
|
2135
|
+
resources.map(async (resource) => ({
|
|
2136
|
+
path: resource.outputPath,
|
|
2137
|
+
content: await readSource(packageRoot, resource.sourcePath)
|
|
2138
|
+
}))
|
|
2139
|
+
);
|
|
2140
|
+
}
|
|
2141
|
+
async function readSource(packageRoot, sourcePath) {
|
|
2142
|
+
const raw = await readFile5(isAbsolute(sourcePath) ? sourcePath : join14(packageRoot, sourcePath), "utf8");
|
|
2143
|
+
return raw.endsWith("\n") ? raw : `${raw}
|
|
2144
|
+
`;
|
|
2145
|
+
}
|
|
2146
|
+
async function preloadExisting(projectRoot, paths) {
|
|
2147
|
+
const entries = await Promise.all(paths.map(async (path) => [path, await readOptional(join14(projectRoot, path))]));
|
|
2148
|
+
return new Map(entries);
|
|
2149
|
+
}
|
|
2150
|
+
async function readOptional(path) {
|
|
2151
|
+
try {
|
|
2152
|
+
return await readFile5(path, "utf8");
|
|
2153
|
+
} catch {
|
|
2154
|
+
return null;
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
async function readPackageVersion(packageRoot) {
|
|
2158
|
+
const packageJson = await readPackageJson2(packageRoot);
|
|
2159
|
+
return packageJson.version ?? "0.0.0";
|
|
2160
|
+
}
|
|
2161
|
+
function keepOperationForFeatures(path, features) {
|
|
2162
|
+
if ((path === ".mcp.json" || path === ".codex/config.toml" || path === ".cursor/mcp.json") && !features.mcp) {
|
|
2163
|
+
return false;
|
|
2164
|
+
}
|
|
2165
|
+
if ((path === ".claude/settings.json" || path === ".codex/hooks.json" || path === ".cursor/hooks.json") && !features.hooks) {
|
|
2166
|
+
return false;
|
|
2167
|
+
}
|
|
2168
|
+
if ((path.startsWith(".claude/skills/") || path.startsWith(".agents/skills/")) && !features.skills) {
|
|
2169
|
+
return false;
|
|
2170
|
+
}
|
|
2171
|
+
if ((path === "CLAUDE.md" || path === "AGENTS.md" || path === ".cursor/rules/node-boost.mdc") && !features.guidelines && !features.skills) {
|
|
2172
|
+
return false;
|
|
2173
|
+
}
|
|
2174
|
+
return true;
|
|
2175
|
+
}
|
|
2176
|
+
function dedupeOperations(operations) {
|
|
2177
|
+
return [...new Map(operations.map((operation) => [operation.path, operation])).values()].sort(
|
|
2178
|
+
(a, b) => a.path.localeCompare(b.path)
|
|
2179
|
+
);
|
|
2180
|
+
}
|
|
2181
|
+
function parseConfigWithReadableErrors(input) {
|
|
2182
|
+
try {
|
|
2183
|
+
return parseNodeBoostConfig(input);
|
|
2184
|
+
} catch (error) {
|
|
2185
|
+
if (error instanceof ZodError) {
|
|
2186
|
+
const issues = error.issues.map((issue) => `${issue.path.join(".") || "<root>"}: ${issue.message}`).join("\n");
|
|
2187
|
+
throw new Error(`Invalid node-boost.json:
|
|
2188
|
+
${issues}`, { cause: error });
|
|
2189
|
+
}
|
|
2190
|
+
throw error;
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
function abortIfCancelled(value) {
|
|
2194
|
+
if (isCancel(value)) {
|
|
2195
|
+
cancel("Install cancelled.");
|
|
2196
|
+
process.exitCode = 1;
|
|
2197
|
+
throw new Error("Install cancelled.");
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
function renderSummary(results) {
|
|
2201
|
+
const counts = results.reduce(
|
|
2202
|
+
(summary, result) => {
|
|
2203
|
+
summary[result.status] += 1;
|
|
2204
|
+
return summary;
|
|
2205
|
+
},
|
|
2206
|
+
{ created: 0, updated: 0, skipped: 0 }
|
|
2207
|
+
);
|
|
2208
|
+
return `created ${counts.created}, updated ${counts.updated}, skipped ${counts.skipped}`;
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
// src/mcp/project.ts
|
|
2212
|
+
import { readFile as readFile6 } from "fs/promises";
|
|
2213
|
+
import { join as join15 } from "path";
|
|
2214
|
+
import { ZodError as ZodError2 } from "zod";
|
|
2215
|
+
async function readBoostConfig(rootDir) {
|
|
2216
|
+
try {
|
|
2217
|
+
const raw = await readFile6(join15(rootDir, "node-boost.json"), "utf8");
|
|
2218
|
+
const parsed = JSON.parse(raw);
|
|
2219
|
+
const config = parseNodeBoostConfig(parsed);
|
|
2220
|
+
return {
|
|
2221
|
+
config,
|
|
2222
|
+
architectures: normalizeArchitectures(config),
|
|
2223
|
+
error: null
|
|
2224
|
+
};
|
|
2225
|
+
} catch (error) {
|
|
2226
|
+
return {
|
|
2227
|
+
config: null,
|
|
2228
|
+
architectures: [],
|
|
2229
|
+
error: normalizeConfigError(error)
|
|
2230
|
+
};
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
async function readTypescriptStrict(rootDir) {
|
|
2234
|
+
try {
|
|
2235
|
+
const raw = await readFile6(join15(rootDir, "tsconfig.json"), "utf8");
|
|
2236
|
+
const parsed = JSON.parse(raw);
|
|
2237
|
+
if (isObject7(parsed) && isObject7(parsed.compilerOptions) && typeof parsed.compilerOptions.strict === "boolean") {
|
|
2238
|
+
return parsed.compilerOptions.strict;
|
|
2239
|
+
}
|
|
2240
|
+
return null;
|
|
2241
|
+
} catch {
|
|
2242
|
+
return null;
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
function normalizeConfigError(error) {
|
|
2246
|
+
if (error instanceof SyntaxError) {
|
|
2247
|
+
return new Error(`Invalid JSON: ${error.message}`, { cause: error });
|
|
2248
|
+
}
|
|
2249
|
+
if (error instanceof ZodError2) {
|
|
2250
|
+
const message = error.issues.map((issue) => `${issue.path.join(".") || "<root>"}: ${issue.message}`).join("; ");
|
|
2251
|
+
return new Error(message, { cause: error });
|
|
2252
|
+
}
|
|
2253
|
+
if (error instanceof Error) {
|
|
2254
|
+
return error;
|
|
2255
|
+
}
|
|
2256
|
+
return new Error("Unknown config error.");
|
|
2257
|
+
}
|
|
2258
|
+
function isObject7(value) {
|
|
2259
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
// src/mcp/tools/doctor.ts
|
|
2263
|
+
async function doctorTool(rootDir, boostVersion) {
|
|
2264
|
+
const checks = [];
|
|
2265
|
+
const configRaw = await readOptional2(join16(rootDir, "node-boost.json"));
|
|
2266
|
+
const boostConfig = await readBoostConfig(rootDir);
|
|
2267
|
+
const stack = await detectStack(rootDir);
|
|
2268
|
+
checks.push({
|
|
2269
|
+
id: "config-present",
|
|
2270
|
+
status: configRaw === null ? "fail" : "pass",
|
|
2271
|
+
message: configRaw === null ? "node-boost.json is missing. Run node-boost install." : "node-boost.json is present."
|
|
2272
|
+
});
|
|
2273
|
+
checks.push({
|
|
2274
|
+
id: "config-valid",
|
|
2275
|
+
status: boostConfig.config ? "pass" : "fail",
|
|
2276
|
+
message: boostConfig.config ? "node-boost.json is valid." : `node-boost.json is invalid: ${boostConfig.error?.message ?? "unknown error"}`
|
|
2277
|
+
});
|
|
2278
|
+
checks.push({
|
|
2279
|
+
id: "generated-with-drift",
|
|
2280
|
+
status: !boostConfig.config || boostConfig.config.generatedWith === boostVersion ? "pass" : "warn",
|
|
2281
|
+
message: !boostConfig.config ? "No generatedWith drift can be checked without a valid config." : boostConfig.config.generatedWith === boostVersion ? "generatedWith matches the installed package version." : `generatedWith is ${boostConfig.config.generatedWith}, package is ${boostVersion}. Run node-boost update.`
|
|
2282
|
+
});
|
|
2283
|
+
checks.push({
|
|
2284
|
+
id: "stack-detected",
|
|
2285
|
+
status: stack.name === "unknown" ? "fail" : "pass",
|
|
2286
|
+
message: stack.name === "unknown" ? "No supported React stack detected." : `Detected ${stack.name}.`
|
|
2287
|
+
});
|
|
2288
|
+
if (!boostConfig.config) {
|
|
2289
|
+
checks.push(skipCheck("resources-fresh", "Requires a valid node-boost.json."));
|
|
2290
|
+
checks.push(skipCheck("agent-files-present", "Requires a valid node-boost.json."));
|
|
2291
|
+
checks.push(await overridesCheck(rootDir));
|
|
2292
|
+
checks.push(skipCheck("hooks-wired", "Requires a valid node-boost.json."));
|
|
2293
|
+
checks.push(await lintStrictCheck(rootDir));
|
|
2294
|
+
return withOk(checks);
|
|
2295
|
+
}
|
|
2296
|
+
const packageRoot = await resolveDefaultPackageRoot(import.meta.url);
|
|
2297
|
+
const expectedOperations = await buildInstallOperations({
|
|
2298
|
+
packageRoot,
|
|
2299
|
+
projectRoot: rootDir,
|
|
2300
|
+
stack,
|
|
2301
|
+
config: boostConfig.config
|
|
2302
|
+
});
|
|
2303
|
+
checks.push(await resourcesFreshCheck(rootDir, expectedOperations));
|
|
2304
|
+
checks.push(await agentFilesPresentCheck(rootDir, expectedOperations));
|
|
2305
|
+
checks.push(await overridesCheck(rootDir));
|
|
2306
|
+
checks.push(await hooksWiredCheck(rootDir, boostConfig.config.features.hooks, expectedOperations));
|
|
2307
|
+
checks.push(await lintStrictCheck(rootDir));
|
|
2308
|
+
return withOk(checks);
|
|
2309
|
+
}
|
|
2310
|
+
async function resourcesFreshCheck(rootDir, expectedOperations) {
|
|
2311
|
+
const stale = await changedExpectedFiles(rootDir, expectedOperations.filter((operation) => operation.path.startsWith(".ai/")));
|
|
2312
|
+
return {
|
|
2313
|
+
id: "resources-fresh",
|
|
2314
|
+
status: stale.length === 0 ? "pass" : "warn",
|
|
2315
|
+
message: stale.length === 0 ? "Generated .ai resources match current node-boost output." : `${stale.length} generated .ai resource(s) differ. Run node-boost update.`,
|
|
2316
|
+
details: stale
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
async function agentFilesPresentCheck(rootDir, expectedOperations) {
|
|
2320
|
+
const expected = expectedOperations.filter((operation) => !operation.path.startsWith(".ai/") && operation.path !== "node-boost.json").map((operation) => operation.path);
|
|
2321
|
+
const missing = await missingFiles(rootDir, expected);
|
|
2322
|
+
return {
|
|
2323
|
+
id: "agent-files-present",
|
|
2324
|
+
status: missing.length === 0 ? "pass" : "fail",
|
|
2325
|
+
message: missing.length === 0 ? "Configured agent files are present." : `${missing.length} configured agent file(s) missing. Run node-boost update.`,
|
|
2326
|
+
details: missing
|
|
2327
|
+
};
|
|
2328
|
+
}
|
|
2329
|
+
async function hooksWiredCheck(rootDir, hooksEnabled, expectedOperations) {
|
|
2330
|
+
if (!hooksEnabled) {
|
|
2331
|
+
return {
|
|
2332
|
+
id: "hooks-wired",
|
|
2333
|
+
status: "pass",
|
|
2334
|
+
message: "Hooks feature is disabled."
|
|
2335
|
+
};
|
|
2336
|
+
}
|
|
2337
|
+
const hookOperations = expectedOperations.filter(
|
|
2338
|
+
(operation) => operation.path === ".claude/settings.json" || operation.path === ".codex/hooks.json" || operation.path === ".cursor/hooks.json"
|
|
2339
|
+
);
|
|
2340
|
+
const stale = await changedExpectedFiles(rootDir, hookOperations);
|
|
2341
|
+
return {
|
|
2342
|
+
id: "hooks-wired",
|
|
2343
|
+
status: stale.length === 0 ? "pass" : "fail",
|
|
2344
|
+
message: stale.length === 0 ? "Configured hook entries are wired." : `${stale.length} hook config file(s) missing or stale. Run node-boost update.`,
|
|
2345
|
+
details: stale
|
|
2346
|
+
};
|
|
2347
|
+
}
|
|
2348
|
+
async function overridesCheck(rootDir) {
|
|
2349
|
+
const overrides = await listFiles(join16(rootDir, ".node-boost"));
|
|
2350
|
+
return {
|
|
2351
|
+
id: "overrides-detected",
|
|
2352
|
+
status: "pass",
|
|
2353
|
+
message: overrides.length === 0 ? "No .node-boost overrides detected." : `${overrides.length} .node-boost override(s) active.`,
|
|
2354
|
+
details: overrides.map((file) => relative4(join16(rootDir, ".node-boost"), file).replaceAll("\\", "/"))
|
|
2355
|
+
};
|
|
2356
|
+
}
|
|
2357
|
+
async function lintStrictCheck(rootDir) {
|
|
2358
|
+
const strict = await tsconfigStrict2(rootDir);
|
|
2359
|
+
return {
|
|
2360
|
+
id: "lint-strict",
|
|
2361
|
+
status: strict === true ? "pass" : "warn",
|
|
2362
|
+
message: strict === true ? "TypeScript strict mode is enabled." : "TypeScript strict mode is not enabled. Enable compilerOptions.strict."
|
|
2363
|
+
};
|
|
2364
|
+
}
|
|
2365
|
+
async function changedExpectedFiles(rootDir, expectedOperations) {
|
|
2366
|
+
const changed = await Promise.all(
|
|
2367
|
+
expectedOperations.map(async (operation) => {
|
|
2368
|
+
const current = await readOptional2(join16(rootDir, operation.path));
|
|
2369
|
+
return current === operation.content ? null : operation.path;
|
|
2370
|
+
})
|
|
2371
|
+
);
|
|
2372
|
+
return changed.filter((path) => path !== null).sort((a, b) => a.localeCompare(b));
|
|
2373
|
+
}
|
|
2374
|
+
async function missingFiles(rootDir, paths) {
|
|
2375
|
+
const missing = await Promise.all(paths.map(async (path) => await readOptional2(join16(rootDir, path)) === null ? path : null));
|
|
2376
|
+
return missing.filter((path) => path !== null).sort((a, b) => a.localeCompare(b));
|
|
2377
|
+
}
|
|
2378
|
+
async function listFiles(dir) {
|
|
2379
|
+
try {
|
|
2380
|
+
const entries = await readdir3(dir, { withFileTypes: true });
|
|
2381
|
+
const nested = await Promise.all(
|
|
2382
|
+
entries.map(async (entry) => {
|
|
2383
|
+
const path = join16(dir, entry.name);
|
|
2384
|
+
if (entry.isDirectory()) {
|
|
2385
|
+
return listFiles(path);
|
|
2386
|
+
}
|
|
2387
|
+
return entry.isFile() ? [path] : [];
|
|
2388
|
+
})
|
|
2389
|
+
);
|
|
2390
|
+
return nested.flat().sort((a, b) => a.localeCompare(b));
|
|
2391
|
+
} catch {
|
|
2392
|
+
return [];
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
async function tsconfigStrict2(rootDir) {
|
|
2396
|
+
const visited = /* @__PURE__ */ new Set();
|
|
2397
|
+
return readStrictFromTsconfig2(join16(rootDir, "tsconfig.json"), visited);
|
|
2398
|
+
}
|
|
2399
|
+
async function readStrictFromTsconfig2(path, visited) {
|
|
2400
|
+
if (visited.has(path)) {
|
|
2401
|
+
return null;
|
|
2402
|
+
}
|
|
2403
|
+
visited.add(path);
|
|
2404
|
+
const raw = await readOptional2(path);
|
|
2405
|
+
if (!raw) {
|
|
2406
|
+
return null;
|
|
2407
|
+
}
|
|
2408
|
+
const parsed = JSON.parse(stripJsonComments2(raw));
|
|
2409
|
+
if (typeof parsed.compilerOptions?.strict === "boolean") {
|
|
2410
|
+
return parsed.compilerOptions.strict;
|
|
2411
|
+
}
|
|
2412
|
+
if (parsed.extends?.startsWith(".")) {
|
|
2413
|
+
return readStrictFromTsconfig2(join16(path, "..", parsed.extends), visited);
|
|
2414
|
+
}
|
|
2415
|
+
return null;
|
|
2416
|
+
}
|
|
2417
|
+
async function readOptional2(path) {
|
|
2418
|
+
try {
|
|
2419
|
+
return await readFile7(path, "utf8");
|
|
2420
|
+
} catch {
|
|
2421
|
+
return null;
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
function stripJsonComments2(value) {
|
|
2425
|
+
return value.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
|
2426
|
+
}
|
|
2427
|
+
function skipCheck(id, message) {
|
|
2428
|
+
return { id, status: "warn", message };
|
|
2429
|
+
}
|
|
2430
|
+
function withOk(checks) {
|
|
2431
|
+
return {
|
|
2432
|
+
ok: checks.every((check) => check.status !== "fail"),
|
|
2433
|
+
checks
|
|
2434
|
+
};
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
// src/cli/commands/doctor.ts
|
|
2438
|
+
var doctorCommand = defineCommand2({
|
|
2439
|
+
meta: {
|
|
2440
|
+
name: "doctor",
|
|
2441
|
+
description: "Check node-boost config, generated files, hooks, and project strictness."
|
|
2442
|
+
},
|
|
2443
|
+
args: {
|
|
2444
|
+
agent: { type: "boolean", description: "Print machine-readable JSON.", default: false }
|
|
2445
|
+
},
|
|
2446
|
+
async run({ args }) {
|
|
2447
|
+
const result = await doctorTool(process.cwd(), package_default.version);
|
|
2448
|
+
process.stdout.write(args.agent ? `${JSON.stringify(result)}
|
|
2449
|
+
` : renderDoctor(result));
|
|
2450
|
+
process.exitCode = result.ok ? 0 : 1;
|
|
2451
|
+
}
|
|
2452
|
+
});
|
|
2453
|
+
function renderDoctor(result) {
|
|
2454
|
+
return `${result.checks.map(renderCheck).join("\n")}
|
|
2455
|
+
`;
|
|
2456
|
+
}
|
|
2457
|
+
function renderCheck(check) {
|
|
2458
|
+
const icon = check.status === "pass" ? "\u2714" : check.status === "warn" ? "\u26A0" : "\u2716";
|
|
2459
|
+
const details = check.details?.length ? `
|
|
2460
|
+
${check.details.map((detail) => ` - ${detail}`).join("\n")}` : "";
|
|
2461
|
+
return `${icon} ${check.id}: ${check.message}${details}`;
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
// src/cli/commands/explain.ts
|
|
2465
|
+
import { defineCommand as defineCommand3 } from "citty";
|
|
2466
|
+
var explainCommand = defineCommand3({
|
|
2467
|
+
meta: {
|
|
2468
|
+
name: "explain",
|
|
2469
|
+
description: "Explain a node-boost audit finding."
|
|
2470
|
+
},
|
|
2471
|
+
args: {
|
|
2472
|
+
agent: { type: "boolean", description: "Print machine-readable JSON.", default: false }
|
|
2473
|
+
},
|
|
2474
|
+
run({ args }) {
|
|
2475
|
+
const ruleId = Array.isArray(args._) ? String(args._[0] ?? "") : "";
|
|
2476
|
+
const entry = explainFinding(ruleId);
|
|
2477
|
+
if (!entry) {
|
|
2478
|
+
const message = `Unknown node-boost rule "${ruleId}".
|
|
2479
|
+
`;
|
|
2480
|
+
if (args.agent) {
|
|
2481
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: message.trim() })}
|
|
2482
|
+
`);
|
|
2483
|
+
} else {
|
|
2484
|
+
process.stderr.write(message);
|
|
2485
|
+
}
|
|
2486
|
+
process.exitCode = 1;
|
|
2487
|
+
return;
|
|
2488
|
+
}
|
|
2489
|
+
if (args.agent) {
|
|
2490
|
+
process.stdout.write(`${JSON.stringify({ ok: true, finding: entry })}
|
|
2491
|
+
`);
|
|
2492
|
+
return;
|
|
2493
|
+
}
|
|
2494
|
+
process.stdout.write([
|
|
2495
|
+
`${entry.rule} ${entry.code}`,
|
|
2496
|
+
`Severity: ${entry.severity}`,
|
|
2497
|
+
`Architecture: ${entry.architecture}`,
|
|
2498
|
+
entry.description,
|
|
2499
|
+
entry.rationale,
|
|
2500
|
+
`Fix: ${entry.fix}`,
|
|
2501
|
+
`Guideline: ${entry.guideline}`,
|
|
2502
|
+
""
|
|
2503
|
+
].join("\n"));
|
|
2504
|
+
}
|
|
2505
|
+
});
|
|
2506
|
+
|
|
2507
|
+
// src/cli/commands/guard.ts
|
|
2508
|
+
import { defineCommand as defineCommand4 } from "citty";
|
|
2509
|
+
|
|
2510
|
+
// src/hooks/report.ts
|
|
2511
|
+
function renderBlockingReason(result) {
|
|
2512
|
+
return `node-boost guard found ${result.err} error finding(s).
|
|
2513
|
+
|
|
2514
|
+
${renderHumanReport(result)}`;
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
// src/hooks/claude-code.ts
|
|
2518
|
+
function formatClaudeCodeHook(result) {
|
|
2519
|
+
if (result.err > 0) {
|
|
2520
|
+
return {
|
|
2521
|
+
exitCode: 2,
|
|
2522
|
+
stdout: "",
|
|
2523
|
+
stderr: renderBlockingReason(result)
|
|
2524
|
+
};
|
|
2525
|
+
}
|
|
2526
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
// src/hooks/codex.ts
|
|
2530
|
+
function formatCodexHook(result) {
|
|
2531
|
+
if (result.err > 0) {
|
|
2532
|
+
return {
|
|
2533
|
+
exitCode: 0,
|
|
2534
|
+
stdout: `${JSON.stringify({ decision: "block", reason: renderBlockingReason(result) })}
|
|
2535
|
+
`,
|
|
2536
|
+
stderr: ""
|
|
2537
|
+
};
|
|
2538
|
+
}
|
|
2539
|
+
return {
|
|
2540
|
+
exitCode: 0,
|
|
2541
|
+
stdout: `${JSON.stringify({ continue: true })}
|
|
2542
|
+
`,
|
|
2543
|
+
stderr: ""
|
|
2544
|
+
};
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
// src/hooks/cursor.ts
|
|
2548
|
+
function formatCursorHook(result) {
|
|
2549
|
+
if (result.err > 0) {
|
|
2550
|
+
return {
|
|
2551
|
+
exitCode: 0,
|
|
2552
|
+
stdout: `${JSON.stringify({ followup_message: renderBlockingReason(result) })}
|
|
2553
|
+
`,
|
|
2554
|
+
stderr: ""
|
|
2555
|
+
};
|
|
2556
|
+
}
|
|
2557
|
+
return {
|
|
2558
|
+
exitCode: 0,
|
|
2559
|
+
stdout: "{}\n",
|
|
2560
|
+
stderr: ""
|
|
2561
|
+
};
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
// src/hooks/adapter.ts
|
|
2565
|
+
async function runGuardHook(agent, rootDir = process.cwd()) {
|
|
2566
|
+
const result = await runAudit({ rootDir, mode: "changed" });
|
|
2567
|
+
if (agent === "claude-code") {
|
|
2568
|
+
return formatClaudeCodeHook(result);
|
|
2569
|
+
}
|
|
2570
|
+
if (agent === "codex") {
|
|
2571
|
+
return formatCodexHook(result);
|
|
2572
|
+
}
|
|
2573
|
+
if (agent === "cursor") {
|
|
2574
|
+
return formatCursorHook(result);
|
|
2575
|
+
}
|
|
2576
|
+
return unsupportedHookAgent(agent);
|
|
2577
|
+
}
|
|
2578
|
+
function unsupportedHookAgent(agent) {
|
|
2579
|
+
return {
|
|
2580
|
+
exitCode: 1,
|
|
2581
|
+
stdout: "",
|
|
2582
|
+
stderr: `Unsupported hook agent "${agent}". Supported agents: claude-code, codex, cursor.
|
|
2583
|
+
`
|
|
2584
|
+
};
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
// src/cli/commands/guard.ts
|
|
2588
|
+
var hookAgents = /* @__PURE__ */ new Set(["claude-code", "codex", "cursor"]);
|
|
2589
|
+
var guardCommand = defineCommand4({
|
|
2590
|
+
meta: {
|
|
2591
|
+
name: "guard",
|
|
2592
|
+
description: "Run audit as a hard gate for CI or agent hooks."
|
|
2593
|
+
},
|
|
2594
|
+
args: {
|
|
2595
|
+
hook: { type: "string", description: "Respond using a hook protocol: claude-code, codex, cursor.", required: false }
|
|
2596
|
+
},
|
|
2597
|
+
async run({ args }) {
|
|
2598
|
+
try {
|
|
2599
|
+
if (args.hook) {
|
|
2600
|
+
if (!hookAgents.has(args.hook)) {
|
|
2601
|
+
const response2 = unsupportedHookAgent(args.hook);
|
|
2602
|
+
process.stdout.write(response2.stdout);
|
|
2603
|
+
process.stderr.write(response2.stderr);
|
|
2604
|
+
process.exitCode = response2.exitCode;
|
|
2605
|
+
return;
|
|
2606
|
+
}
|
|
2607
|
+
await readStdin();
|
|
2608
|
+
const response = await runGuardHook(args.hook);
|
|
2609
|
+
process.stdout.write(response.stdout);
|
|
2610
|
+
process.stderr.write(response.stderr);
|
|
2611
|
+
process.exitCode = response.exitCode;
|
|
2612
|
+
return;
|
|
2613
|
+
}
|
|
2614
|
+
const result = await runAudit({ mode: "changed" });
|
|
2615
|
+
process.stdout.write(renderAgentReport(result));
|
|
2616
|
+
process.exitCode = result.err > 0 ? 1 : 0;
|
|
2617
|
+
} catch (error) {
|
|
2618
|
+
if (handleCliError(error)) {
|
|
2619
|
+
return;
|
|
2620
|
+
}
|
|
2621
|
+
throw error;
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
});
|
|
2625
|
+
async function readStdin() {
|
|
2626
|
+
if (process.stdin.isTTY) {
|
|
2627
|
+
return "";
|
|
2628
|
+
}
|
|
2629
|
+
const chunks = [];
|
|
2630
|
+
for await (const chunk of process.stdin) {
|
|
2631
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
2632
|
+
}
|
|
2633
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
// src/cli/commands/install.ts
|
|
2637
|
+
import { defineCommand as defineCommand5 } from "citty";
|
|
2638
|
+
var installCommand = defineCommand5({
|
|
2639
|
+
meta: {
|
|
2640
|
+
name: "install",
|
|
2641
|
+
description: "Generate node-boost guidelines, skills, config, and agent files."
|
|
2642
|
+
},
|
|
2643
|
+
args: {
|
|
2644
|
+
interaction: {
|
|
2645
|
+
type: "boolean",
|
|
2646
|
+
description: "Prompt before writing files. Use --no-interaction for detected defaults.",
|
|
2647
|
+
default: true
|
|
2648
|
+
}
|
|
2649
|
+
},
|
|
2650
|
+
async run({ args }) {
|
|
2651
|
+
const result = await runInstall({ noInteraction: args.interaction === false });
|
|
2652
|
+
const counts = countStatuses(result.operations);
|
|
2653
|
+
console.log(`node-boost install: created ${counts.created}, updated ${counts.updated}, skipped ${counts.skipped}`);
|
|
2654
|
+
}
|
|
2655
|
+
});
|
|
2656
|
+
function countStatuses(operations) {
|
|
2657
|
+
return operations.reduce(
|
|
2658
|
+
(summary, operation) => {
|
|
2659
|
+
summary[operation.status] += 1;
|
|
2660
|
+
return summary;
|
|
2661
|
+
},
|
|
2662
|
+
{ created: 0, updated: 0, skipped: 0 }
|
|
2663
|
+
);
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
// src/cli/commands/mcp.ts
|
|
2667
|
+
import { defineCommand as defineCommand6 } from "citty";
|
|
2668
|
+
|
|
2669
|
+
// src/mcp/server.ts
|
|
2670
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2671
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2672
|
+
import { z as z2 } from "zod";
|
|
2673
|
+
|
|
2674
|
+
// src/mcp/errors.ts
|
|
2675
|
+
var NodeBoostMcpError = class extends Error {
|
|
2676
|
+
constructor(code, message, options) {
|
|
2677
|
+
super(message, options);
|
|
2678
|
+
this.code = code;
|
|
2679
|
+
this.name = "NodeBoostMcpError";
|
|
2680
|
+
}
|
|
2681
|
+
code;
|
|
2682
|
+
};
|
|
2683
|
+
function formatMcpError(error) {
|
|
2684
|
+
if (error instanceof NodeBoostMcpError) {
|
|
2685
|
+
return `${error.code}: ${error.message}`;
|
|
2686
|
+
}
|
|
2687
|
+
if (error instanceof Error) {
|
|
2688
|
+
return `NB-E500: ${error.message}`;
|
|
2689
|
+
}
|
|
2690
|
+
return "NB-E500: Unknown MCP tool error.";
|
|
2691
|
+
}
|
|
2692
|
+
function isDebugEnabled() {
|
|
2693
|
+
return process.env.NODE_BOOST_DEBUG === "1";
|
|
2694
|
+
}
|
|
2695
|
+
function debugLog(message) {
|
|
2696
|
+
if (isDebugEnabled()) {
|
|
2697
|
+
process.stderr.write(`${message}
|
|
2698
|
+
`);
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
// src/mcp/tools/application-info.ts
|
|
2703
|
+
import { version as nodeVersion } from "process";
|
|
2704
|
+
async function applicationInfoTool(rootDir, boostVersion) {
|
|
2705
|
+
const stack = await detectStack(rootDir);
|
|
2706
|
+
const boostConfig = await readBoostConfig(rootDir);
|
|
2707
|
+
return {
|
|
2708
|
+
node: nodeVersion.replace(/^v/, ""),
|
|
2709
|
+
packageManager: `${stack.packageManager.name}${stack.packageManager.version ? `@${stack.packageManager.version}` : ""}`,
|
|
2710
|
+
typescript: {
|
|
2711
|
+
version: stack.packages.typescript?.version ?? null,
|
|
2712
|
+
strict: await readTypescriptStrict(rootDir)
|
|
2713
|
+
},
|
|
2714
|
+
stack: {
|
|
2715
|
+
name: stack.name,
|
|
2716
|
+
version: stackVersion(stack.packages),
|
|
2717
|
+
router: stack.router,
|
|
2718
|
+
srcDir: stack.srcDir
|
|
2719
|
+
},
|
|
2720
|
+
linting: stack.linting,
|
|
2721
|
+
packages: detectedPackages(stack.packages),
|
|
2722
|
+
boost: boostConfig.config ? {
|
|
2723
|
+
version: boostVersion,
|
|
2724
|
+
generatedWith: boostConfig.config.generatedWith,
|
|
2725
|
+
architectures: boostConfig.architectures.map((architecture) => ({
|
|
2726
|
+
name: architecture.name,
|
|
2727
|
+
options: architecture.options
|
|
2728
|
+
}))
|
|
2729
|
+
} : null,
|
|
2730
|
+
...boostConfig.config ? {} : { hint: "run node-boost install" }
|
|
2731
|
+
};
|
|
2732
|
+
}
|
|
2733
|
+
function detectedPackages(packages) {
|
|
2734
|
+
return Object.fromEntries(
|
|
2735
|
+
Object.values(packages).filter((pkg) => pkg.source !== "missing" && pkg.version).sort((a, b) => a.name.localeCompare(b.name)).map((pkg) => [pkg.name, pkg.version])
|
|
2736
|
+
);
|
|
2737
|
+
}
|
|
2738
|
+
function stackVersion(packages) {
|
|
2739
|
+
return packages.next?.version ?? packages.vite?.version ?? packages.react?.version ?? null;
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
// src/mcp/tools/audit.ts
|
|
2743
|
+
async function auditTool(rootDir) {
|
|
2744
|
+
return runAudit({ rootDir, mode: "all" });
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
// src/mcp/tools/explain-finding.ts
|
|
2748
|
+
function explainFindingTool(rule) {
|
|
2749
|
+
const finding2 = explainFinding(rule);
|
|
2750
|
+
if (!finding2) {
|
|
2751
|
+
return { ok: false, error: `Unknown node-boost rule "${rule}".` };
|
|
2752
|
+
}
|
|
2753
|
+
return { ok: true, finding: finding2 };
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
// src/mcp/tools/list-routes.ts
|
|
2757
|
+
import { readdir as readdir4 } from "fs/promises";
|
|
2758
|
+
import { join as join17, relative as relative5, sep } from "path";
|
|
2759
|
+
var appRouteFiles = {
|
|
2760
|
+
page: "page",
|
|
2761
|
+
layout: "layout",
|
|
2762
|
+
route: "route-handler",
|
|
2763
|
+
error: "error",
|
|
2764
|
+
loading: "loading",
|
|
2765
|
+
"not-found": "not-found",
|
|
2766
|
+
middleware: "middleware"
|
|
2767
|
+
};
|
|
2768
|
+
var supportedExtensions = /* @__PURE__ */ new Set(["js", "jsx", "ts", "tsx", "mdx"]);
|
|
2769
|
+
async function listRoutesTool(rootDir) {
|
|
2770
|
+
const stack = await detectStack(rootDir);
|
|
2771
|
+
if (stack.name === "vite-react") {
|
|
2772
|
+
return {
|
|
2773
|
+
supported: false,
|
|
2774
|
+
reason: "react-router route map is on the roadmap"
|
|
2775
|
+
};
|
|
2776
|
+
}
|
|
2777
|
+
if (stack.name !== "next") {
|
|
2778
|
+
return [];
|
|
2779
|
+
}
|
|
2780
|
+
if (stack.router === "app") {
|
|
2781
|
+
return sortRoutes([
|
|
2782
|
+
...await scanAppRouter(rootDir, join17(rootDir, "app")),
|
|
2783
|
+
...await scanAppRouter(rootDir, join17(rootDir, "src", "app"))
|
|
2784
|
+
]);
|
|
2785
|
+
}
|
|
2786
|
+
if (stack.router === "pages") {
|
|
2787
|
+
return sortRoutes([
|
|
2788
|
+
...await scanPagesRouter(rootDir, join17(rootDir, "pages")),
|
|
2789
|
+
...await scanPagesRouter(rootDir, join17(rootDir, "src", "pages"))
|
|
2790
|
+
]);
|
|
2791
|
+
}
|
|
2792
|
+
return [];
|
|
2793
|
+
}
|
|
2794
|
+
async function scanAppRouter(rootDir, dir) {
|
|
2795
|
+
const files = await walkFiles2(dir);
|
|
2796
|
+
return files.flatMap((file) => {
|
|
2797
|
+
const parsed = parseRouteFile(file);
|
|
2798
|
+
if (!parsed || !appRouteFiles[parsed.basename]) {
|
|
2799
|
+
return [];
|
|
2800
|
+
}
|
|
2801
|
+
const appRelative = relative5(dir, file);
|
|
2802
|
+
const routeSegments = appRelative.split(sep).slice(0, -1);
|
|
2803
|
+
const slot = routeSegments.find((segment) => segment.startsWith("@"))?.slice(1);
|
|
2804
|
+
const path = segmentsToPath(routeSegments);
|
|
2805
|
+
return [
|
|
2806
|
+
{
|
|
2807
|
+
path,
|
|
2808
|
+
type: appRouteFiles[parsed.basename],
|
|
2809
|
+
file: normalizePath(relative5(rootDir, file)),
|
|
2810
|
+
dynamic: extractDynamicSegments(routeSegments),
|
|
2811
|
+
...slot ? { slot } : {}
|
|
2812
|
+
}
|
|
2813
|
+
];
|
|
2814
|
+
});
|
|
2815
|
+
}
|
|
2816
|
+
async function scanPagesRouter(rootDir, dir) {
|
|
2817
|
+
const files = await walkFiles2(dir);
|
|
2818
|
+
return files.flatMap((file) => {
|
|
2819
|
+
const parsed = parseRouteFile(file);
|
|
2820
|
+
if (!parsed || parsed.basename.startsWith("_") || parsed.basename === "middleware") {
|
|
2821
|
+
return [];
|
|
2822
|
+
}
|
|
2823
|
+
const pagesRelative = relative5(dir, file);
|
|
2824
|
+
const pathWithoutExtension = pagesRelative.slice(0, -parsed.extension.length - 1);
|
|
2825
|
+
const segments = pathWithoutExtension.split(sep);
|
|
2826
|
+
const isApi = segments[0] === "api";
|
|
2827
|
+
const routeSegments = isApi ? segments.slice(1) : segments;
|
|
2828
|
+
if (routeSegments[routeSegments.length - 1] === "index") {
|
|
2829
|
+
routeSegments.pop();
|
|
2830
|
+
}
|
|
2831
|
+
return [
|
|
2832
|
+
{
|
|
2833
|
+
path: isApi ? `/api${segmentsToPath(routeSegments) === "/" ? "" : segmentsToPath(routeSegments)}` : segmentsToPath(routeSegments),
|
|
2834
|
+
type: isApi ? "api-route" : "page",
|
|
2835
|
+
file: normalizePath(relative5(rootDir, file)),
|
|
2836
|
+
dynamic: extractDynamicSegments(routeSegments)
|
|
2837
|
+
}
|
|
2838
|
+
];
|
|
2839
|
+
});
|
|
2840
|
+
}
|
|
2841
|
+
async function walkFiles2(dir) {
|
|
2842
|
+
let entries;
|
|
2843
|
+
try {
|
|
2844
|
+
entries = await readdir4(dir, { withFileTypes: true });
|
|
2845
|
+
} catch {
|
|
2846
|
+
return [];
|
|
2847
|
+
}
|
|
2848
|
+
const files = await Promise.all(
|
|
2849
|
+
entries.map(async (entry) => {
|
|
2850
|
+
const path = join17(dir, entry.name);
|
|
2851
|
+
if (entry.isDirectory()) {
|
|
2852
|
+
return walkFiles2(path);
|
|
2853
|
+
}
|
|
2854
|
+
return entry.isFile() ? [path] : [];
|
|
2855
|
+
})
|
|
2856
|
+
);
|
|
2857
|
+
return files.flat();
|
|
2858
|
+
}
|
|
2859
|
+
function parseRouteFile(file) {
|
|
2860
|
+
const filename = file.split(sep).at(-1) ?? "";
|
|
2861
|
+
const parts = filename.split(".");
|
|
2862
|
+
if (parts.length < 2) {
|
|
2863
|
+
return null;
|
|
2864
|
+
}
|
|
2865
|
+
const extension = parts.at(-1) ?? "";
|
|
2866
|
+
const basename = parts.slice(0, -1).join(".");
|
|
2867
|
+
if (!supportedExtensions.has(extension)) {
|
|
2868
|
+
return null;
|
|
2869
|
+
}
|
|
2870
|
+
return { basename, extension };
|
|
2871
|
+
}
|
|
2872
|
+
function segmentsToPath(segments) {
|
|
2873
|
+
const routeSegments = segments.filter((segment) => !segment.startsWith("(") && !segment.startsWith("@"));
|
|
2874
|
+
return `/${routeSegments.join("/")}`.replace(/\/+/g, "/");
|
|
2875
|
+
}
|
|
2876
|
+
function extractDynamicSegments(segments) {
|
|
2877
|
+
return segments.map((segment) => segment.match(/^\[\[?\.{0,3}([^\]]+)\]?\]$/)?.[1] ?? null).filter((segment) => Boolean(segment));
|
|
2878
|
+
}
|
|
2879
|
+
function sortRoutes(routes) {
|
|
2880
|
+
return routes.sort((a, b) => a.path.localeCompare(b.path) || a.type.localeCompare(b.type) || a.file.localeCompare(b.file));
|
|
2881
|
+
}
|
|
2882
|
+
function normalizePath(path) {
|
|
2883
|
+
return path.split(sep).join("/");
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
// src/mcp/server.ts
|
|
2887
|
+
function createNodeBoostMcpServer(options = {}) {
|
|
2888
|
+
const rootDir = options.rootDir ?? process.cwd();
|
|
2889
|
+
const packageVersion = options.packageVersion ?? package_default.version;
|
|
2890
|
+
const server = new McpServer({
|
|
2891
|
+
name: "node-boost",
|
|
2892
|
+
version: packageVersion
|
|
2893
|
+
});
|
|
2894
|
+
registerJsonTool(
|
|
2895
|
+
server,
|
|
2896
|
+
"application_info",
|
|
2897
|
+
"Return detected Node/React project information.",
|
|
2898
|
+
async () => applicationInfoTool(rootDir, packageVersion)
|
|
2899
|
+
);
|
|
2900
|
+
registerJsonTool(server, "list_routes", "List detected application routes.", async () => listRoutesTool(rootDir));
|
|
2901
|
+
registerJsonTool(server, "doctor", "Run minimal node-boost project diagnostics.", async () => doctorTool(rootDir, packageVersion));
|
|
2902
|
+
registerJsonTool(server, "audit", "Run node-boost audit on all source files.", async () => auditTool(rootDir));
|
|
2903
|
+
registerJsonTool(
|
|
2904
|
+
server,
|
|
2905
|
+
"explain_finding",
|
|
2906
|
+
'Explain a node-boost audit finding. Pass {"rule":"NB-ARCH-005"}.',
|
|
2907
|
+
(args) => explainFindingTool(readRuleArg(args)),
|
|
2908
|
+
{ rule: z2.string() }
|
|
2909
|
+
);
|
|
2910
|
+
if (options.includeThrowingTestTool) {
|
|
2911
|
+
registerJsonTool(server, "__throw_for_test", "Internal test-only failing tool.", () => {
|
|
2912
|
+
throw new Error("forced test error");
|
|
2913
|
+
});
|
|
2914
|
+
}
|
|
2915
|
+
return server;
|
|
2916
|
+
}
|
|
2917
|
+
async function startNodeBoostMcpServer(options = {}) {
|
|
2918
|
+
const server = createNodeBoostMcpServer(options);
|
|
2919
|
+
await server.connect(new StdioServerTransport());
|
|
2920
|
+
}
|
|
2921
|
+
function registerJsonTool(server, name, description, handler, inputSchema = {}) {
|
|
2922
|
+
server.registerTool(
|
|
2923
|
+
name,
|
|
2924
|
+
{
|
|
2925
|
+
title: name,
|
|
2926
|
+
description,
|
|
2927
|
+
inputSchema
|
|
2928
|
+
},
|
|
2929
|
+
async (args) => {
|
|
2930
|
+
const start = performance.now();
|
|
2931
|
+
try {
|
|
2932
|
+
const output = await handler(args);
|
|
2933
|
+
debugLog(`[node-boost:mcp] ${name} ok ${Math.round(performance.now() - start)}ms`);
|
|
2934
|
+
return {
|
|
2935
|
+
content: [{ type: "text", text: JSON.stringify(output) }]
|
|
2936
|
+
};
|
|
2937
|
+
} catch (error) {
|
|
2938
|
+
const message = formatMcpError(error);
|
|
2939
|
+
debugLog(`[node-boost:mcp] ${name} err ${Math.round(performance.now() - start)}ms ${message}`);
|
|
2940
|
+
return {
|
|
2941
|
+
content: [{ type: "text", text: message }],
|
|
2942
|
+
isError: true
|
|
2943
|
+
};
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
);
|
|
2947
|
+
}
|
|
2948
|
+
function readRuleArg(args) {
|
|
2949
|
+
return typeof args.rule === "string" ? args.rule : "";
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
// src/cli/commands/mcp.ts
|
|
2953
|
+
var mcpCommand = defineCommand6({
|
|
2954
|
+
meta: {
|
|
2955
|
+
name: "mcp",
|
|
2956
|
+
description: "Start the node-boost MCP server over stdio."
|
|
2957
|
+
},
|
|
2958
|
+
async run() {
|
|
2959
|
+
await startNodeBoostMcpServer();
|
|
2960
|
+
}
|
|
2961
|
+
});
|
|
2962
|
+
|
|
2963
|
+
// src/cli/commands/update.ts
|
|
2964
|
+
import { defineCommand as defineCommand7 } from "citty";
|
|
2965
|
+
var updateCommand = defineCommand7({
|
|
2966
|
+
meta: {
|
|
2967
|
+
name: "update",
|
|
2968
|
+
description: "Regenerate node-boost files from node-boost.json without prompts."
|
|
2969
|
+
},
|
|
2970
|
+
async run() {
|
|
2971
|
+
const result = await runUpdate();
|
|
2972
|
+
const counts = result.operations.reduce(
|
|
2973
|
+
(summary, operation) => {
|
|
2974
|
+
summary[operation.status] += 1;
|
|
2975
|
+
return summary;
|
|
2976
|
+
},
|
|
2977
|
+
{ created: 0, updated: 0, skipped: 0 }
|
|
2978
|
+
);
|
|
2979
|
+
console.log(`node-boost update: created ${counts.created}, updated ${counts.updated}, skipped ${counts.skipped}`);
|
|
2980
|
+
}
|
|
2981
|
+
});
|
|
2982
|
+
|
|
2983
|
+
// src/cli/index.ts
|
|
2984
|
+
var main = defineCommand8({
|
|
2985
|
+
meta: {
|
|
2986
|
+
name: "node-boost",
|
|
2987
|
+
description: "CLI and MCP guidance layer for Node/React projects."
|
|
2988
|
+
},
|
|
2989
|
+
subCommands: {
|
|
2990
|
+
audit: auditCommand,
|
|
2991
|
+
doctor: doctorCommand,
|
|
2992
|
+
explain: explainCommand,
|
|
2993
|
+
guard: guardCommand,
|
|
2994
|
+
install: installCommand,
|
|
2995
|
+
mcp: mcpCommand,
|
|
2996
|
+
update: updateCommand
|
|
2997
|
+
}
|
|
2998
|
+
});
|
|
2999
|
+
await runMain(main);
|
|
3000
|
+
//# sourceMappingURL=cli.js.map
|