claude-crap 0.3.1 → 0.3.4
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 +52 -0
- package/README.md +1 -1
- package/dist/dashboard/server.d.ts.map +1 -1
- package/dist/dashboard/server.js +59 -5
- package/dist/dashboard/server.js.map +1 -1
- package/dist/scanner/auto-scan.d.ts.map +1 -1
- package/dist/scanner/auto-scan.js +24 -0
- package/dist/scanner/auto-scan.js.map +1 -1
- package/dist/scanner/bootstrap.d.ts.map +1 -1
- package/dist/scanner/bootstrap.js +59 -22
- package/dist/scanner/bootstrap.js.map +1 -1
- package/dist/scanner/runner.d.ts.map +1 -1
- package/dist/scanner/runner.js +7 -2
- package/dist/scanner/runner.js.map +1 -1
- package/package.json +6 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -1
- package/plugin/bundle/launcher.mjs +112 -0
- package/plugin/bundle/mcp-server.mjs +126 -24
- package/plugin/bundle/mcp-server.mjs.map +4 -4
- package/plugin/eslint.config.mjs +15 -0
- package/plugin/launcher.mjs +112 -0
- package/plugin/package-lock.json +2714 -0
- package/plugin/package.json +5 -1
- package/scripts/bundle-plugin.mjs +30 -0
- package/src/dashboard/server.ts +75 -5
- package/src/scanner/auto-scan.ts +28 -0
- package/src/scanner/bootstrap.ts +67 -24
- package/src/scanner/runner.ts +8 -2
package/plugin/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-crap-plugin",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Runtime dependencies for the claude-crap plugin bundle",
|
|
6
6
|
"type": "module",
|
|
@@ -14,5 +14,9 @@
|
|
|
14
14
|
},
|
|
15
15
|
"engines": {
|
|
16
16
|
"node": ">=20.0.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@eslint/js": "^10.0.1",
|
|
20
|
+
"eslint": "^10.2.0"
|
|
17
21
|
}
|
|
18
22
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// scripts/bundle-plugin.mjs
|
|
2
2
|
import { build } from "esbuild";
|
|
3
3
|
import { cp, mkdir, rm } from "node:fs/promises";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
4
5
|
import { resolve } from "node:path";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
|
|
@@ -66,6 +67,35 @@ async function main() {
|
|
|
66
67
|
resolve(BUNDLE_DIR, "dashboard/public"),
|
|
67
68
|
{ recursive: true },
|
|
68
69
|
);
|
|
70
|
+
|
|
71
|
+
// 4. Copy the launcher wrapper into the bundle directory.
|
|
72
|
+
// launcher.mjs is the .mcp.json entry point and is hand-written
|
|
73
|
+
// (not bundled by esbuild). It ensures node_modules/ exist before
|
|
74
|
+
// dynamically importing the real MCP server.
|
|
75
|
+
await cp(
|
|
76
|
+
resolve(ROOT, "plugin/launcher.mjs"),
|
|
77
|
+
resolve(BUNDLE_DIR, "launcher.mjs"),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// 5. Generate plugin/package-lock.json for deterministic installs.
|
|
81
|
+
// This lockfile ships with the plugin so that the launcher's
|
|
82
|
+
// `npm install --omit=dev` resolves the exact same versions on
|
|
83
|
+
// every developer's machine. `--package-lock-only` does NOT
|
|
84
|
+
// create node_modules/, keeping the repo light.
|
|
85
|
+
try {
|
|
86
|
+
execFileSync("npm", [
|
|
87
|
+
"install",
|
|
88
|
+
"--package-lock-only",
|
|
89
|
+
"--omit=dev",
|
|
90
|
+
], {
|
|
91
|
+
cwd: resolve(ROOT, "plugin"),
|
|
92
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
93
|
+
});
|
|
94
|
+
} catch (err) {
|
|
95
|
+
process.stderr.write(
|
|
96
|
+
`warning: could not generate plugin/package-lock.json: ${err.message}\n`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
69
99
|
}
|
|
70
100
|
|
|
71
101
|
main().catch((err) => {
|
package/src/dashboard/server.ts
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { promises as fs } from "node:fs";
|
|
28
|
+
import { createServer as createTcpServer } from "node:net";
|
|
28
29
|
import { dirname, resolve } from "node:path";
|
|
29
30
|
import { fileURLToPath } from "node:url";
|
|
30
31
|
|
|
@@ -94,13 +95,12 @@ export async function startDashboard(options: StartDashboardOptions): Promise<Da
|
|
|
94
95
|
await fastify.register(fastifyStatic, {
|
|
95
96
|
root: publicRoot,
|
|
96
97
|
prefix: "/",
|
|
97
|
-
decorateReply: false,
|
|
98
98
|
});
|
|
99
99
|
|
|
100
100
|
// ------------------------------------------------------------------
|
|
101
101
|
// /api/health — liveness probe
|
|
102
102
|
// ------------------------------------------------------------------
|
|
103
|
-
fastify.get("/api/health", async () => ({ status: "ok", server: "claude-crap", version: "0.
|
|
103
|
+
fastify.get("/api/health", async () => ({ status: "ok", server: "claude-crap", version: "0.3.4" }));
|
|
104
104
|
|
|
105
105
|
// ------------------------------------------------------------------
|
|
106
106
|
// /api/score — live project score
|
|
@@ -117,11 +117,31 @@ export async function startDashboard(options: StartDashboardOptions): Promise<Da
|
|
|
117
117
|
fastify.get("/api/sarif", async () => sarifStore.toSarifDocument());
|
|
118
118
|
|
|
119
119
|
// ------------------------------------------------------------------
|
|
120
|
-
// / —
|
|
120
|
+
// / — explicit SPA fallback for index.html
|
|
121
121
|
// ------------------------------------------------------------------
|
|
122
|
+
// @fastify/static sometimes doesn't serve index.html on GET / when
|
|
123
|
+
// API routes are registered on the same prefix. Explicit fallback
|
|
124
|
+
// ensures the dashboard always loads.
|
|
125
|
+
fastify.get("/", async (_request, reply) => {
|
|
126
|
+
return reply.sendFile("index.html");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Find a free port. The configured port is tried first, then up to
|
|
130
|
+
// MAX_PORT_RETRIES consecutive neighbors. This handles the common
|
|
131
|
+
// case where a previous Claude Code session left a stale MCP server
|
|
132
|
+
// process holding the default port.
|
|
133
|
+
const MAX_PORT_RETRIES = 4;
|
|
134
|
+
const boundPort = await findFreePort(config.dashboardPort, MAX_PORT_RETRIES, logger);
|
|
122
135
|
|
|
123
|
-
await fastify.listen({ port:
|
|
124
|
-
|
|
136
|
+
await fastify.listen({ port: boundPort, host: "127.0.0.1" });
|
|
137
|
+
|
|
138
|
+
const url = `http://127.0.0.1:${boundPort}`;
|
|
139
|
+
if (boundPort !== config.dashboardPort) {
|
|
140
|
+
logger.warn(
|
|
141
|
+
{ url, configuredPort: config.dashboardPort, actualPort: boundPort },
|
|
142
|
+
"claude-crap dashboard bound to fallback port (configured port was in use)",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
125
145
|
logger.info({ url, publicRoot }, "claude-crap dashboard listening");
|
|
126
146
|
|
|
127
147
|
return {
|
|
@@ -183,6 +203,56 @@ function urlOf(fastify: FastifyInstance, config: CrapConfig): string {
|
|
|
183
203
|
return `http://127.0.0.1:${config.dashboardPort}`;
|
|
184
204
|
}
|
|
185
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Probe a sequence of TCP ports starting at `startPort` and return the
|
|
208
|
+
* first one that is free. Uses a raw `net.createServer()` probe that
|
|
209
|
+
* opens and immediately closes to avoid interfering with Fastify's own
|
|
210
|
+
* listen lifecycle (Fastify instances cannot re-listen after close).
|
|
211
|
+
*
|
|
212
|
+
* @param startPort The preferred port.
|
|
213
|
+
* @param maxRetries How many consecutive ports to try after the first.
|
|
214
|
+
* @param logger Pino logger for diagnostics.
|
|
215
|
+
* @returns The first free port found.
|
|
216
|
+
* @throws When all candidate ports are occupied.
|
|
217
|
+
*/
|
|
218
|
+
async function findFreePort(
|
|
219
|
+
startPort: number,
|
|
220
|
+
maxRetries: number,
|
|
221
|
+
logger: Logger,
|
|
222
|
+
): Promise<number> {
|
|
223
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
224
|
+
const candidatePort = startPort + attempt;
|
|
225
|
+
const isFree = await new Promise<boolean>((resolvePromise) => {
|
|
226
|
+
const probe = createTcpServer();
|
|
227
|
+
probe.once("error", (err: NodeJS.ErrnoException) => {
|
|
228
|
+
if (err.code === "EADDRINUSE") {
|
|
229
|
+
resolvePromise(false);
|
|
230
|
+
} else {
|
|
231
|
+
// Unexpected error (permission denied, etc.) — treat as unavailable.
|
|
232
|
+
resolvePromise(false);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
probe.listen({ port: candidatePort, host: "127.0.0.1" }, () => {
|
|
236
|
+
probe.close(() => resolvePromise(true));
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
if (isFree) return candidatePort;
|
|
241
|
+
|
|
242
|
+
if (attempt < maxRetries) {
|
|
243
|
+
logger.info(
|
|
244
|
+
{ port: candidatePort, nextPort: candidatePort + 1 },
|
|
245
|
+
"dashboard port in use, trying next",
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// All ports exhausted — throw so startDashboard rejects gracefully.
|
|
251
|
+
throw new Error(
|
|
252
|
+
`[claude-crap] dashboard: all ports ${startPort}–${startPort + maxRetries} are in use`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
186
256
|
/**
|
|
187
257
|
* Wrap {@link computeProjectScore} so the dashboard endpoint can call
|
|
188
258
|
* it with the live store and provide consistent location metadata.
|
package/src/scanner/auto-scan.ts
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
* @module scanner/auto-scan
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
+
import { existsSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
22
24
|
import type { Logger } from "pino";
|
|
23
25
|
import { detectScanners, type ScannerDetection } from "./detector.js";
|
|
24
26
|
import { runScanner, type ScannerRunResult } from "./runner.js";
|
|
@@ -106,6 +108,32 @@ export async function autoScan(
|
|
|
106
108
|
"auto-scan: detection complete",
|
|
107
109
|
);
|
|
108
110
|
|
|
111
|
+
// If ESLint is detected (e.g. in package.json) but has no config file,
|
|
112
|
+
// bootstrap will create one before we try to scan.
|
|
113
|
+
const eslintConfigFiles = [
|
|
114
|
+
"eslint.config.js", "eslint.config.mjs", "eslint.config.cjs",
|
|
115
|
+
"eslint.config.ts", "eslint.config.mts", "eslint.config.cts",
|
|
116
|
+
".eslintrc.js", ".eslintrc.cjs", ".eslintrc.yaml",
|
|
117
|
+
".eslintrc.yml", ".eslintrc.json",
|
|
118
|
+
];
|
|
119
|
+
const eslintDetected = available.some((d) => d.scanner === "eslint");
|
|
120
|
+
const hasEslintConfig = eslintConfigFiles.some((f) => existsSync(join(workspaceRoot, f)));
|
|
121
|
+
|
|
122
|
+
if (eslintDetected && !hasEslintConfig) {
|
|
123
|
+
logger.info("auto-scan: ESLint detected but no config — running bootstrap");
|
|
124
|
+
try {
|
|
125
|
+
const bootstrapResult = await bootstrapScanner(workspaceRoot, sarifStore, logger);
|
|
126
|
+
if (bootstrapResult.autoScanResult) {
|
|
127
|
+
return bootstrapResult.autoScanResult;
|
|
128
|
+
}
|
|
129
|
+
} catch (err) {
|
|
130
|
+
logger.warn(
|
|
131
|
+
{ err: (err as Error).message },
|
|
132
|
+
"auto-scan: bootstrap config creation failed",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
109
137
|
if (available.length === 0) {
|
|
110
138
|
// No scanners configured — try to bootstrap one automatically.
|
|
111
139
|
logger.info("auto-scan: no scanners found, attempting bootstrap");
|
package/src/scanner/bootstrap.ts
CHANGED
|
@@ -137,7 +137,14 @@ export default tseslint.config(
|
|
|
137
137
|
js.configs.recommended,
|
|
138
138
|
...tseslint.configs.recommended,
|
|
139
139
|
{
|
|
140
|
-
ignores: [
|
|
140
|
+
ignores: [
|
|
141
|
+
"dist/",
|
|
142
|
+
"node_modules/",
|
|
143
|
+
"coverage/",
|
|
144
|
+
"**/bundle/",
|
|
145
|
+
"**/vendor/",
|
|
146
|
+
"**/*.min.js",
|
|
147
|
+
],
|
|
141
148
|
},
|
|
142
149
|
);
|
|
143
150
|
`;
|
|
@@ -148,7 +155,14 @@ export default tseslint.config(
|
|
|
148
155
|
export default [
|
|
149
156
|
js.configs.recommended,
|
|
150
157
|
{
|
|
151
|
-
ignores: [
|
|
158
|
+
ignores: [
|
|
159
|
+
"dist/",
|
|
160
|
+
"node_modules/",
|
|
161
|
+
"coverage/",
|
|
162
|
+
"**/bundle/",
|
|
163
|
+
"**/vendor/",
|
|
164
|
+
"**/*.min.js",
|
|
165
|
+
],
|
|
152
166
|
},
|
|
153
167
|
];
|
|
154
168
|
`;
|
|
@@ -292,7 +306,12 @@ export async function bootstrapScanner(
|
|
|
292
306
|
const detections = await detectScanners(workspaceRoot);
|
|
293
307
|
const available = detections.filter((d) => d.available);
|
|
294
308
|
|
|
295
|
-
if
|
|
309
|
+
// A scanner is truly "configured" only if it also has a config
|
|
310
|
+
// file. ESLint in package.json without eslint.config.mjs will crash.
|
|
311
|
+
const eslintNeedsConfig = available.some((d) => d.scanner === "eslint")
|
|
312
|
+
&& !detections.some((d) => d.scanner === "eslint" && d.configPath);
|
|
313
|
+
|
|
314
|
+
if (available.length > 0 && !eslintNeedsConfig) {
|
|
296
315
|
const existingScanners = available.map((d) => d.scanner);
|
|
297
316
|
logger.info(
|
|
298
317
|
{ existingScanners },
|
|
@@ -319,21 +338,32 @@ export async function bootstrapScanner(
|
|
|
319
338
|
"bootstrap: detected project type",
|
|
320
339
|
);
|
|
321
340
|
|
|
322
|
-
// 3. Install scanner
|
|
341
|
+
// 3. Install scanner (skip npm install if already in package.json)
|
|
323
342
|
if (recommendation.canAutoInstall) {
|
|
324
|
-
// JS/TS: auto-install ESLint
|
|
325
343
|
const isTypeScript = projectType === "typescript";
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
344
|
+
const eslintAlreadyInstalled = available.some((d) => d.scanner === "eslint");
|
|
345
|
+
|
|
346
|
+
if (!eslintAlreadyInstalled) {
|
|
347
|
+
const packages = isTypeScript
|
|
348
|
+
? ["eslint", "@eslint/js", "typescript-eslint"]
|
|
349
|
+
: ["eslint", "@eslint/js"];
|
|
350
|
+
const installStep = await npmInstall(workspaceRoot, packages);
|
|
351
|
+
steps.push(installStep);
|
|
352
|
+
if (!installStep.success) {
|
|
353
|
+
// npm install failed — skip config creation, fall through to result
|
|
354
|
+
return buildResult(projectType, steps, null);
|
|
355
|
+
}
|
|
356
|
+
} else {
|
|
357
|
+
steps.push({
|
|
358
|
+
action: "npm install eslint",
|
|
359
|
+
success: true,
|
|
360
|
+
detail: "eslint already in package.json — skipped install",
|
|
361
|
+
});
|
|
336
362
|
}
|
|
363
|
+
|
|
364
|
+
// Always create config if missing
|
|
365
|
+
const configStep = writeEslintConfigFile(workspaceRoot, isTypeScript);
|
|
366
|
+
steps.push(configStep);
|
|
337
367
|
} else {
|
|
338
368
|
// Python / Java / C# / Unknown: return instructions
|
|
339
369
|
steps.push({
|
|
@@ -409,18 +439,31 @@ export async function bootstrapScanner(
|
|
|
409
439
|
}
|
|
410
440
|
|
|
411
441
|
// 5. Build result
|
|
442
|
+
return buildResult(projectType, steps, autoScanResult, recommendation);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Build a BootstrapResult from the collected steps and optional scan result.
|
|
447
|
+
*/
|
|
448
|
+
function buildResult(
|
|
449
|
+
projectType: ProjectType,
|
|
450
|
+
steps: BootstrapStep[],
|
|
451
|
+
autoScanResult: AutoScanResult | null,
|
|
452
|
+
recommendation?: { scanner: KnownScanner; canAutoInstall: boolean; installInstructions: string },
|
|
453
|
+
): BootstrapResult {
|
|
454
|
+
const success = steps.every((s) => s.success);
|
|
412
455
|
const findings = autoScanResult?.totalFindings ?? 0;
|
|
413
|
-
const
|
|
456
|
+
const scanner = recommendation?.scanner ?? "unknown";
|
|
414
457
|
|
|
415
458
|
let summary: string;
|
|
416
|
-
if (
|
|
417
|
-
summary = `
|
|
418
|
-
} else if (
|
|
419
|
-
summary = `
|
|
420
|
-
} else if (
|
|
421
|
-
summary = `
|
|
459
|
+
if (success && autoScanResult) {
|
|
460
|
+
summary = `Configured ${scanner} for ${projectType} project. Scan found ${findings} finding(s).`;
|
|
461
|
+
} else if (success && recommendation && !recommendation.canAutoInstall) {
|
|
462
|
+
summary = `Detected ${projectType} project. Install ${scanner} manually: ${recommendation.installInstructions}`;
|
|
463
|
+
} else if (success) {
|
|
464
|
+
summary = `Configured ${scanner} for ${projectType} project.`;
|
|
422
465
|
} else {
|
|
423
|
-
summary = `Failed to
|
|
466
|
+
summary = `Failed to configure ${scanner}. Check the error details in the steps.`;
|
|
424
467
|
}
|
|
425
468
|
|
|
426
469
|
return {
|
|
@@ -429,7 +472,7 @@ export async function bootstrapScanner(
|
|
|
429
472
|
existingScanners: [],
|
|
430
473
|
steps,
|
|
431
474
|
autoScanResult,
|
|
432
|
-
success
|
|
475
|
+
success,
|
|
433
476
|
summary,
|
|
434
477
|
};
|
|
435
478
|
}
|
package/src/scanner/runner.ts
CHANGED
|
@@ -124,8 +124,14 @@ export function runScanner(
|
|
|
124
124
|
const durationMs = Date.now() - start;
|
|
125
125
|
|
|
126
126
|
// For scanners where non-zero exit means "findings exist",
|
|
127
|
-
// we still have valid output in stdout.
|
|
128
|
-
|
|
127
|
+
// we still have valid output in stdout. But if the scanner
|
|
128
|
+
// crashed (e.g. ESLint with no config file), treat it as a
|
|
129
|
+
// real failure even when nonZeroIsNormal is set.
|
|
130
|
+
const isFatalError = cmd.nonZeroIsNormal
|
|
131
|
+
&& err
|
|
132
|
+
&& (!stdout?.trim() || stderr?.includes("Oops!") || stderr?.includes("couldn't find"));
|
|
133
|
+
|
|
134
|
+
if (err && (!cmd.nonZeroIsNormal || isFatalError)) {
|
|
129
135
|
// Stryker: check if the output file was written despite the error
|
|
130
136
|
if (cmd.outputFile && existsSync(cmd.outputFile)) {
|
|
131
137
|
try {
|