claude-crap 0.3.3 → 0.3.5

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-crap-plugin",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
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) => {
@@ -152,13 +152,13 @@ async function checkDist(pluginRoot) {
152
152
  const stat = await fs.stat(npmEntry);
153
153
  npmAge = Math.round((Date.now() - stat.mtimeMs) / (1000 * 60 * 60));
154
154
  npmOk = true;
155
- } catch {}
155
+ } catch { /* probe — absence is expected */ }
156
156
 
157
157
  try {
158
158
  const stat = await fs.stat(gitEntry);
159
159
  gitAge = Math.round((Date.now() - stat.mtimeMs) / (1000 * 60 * 60));
160
160
  gitOk = true;
161
- } catch {}
161
+ } catch { /* probe — absence is expected */ }
162
162
 
163
163
  const details = [];
164
164
  if (npmOk) details.push(`dist/index.js (~${npmAge}h)`);
@@ -24,8 +24,8 @@
24
24
  * @module dashboard/server
25
25
  */
26
26
 
27
- import { promises as fs } from "node:fs";
28
- import { dirname, resolve } from "node:path";
27
+ import { promises as fs, existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
28
+ import { dirname, join, resolve } from "node:path";
29
29
  import { fileURLToPath } from "node:url";
30
30
 
31
31
  import Fastify, { type FastifyInstance } from "fastify";
@@ -99,7 +99,7 @@ export async function startDashboard(options: StartDashboardOptions): Promise<Da
99
99
  // ------------------------------------------------------------------
100
100
  // /api/health — liveness probe
101
101
  // ------------------------------------------------------------------
102
- fastify.get("/api/health", async () => ({ status: "ok", server: "claude-crap", version: "0.3.2" }));
102
+ fastify.get("/api/health", async () => ({ status: "ok", server: "claude-crap", version: "0.3.5" }));
103
103
 
104
104
  // ------------------------------------------------------------------
105
105
  // /api/score — live project score
@@ -125,13 +125,24 @@ export async function startDashboard(options: StartDashboardOptions): Promise<Da
125
125
  return reply.sendFile("index.html");
126
126
  });
127
127
 
128
+ // Kill any stale dashboard from a previous session so we always
129
+ // bind to the configured port. This mirrors claude-mem's PID file
130
+ // pattern: write a PID file when alive, check + kill on next boot.
131
+ const pidFilePath = resolvePidFilePath(config);
132
+ await killStaleDashboard(pidFilePath, config.dashboardPort, logger);
133
+
128
134
  await fastify.listen({ port: config.dashboardPort, host: "127.0.0.1" });
135
+
129
136
  const url = `http://127.0.0.1:${config.dashboardPort}`;
130
137
  logger.info({ url, publicRoot }, "claude-crap dashboard listening");
131
138
 
139
+ // Write PID file so the next session can find and kill us.
140
+ writePidFile(pidFilePath, config.dashboardPort);
141
+
132
142
  return {
133
143
  url,
134
144
  async close() {
145
+ removePidFile(pidFilePath);
135
146
  await fastify.close();
136
147
  },
137
148
  };
@@ -188,6 +199,136 @@ function urlOf(fastify: FastifyInstance, config: CrapConfig): string {
188
199
  return `http://127.0.0.1:${config.dashboardPort}`;
189
200
  }
190
201
 
202
+ // ------------------------------------------------------------------
203
+ // PID file management — mirrors claude-mem's worker.pid pattern
204
+ // ------------------------------------------------------------------
205
+
206
+ /**
207
+ * Shape of the PID file written by the dashboard process.
208
+ */
209
+ interface DashboardPidFile {
210
+ pid: number;
211
+ port: number;
212
+ startedAt: string;
213
+ }
214
+
215
+ /**
216
+ * Resolve the path to the PID file. Stored under
217
+ * `.claude-crap/dashboard.pid` in the workspace so it survives
218
+ * across sessions but is gitignored with the rest of `.claude-crap/`.
219
+ */
220
+ function resolvePidFilePath(config: CrapConfig): string {
221
+ return join(config.pluginRoot, ".claude-crap", "dashboard.pid");
222
+ }
223
+
224
+ /**
225
+ * Write the PID file atomically after the dashboard has started.
226
+ */
227
+ function writePidFile(path: string, port: number): void {
228
+ const data: DashboardPidFile = {
229
+ pid: process.pid,
230
+ port,
231
+ startedAt: new Date().toISOString(),
232
+ };
233
+ try {
234
+ writeFileSync(path, JSON.stringify(data, null, 2) + "\n");
235
+ } catch {
236
+ /* best effort — dashboard still works without a PID file */
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Remove the PID file during graceful shutdown.
242
+ */
243
+ function removePidFile(path: string): void {
244
+ try {
245
+ unlinkSync(path);
246
+ } catch {
247
+ /* already gone or never written */
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Check whether a process is alive using the signal-0 probe.
253
+ * Returns `true` when the process exists and is reachable.
254
+ */
255
+ function isPidAlive(pid: number): boolean {
256
+ try {
257
+ process.kill(pid, 0);
258
+ return true;
259
+ } catch {
260
+ return false;
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Read the PID file, kill any stale dashboard process, and free the
266
+ * port so the current session can bind to it. This is the key
267
+ * difference from the port-fallback approach: instead of drifting to
268
+ * 5118, 5119, etc., we reclaim the configured port every time.
269
+ *
270
+ * @param pidFilePath Absolute path to `dashboard.pid`.
271
+ * @param port The configured dashboard port.
272
+ * @param logger Pino logger for diagnostics.
273
+ */
274
+ async function killStaleDashboard(
275
+ pidFilePath: string,
276
+ port: number,
277
+ logger: Logger,
278
+ ): Promise<void> {
279
+ if (!existsSync(pidFilePath)) return;
280
+
281
+ let stale: DashboardPidFile;
282
+ try {
283
+ stale = JSON.parse(readFileSync(pidFilePath, "utf8"));
284
+ } catch {
285
+ // Corrupted PID file — remove it and move on.
286
+ removePidFile(pidFilePath);
287
+ return;
288
+ }
289
+
290
+ if (!isPidAlive(stale.pid)) {
291
+ logger.info({ stalePid: stale.pid }, "stale dashboard PID file found (process dead), removing");
292
+ removePidFile(pidFilePath);
293
+ return;
294
+ }
295
+
296
+ // Process is alive — kill it so we can reclaim the port.
297
+ logger.info(
298
+ { stalePid: stale.pid, port: stale.port, startedAt: stale.startedAt },
299
+ "killing stale dashboard process from previous session",
300
+ );
301
+
302
+ try {
303
+ process.kill(stale.pid, "SIGTERM");
304
+ } catch {
305
+ // Permission denied or already gone — remove PID file either way.
306
+ removePidFile(pidFilePath);
307
+ return;
308
+ }
309
+
310
+ // Wait up to 3 seconds for the process to exit.
311
+ for (let i = 0; i < 30; i++) {
312
+ if (!isPidAlive(stale.pid)) break;
313
+ await new Promise((r) => setTimeout(r, 100));
314
+ }
315
+
316
+ // If still alive after 3s, escalate to SIGKILL.
317
+ if (isPidAlive(stale.pid)) {
318
+ try {
319
+ process.kill(stale.pid, "SIGKILL");
320
+ } catch {
321
+ /* best effort */
322
+ }
323
+ await new Promise((r) => setTimeout(r, 200));
324
+ }
325
+
326
+ removePidFile(pidFilePath);
327
+
328
+ // Give the OS a moment to release the TCP port after the process dies.
329
+ await new Promise((r) => setTimeout(r, 300));
330
+ }
331
+
191
332
  /**
192
333
  * Wrap {@link computeProjectScore} so the dashboard endpoint can call
193
334
  * it with the live store and provide consistent location metadata.
@@ -184,6 +184,7 @@ export class SarifStore {
184
184
  }
185
185
  throw new Error(
186
186
  `[sarif-store] Failed to load consolidated report at ${this.filePath}: ${error.message}`,
187
+ { cause: err },
187
188
  );
188
189
  }
189
190
  }