claude-crap 0.3.4 → 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,4 +1,5 @@
1
1
  import js from "@eslint/js";
2
+ import globals from "globals";
2
3
 
3
4
  export default [
4
5
  js.configs.recommended,
@@ -12,4 +13,15 @@ export default [
12
13
  "**/*.min.js",
13
14
  ],
14
15
  },
16
+ {
17
+ languageOptions: {
18
+ globals: globals.node,
19
+ },
20
+ rules: {
21
+ "preserve-caught-error": "warn",
22
+ "no-empty": "warn",
23
+ "no-useless-assignment": "warn",
24
+ "no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
25
+ },
26
+ },
15
27
  ];
@@ -50,7 +50,7 @@ export async function readStdinJson() {
50
50
  try {
51
51
  return JSON.parse(raw);
52
52
  } catch (err) {
53
- throw new Error(`stdin is not valid JSON: ${/** @type {Error} */ (err).message}`);
53
+ throw new Error(`stdin is not valid JSON: ${/** @type {Error} */ (err).message}`, { cause: err });
54
54
  }
55
55
  }
56
56
 
@@ -111,7 +111,7 @@ function parseStdinJson(raw) {
111
111
  try {
112
112
  return JSON.parse(raw);
113
113
  } catch (err) {
114
- throw new Error(`stdin is not valid JSON: ${/** @type {Error} */ (err).message}`);
114
+ throw new Error(`stdin is not valid JSON: ${/** @type {Error} */ (err).message}`, { cause: err });
115
115
  }
116
116
  }
117
117
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-crap-plugin",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-crap-plugin",
9
- "version": "0.3.4",
9
+ "version": "0.3.5",
10
10
  "dependencies": {
11
11
  "@fastify/static": "^8.0.3",
12
12
  "@modelcontextprotocol/sdk": "^1.0.4",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-crap-plugin",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "private": true,
5
5
  "description": "Runtime dependencies for the claude-crap plugin bundle",
6
6
  "type": "module",
@@ -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,9 +24,8 @@
24
24
  * @module dashboard/server
25
25
  */
26
26
 
27
- import { promises as fs } from "node:fs";
28
- import { createServer as createTcpServer } from "node:net";
29
- 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";
30
29
  import { fileURLToPath } from "node:url";
31
30
 
32
31
  import Fastify, { type FastifyInstance } from "fastify";
@@ -100,7 +99,7 @@ export async function startDashboard(options: StartDashboardOptions): Promise<Da
100
99
  // ------------------------------------------------------------------
101
100
  // /api/health — liveness probe
102
101
  // ------------------------------------------------------------------
103
- fastify.get("/api/health", async () => ({ status: "ok", server: "claude-crap", version: "0.3.4" }));
102
+ fastify.get("/api/health", async () => ({ status: "ok", server: "claude-crap", version: "0.3.5" }));
104
103
 
105
104
  // ------------------------------------------------------------------
106
105
  // /api/score — live project score
@@ -126,27 +125,24 @@ export async function startDashboard(options: StartDashboardOptions): Promise<Da
126
125
  return reply.sendFile("index.html");
127
126
  });
128
127
 
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);
135
-
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
- }
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
+
134
+ await fastify.listen({ port: config.dashboardPort, host: "127.0.0.1" });
135
+
136
+ const url = `http://127.0.0.1:${config.dashboardPort}`;
145
137
  logger.info({ url, publicRoot }, "claude-crap dashboard listening");
146
138
 
139
+ // Write PID file so the next session can find and kill us.
140
+ writePidFile(pidFilePath, config.dashboardPort);
141
+
147
142
  return {
148
143
  url,
149
144
  async close() {
145
+ removePidFile(pidFilePath);
150
146
  await fastify.close();
151
147
  },
152
148
  };
@@ -203,54 +199,134 @@ function urlOf(fastify: FastifyInstance, config: CrapConfig): string {
203
199
  return `http://127.0.0.1:${config.dashboardPort}`;
204
200
  }
205
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
+
206
215
  /**
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).
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.
211
269
  *
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.
270
+ * @param pidFilePath Absolute path to `dashboard.pid`.
271
+ * @param port The configured dashboard port.
272
+ * @param logger Pino logger for diagnostics.
217
273
  */
218
- async function findFreePort(
219
- startPort: number,
220
- maxRetries: number,
274
+ async function killStaleDashboard(
275
+ pidFilePath: string,
276
+ port: number,
221
277
  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
- }
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;
248
288
  }
249
289
 
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`,
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",
253
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));
254
330
  }
255
331
 
256
332
  /**
@@ -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
  }