ccgauge 1.0.4 → 1.0.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.
Files changed (45) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-build-manifest.json +33 -33
  3. package/.next/standalone/.next/app-path-routes-manifest.json +9 -9
  4. package/.next/standalone/.next/build-manifest.json +2 -2
  5. package/.next/standalone/.next/server/app/_not-found/page.js +2 -2
  6. package/.next/standalone/.next/server/app/api/projects/route.js +1 -1
  7. package/.next/standalone/.next/server/app/api/projects/route.js.nft.json +1 -1
  8. package/.next/standalone/.next/server/app/api/sessions/route.js +1 -1
  9. package/.next/standalone/.next/server/app/api/sessions/route.js.nft.json +1 -1
  10. package/.next/standalone/.next/server/app/api/usage/route.js +1 -1
  11. package/.next/standalone/.next/server/app/api/usage/route.js.nft.json +1 -1
  12. package/.next/standalone/.next/server/app/models/page.js +2 -2
  13. package/.next/standalone/.next/server/app/models/page.js.nft.json +1 -1
  14. package/.next/standalone/.next/server/app/page.js +2 -2
  15. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  16. package/.next/standalone/.next/server/app/projects/[id]/page.js +2 -2
  17. package/.next/standalone/.next/server/app/projects/[id]/page.js.nft.json +1 -1
  18. package/.next/standalone/.next/server/app/projects/page.js +1 -1
  19. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  20. package/.next/standalone/.next/server/app/sessions/[id]/page.js +2 -2
  21. package/.next/standalone/.next/server/app/sessions/[id]/page.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/sessions/page.js +1 -1
  23. package/.next/standalone/.next/server/app/sessions/page.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/settings/page.js +1 -1
  25. package/.next/standalone/.next/server/app/usage/page.js +3 -3
  26. package/.next/standalone/.next/server/app/usage/page.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app-paths-manifest.json +9 -9
  28. package/.next/standalone/.next/server/chunks/287.js +1 -0
  29. package/.next/standalone/.next/server/chunks/567.js +2 -2
  30. package/.next/standalone/.next/server/chunks/730.js +1 -0
  31. package/.next/standalone/.next/server/functions-config-manifest.json +2 -2
  32. package/.next/standalone/.next/server/pages/500.html +1 -1
  33. package/.next/standalone/node_modules/next/node_modules/postcss/package.json +0 -0
  34. package/.next/standalone/package.json +1 -1
  35. package/.next/standalone/public/codex-logo.png +0 -0
  36. package/CHANGELOG.md +223 -0
  37. package/README.md +6 -2
  38. package/README.zh-CN.md +6 -2
  39. package/bin/cli.mjs +394 -91
  40. package/dist/mcp/server.mjs +16 -16
  41. package/dist/report/index.mjs +183 -28
  42. package/package.json +22 -24
  43. package/.next/standalone/.next/server/chunks/971.js +0 -1
  44. /package/.next/standalone/.next/static/{ir1LZCnQKkiNUVXLprtzh → kdpS1dOlXPsnKYuNBuMt9}/_buildManifest.js +0 -0
  45. /package/.next/standalone/.next/static/{ir1LZCnQKkiNUVXLprtzh → kdpS1dOlXPsnKYuNBuMt9}/_ssgManifest.js +0 -0
package/bin/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { fork, spawn } from 'node:child_process';
2
+ import { execFileSync, spawn } from 'node:child_process';
3
3
  import { closeSync, createReadStream, existsSync, openSync } from 'node:fs';
4
4
  import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
5
5
  import os from 'node:os';
@@ -13,11 +13,20 @@ const __dirname = dirname(__filename);
13
13
  const packageRoot = resolve(__dirname, '..');
14
14
  const pkg = require(join(packageRoot, 'package.json'));
15
15
 
16
+ // commander is needed before we even parse argv to know which subcommand
17
+ // the user asked for, so we load it eagerly. `get-port` (only used by
18
+ // start / restart) and `open` (only by start / open) are deferred so
19
+ // short-lived commands like `mcp`, `status`, `logs`, `report`, `--version`
20
+ // don't pay their import cost (~20-30 ms cold-start each).
16
21
  const { Command } = await import('commander');
17
- const getPortMod = await import('get-port');
18
- const openMod = await import('open');
19
- const getPort = getPortMod.default;
20
- const openBrowser = openMod.default;
22
+ async function loadGetPort() {
23
+ const mod = await import('get-port');
24
+ return mod.default;
25
+ }
26
+ async function loadOpenBrowser() {
27
+ const mod = await import('open');
28
+ return mod.default;
29
+ }
21
30
 
22
31
  const STATE_DIR = process.env.CCGAUGE_STATE_DIR || join(os.homedir(), '.ccgauge');
23
32
  const STATE_FILE = join(STATE_DIR, 'state.json');
@@ -27,12 +36,24 @@ const DEFAULT_PORT = '3737';
27
36
  const DEFAULT_HOST = '127.0.0.1';
28
37
  const COMMAND_NAMES = new Set([
29
38
  'start', 'stop', 'restart', 'status', 'open', 'logs', 'mcp',
30
- 'report',
39
+ 'report', 'doctor',
31
40
  ]);
32
- const VALUE_OPTIONS = new Set([
33
- '-p', '--port', '-H', '--host', '--dir', '--log', '-n', '--lines',
34
- '-r', '--range', '-s', '--source', '-b', '--by', '-g', '--gran',
35
- '-m', '--model', '--project', '--since', '--until',
41
+ // `start` subcommand's own option set (incl. short aliases). The value
42
+ // indicates whether the option consumes the following positional as a
43
+ // value. Used by `normalizeArgv` to decide whether `ccgauge -p 3000`
44
+ // should be treated as the bare-`start` shortcut. Anything outside this
45
+ // set (e.g. `-r` / `--range` from `report`) makes us bail out and let
46
+ // commander surface its own "unknown option" against the root program
47
+ // — which lists subcommands. Keep in sync with `addStartOptions`.
48
+ const START_OPTIONS = new Map([
49
+ ['-p', true], ['--port', true],
50
+ ['-H', true], ['--host', true],
51
+ ['--dir', true],
52
+ ['--log', true],
53
+ ['-q', false], ['--quiet', false],
54
+ ['-b', false], ['--background', false],
55
+ ['--no-open', false],
56
+ ['--strict-port', false],
36
57
  ]);
37
58
 
38
59
  function browserHost(host) {
@@ -44,6 +65,25 @@ function buildUrl(host, port) {
44
65
  return `http://${browserHost(host)}:${port}`;
45
66
  }
46
67
 
68
+ /** Decide whether to emit ANSI colour. Precedence (high → low):
69
+ * 1. `forceOff` (commander's `--no-color`) → off.
70
+ * 2. `NO_COLOR` env var (NO_COLOR.org convention) → off.
71
+ * 3. `FORCE_COLOR` env var → on (covers tee / pipes / CI).
72
+ * 4. stdout TTY check → on.
73
+ * 5. else → off.
74
+ *
75
+ * We take `forceOff` as an explicit boolean rather than reading
76
+ * `opts.color` directly because commander gives `.option('--no-color')`
77
+ * a default of `true` — there's no in-band way to distinguish "user
78
+ * didn't say anything" from "user passed --color". Callers translate
79
+ * their own option to `forceOff: opts.color === false`. */
80
+ function shouldUseColor({ forceOff = false } = {}) {
81
+ if (forceOff) return false;
82
+ if (process.env.NO_COLOR) return false;
83
+ if (process.env.FORCE_COLOR) return true;
84
+ return Boolean(process.stdout.isTTY);
85
+ }
86
+
47
87
  function safeKill(pid, signal) {
48
88
  if (!pid) return false;
49
89
  try {
@@ -81,11 +121,18 @@ async function inheritFromState(opts, cmd) {
81
121
  const prev = await readState();
82
122
  if (!prev) return { ...opts };
83
123
  const isDefault = (key) => cmd.getOptionValueSource(key) === 'default';
124
+ // `--dir` has no default value (see addStartOptions), so commander
125
+ // reports its source as `undefined` when unset and `'cli'` when the
126
+ // user typed it — including `--dir ""` which means "clear override".
127
+ // Using truthy-on-opts.dir would conflate "unset" and "explicit empty"
128
+ // and re-inherit the old dataDir, defeating the whole point of
129
+ // letting users switch back to the default Claude path on restart.
130
+ const isProvided = (key) => cmd.getOptionValueSource(key) === 'cli';
84
131
  const merged = { ...opts };
85
132
  if (isDefault('port') && prev.port) merged.port = String(prev.port);
86
133
  if (isDefault('host') && prev.host) merged.host = prev.host;
87
134
  if (isDefault('log') && prev.logFile) merged.log = prev.logFile;
88
- if (!opts.dir && prev.dataDir) merged.dir = prev.dataDir;
135
+ if (!isProvided('dir') && prev.dataDir) merged.dir = prev.dataDir;
89
136
  return merged;
90
137
  }
91
138
 
@@ -147,6 +194,13 @@ program
147
194
  await startMcp(opts);
148
195
  });
149
196
 
197
+ program
198
+ .command('doctor')
199
+ .description('print a one-screen diagnostic: env, build artifacts, state, indexer')
200
+ .action(async () => {
201
+ await doctor();
202
+ });
203
+
150
204
  function addReportOptions(cmd) {
151
205
  return cmd
152
206
  .option('-r, --range <range>', 'today | 1d | 7d | 30d | 90d | all', '7d')
@@ -171,23 +225,43 @@ addReportOptions(program.command('report').description('print a formatted usage
171
225
 
172
226
  await program.parseAsync(normalizeArgv(process.argv));
173
227
 
228
+ /** Implements `ccgauge` (no subcommand) as a shortcut for `ccgauge start`,
229
+ * but only when every flag we see belongs to `start`. If the user typed
230
+ * something that looks like a `report` / `mcp` flag without the
231
+ * subcommand (e.g. `ccgauge -r 7d`), we leave argv alone so commander
232
+ * surfaces "unknown option" against the root program — which lists the
233
+ * available subcommands. Without this discrimination commander would
234
+ * complain about `start: unknown option -r`, which is the wrong hint. */
174
235
  function normalizeArgv(argv) {
175
236
  const args = argv.slice(2);
237
+ // `ccgauge` (no args) is documented as a shortcut for `ccgauge start` —
238
+ // we deliberately do NOT early-return on `args.length === 0`. The flag
239
+ // walk below is a no-op for an empty argv, so we fall through to the
240
+ // final `[argv[0], argv[1], 'start']` injection.
176
241
  if (args.includes('--help') || args.includes('-h') || args.includes('-V') || args.includes('--version')) {
177
242
  return argv;
178
243
  }
179
- if (hasSubcommand(args)) return argv;
180
- return [argv[0], argv[1], 'start', ...args];
181
- }
182
-
183
- function hasSubcommand(args) {
244
+ // First token is a known subcommand → caller knows what they're doing.
245
+ if (args.length > 0 && COMMAND_NAMES.has(args[0])) return argv;
246
+ // Walk flags; bail if we see anything `start` doesn't accept.
184
247
  for (let i = 0; i < args.length; i += 1) {
185
248
  const arg = args[i];
186
249
  if (arg === '--') break;
187
- if (COMMAND_NAMES.has(arg)) return true;
188
- if (VALUE_OPTIONS.has(arg)) i += 1;
250
+ if (!arg.startsWith('-')) {
251
+ // Stray positional with no subcommand prefix → let commander error
252
+ // out at the root rather than after silently picking `start`.
253
+ return argv;
254
+ }
255
+ // Support `--port=3737` style: only the part before `=` is the flag.
256
+ const eqIdx = arg.indexOf('=');
257
+ const flag = eqIdx >= 0 ? arg.slice(0, eqIdx) : arg;
258
+ if (!START_OPTIONS.has(flag)) return argv;
259
+ // If the option takes a value and the user didn't inline it with `=`,
260
+ // the next token is the value — skip it so we don't re-check it as a
261
+ // flag.
262
+ if (START_OPTIONS.get(flag) === true && eqIdx < 0) i += 1;
189
263
  }
190
- return false;
264
+ return [argv[0], argv[1], 'start', ...args];
191
265
  }
192
266
 
193
267
  async function start(opts) {
@@ -203,10 +277,20 @@ async function start(opts) {
203
277
  async function startForeground(standaloneEntry, opts) {
204
278
  const port = await resolvePort(opts);
205
279
  const env = makeServerEnv(opts, port);
206
- const child = fork(standaloneEntry, [], {
280
+ // `spawn` (not `fork`) because Next's standalone server is a plain
281
+ // node script — it doesn't use the IPC channel. `fork` would open one
282
+ // anyway, and since Next never calls `process.disconnect()`, the
283
+ // parent could never exit cleanly without `process.exit()` racing the
284
+ // child's shutdown. `spawn` matches the background path and lets us
285
+ // hand the child our stdio directly. `--quiet` muffles both streams
286
+ // (Next's warnings go to stderr, so muting only stdout misses them).
287
+ const stdio = opts.quiet
288
+ ? ['ignore', 'ignore', 'ignore']
289
+ : 'inherit';
290
+ const child = spawn(process.execPath, [standaloneEntry], {
207
291
  cwd: dirname(standaloneEntry),
208
292
  env,
209
- stdio: opts.quiet ? ['ignore', 'ignore', 'inherit', 'ipc'] : 'inherit',
293
+ stdio,
210
294
  });
211
295
 
212
296
  const url = buildUrl(opts.host, port);
@@ -216,31 +300,40 @@ async function startForeground(standaloneEntry, opts) {
216
300
  printReady(url, { background: false });
217
301
  })
218
302
  .catch((err) => {
219
- console.error(`\n[ccgauge] failed to start: ${err.message}\n`);
303
+ console.error(`\n[ccgauge] error: failed to start: ${err.message}\n`);
220
304
  safeKill(child.pid, 'SIGTERM');
221
305
  process.exit(1);
222
306
  });
223
307
 
224
- function shutdown(signal) {
308
+ // Forward our signals to the child, then wait for it to exit so its
309
+ // teardown is observable rather than racing `process.exit()`. The
310
+ // child propagates the exit code back via the `exit` event below.
311
+ function forward(signal) {
225
312
  return () => {
226
313
  if (!child.killed) child.kill(signal);
227
- process.exit(0);
314
+ // Don't process.exit() here; let `child.on('exit')` decide. If the
315
+ // child ignores the signal for some reason, a second Ctrl+C will
316
+ // re-enter and the OS eventually escalates.
228
317
  };
229
318
  }
230
- process.on('SIGINT', shutdown('SIGINT'));
231
- process.on('SIGTERM', shutdown('SIGTERM'));
319
+ process.on('SIGINT', forward('SIGINT'));
320
+ process.on('SIGTERM', forward('SIGTERM'));
232
321
  process.on('exit', () => {
233
322
  if (!child.killed) child.kill('SIGTERM');
234
323
  });
235
324
 
236
- child.on('exit', (code) => {
237
- process.exit(code ?? 0);
325
+ child.on('exit', (code, signal) => {
326
+ // Convention: signal-terminated child → 128 + signal number (bash
327
+ // standard). Plain numeric exit → forward as-is. `code === null`
328
+ // happens when only `signal` is set.
329
+ if (typeof code === 'number') process.exit(code);
330
+ else process.exit(signal ? 128 : 0);
238
331
  });
239
332
  }
240
333
 
241
334
  async function startBackground(standaloneEntry, opts) {
242
335
  const existing = await readState();
243
- if (existing && isProcessRunning(existing.pid)) {
336
+ if (existing && isProcessRunning(existing.pid, existing)) {
244
337
  printAlreadyRunning(existing);
245
338
  return;
246
339
  }
@@ -276,7 +369,13 @@ async function startBackground(standaloneEntry, opts) {
276
369
  const exited = await waitForProcessExit(child.pid, 2_000);
277
370
  if (!exited) safeKill(child.pid, 'SIGKILL');
278
371
  }
279
- throw new Error(`failed to start background service: ${startErr.message}`);
372
+ // The actual reason (EADDRINUSE, bad CCGAUGE_CONFIG_DIR, port taken
373
+ // by a sibling that survived getPort's check, etc.) is in the log
374
+ // file that the spawned child writes to. Surface the tail so users
375
+ // don't have to discover `ccgauge logs` themselves.
376
+ const tail = await tailLog(logFile, 5);
377
+ const tailNote = tail ? `\nLast log lines (${logFile}):\n${tail}\n` : '';
378
+ throw new Error(`failed to start background service: ${startErr.message}${tailNote}`);
280
379
  }
281
380
 
282
381
  await writeState({
@@ -286,6 +385,13 @@ async function startBackground(standaloneEntry, opts) {
286
385
  url,
287
386
  logFile,
288
387
  startedAt: new Date().toISOString(),
388
+ bootId: bootId(),
389
+ // The Next.js standalone bundle renames the process via
390
+ // `process.title = 'next-server (vX.Y.Z)'` on boot, so `ps` will
391
+ // show "next-server" in the command column. Pinning that as the
392
+ // identity marker means a recycled PID belonging to some other
393
+ // node process won't pass the identity check in isProcessRunning.
394
+ cmdMarker: 'next-server',
289
395
  packageRoot,
290
396
  dataDir: opts.dir ? String(opts.dir) : null,
291
397
  });
@@ -298,7 +404,7 @@ async function stop({ force = false, verbose = true } = {}) {
298
404
  if (verbose) console.log('[ccgauge] no background service state found');
299
405
  return false;
300
406
  }
301
- if (!isProcessRunning(state.pid)) {
407
+ if (!isProcessRunning(state.pid, state)) {
302
408
  await removeState();
303
409
  if (verbose) console.log('[ccgauge] background service is not running; cleaned stale state');
304
410
  return false;
@@ -317,19 +423,28 @@ async function stop({ force = false, verbose = true } = {}) {
317
423
 
318
424
  async function status(opts) {
319
425
  const state = await readState();
320
- const running = !!state && isProcessRunning(state.pid);
426
+ const running = !!state && isProcessRunning(state.pid, state);
321
427
  if (state && !running) await removeState();
322
428
 
323
429
  const payload = state
324
430
  ? { running, ...state }
325
431
  : { running: false };
432
+ // Exit-code convention split between the two output modes:
433
+ // - Plain text → systemd-style 3 when not running, so
434
+ // `if ccgauge status; then …` works in shell.
435
+ // - `--json` → always 0; the consumer is a script that should read
436
+ // `payload.running` from the JSON. Non-zero here would break
437
+ // pipelines like `ccgauge status --json | jq` under `set -e`.
438
+ // Number inlined (no const) because this function sits below the
439
+ // file's top-level `await program.parseAsync(...)` — a const there
440
+ // would be in the TDZ when commander invokes the action handler.
326
441
  if (opts.json) {
327
442
  console.log(JSON.stringify(payload, null, 2));
328
443
  return;
329
444
  }
330
445
  if (!running) {
331
446
  console.log('ccgauge is not running');
332
- return;
447
+ process.exit(3);
333
448
  }
334
449
  console.log([
335
450
  'ccgauge is running',
@@ -342,9 +457,9 @@ async function status(opts) {
342
457
 
343
458
  async function openRunningDashboard() {
344
459
  const state = await readState();
345
- if (!state || !isProcessRunning(state.pid)) {
460
+ if (!state || !isProcessRunning(state.pid, state)) {
346
461
  if (state) await removeState();
347
- console.error('[ccgauge] background service is not running');
462
+ console.error('[ccgauge] error: background service is not running');
348
463
  process.exit(1);
349
464
  }
350
465
  await tryOpen(state.url);
@@ -355,7 +470,7 @@ async function logs(opts) {
355
470
  const state = await readState();
356
471
  const logFile = state?.logFile || DEFAULT_LOG_FILE;
357
472
  if (!existsSync(logFile)) {
358
- console.error(`[ccgauge] log file not found: ${logFile}`);
473
+ console.error(`[ccgauge] error: log file not found: ${logFile}`);
359
474
  process.exit(1);
360
475
  }
361
476
  const lines = Math.max(1, parseInt(String(opts.lines), 10) || 80);
@@ -366,40 +481,57 @@ async function logs(opts) {
366
481
  await followLog(logFile, content.length);
367
482
  }
368
483
 
484
+ /** Shared "build artifact missing" template. Used by start / report /
485
+ * mcp so all three give the same diagnostic shape and so future
486
+ * artifacts only need a one-line registration here. Three install
487
+ * sources mean three different remediation hints:
488
+ *
489
+ * - `npm`: the tarball should already contain this — reinstall is the
490
+ * move. We also print `node -v` / `ccgauge -v` so issue reports
491
+ * don't lose those.
492
+ * - `source`: a `pnpm build` (or the per-artifact target) is missing.
493
+ * - `dev`: there's no built artifact in dev mode — point at `pnpm dev`. */
494
+ function missingArtifactError({ artifactName, expectedPath, buildCmd, devCmd }) {
495
+ const lines = [
496
+ '',
497
+ `[ccgauge] error: ${artifactName} not found:`,
498
+ ` ${expectedPath}`,
499
+ '',
500
+ 'If you installed ccgauge from npm: please reinstall — the published',
501
+ 'package should include this artifact. Include the following in any',
502
+ 'bug report so we can spot a packaging regression:',
503
+ ` node: ${process.version}`,
504
+ ` ccgauge: v${pkg.version ?? '?'}`,
505
+ ` platform: ${process.platform}/${process.arch}`,
506
+ '',
507
+ `If you are running from source: build first with`,
508
+ ` $ ${buildCmd}`,
509
+ ];
510
+ if (devCmd) lines.push(`or run the dev server with`, ` $ ${devCmd}`);
511
+ lines.push('');
512
+ console.error(lines.join('\n'));
513
+ process.exit(1);
514
+ }
515
+
369
516
  function assertStandaloneEntry() {
370
517
  const standaloneEntry = join(packageRoot, '.next', 'standalone', 'server.js');
371
518
  if (existsSync(standaloneEntry)) return standaloneEntry;
372
- console.error(`
373
- [ccgauge] Build artifact not found:
374
- ${standaloneEntry}
375
-
376
- If you installed ccgauge from npm: please reinstall — the published package should
377
- include the standalone build.
378
-
379
- If you are running from source: build first with
380
- $ pnpm build
381
- or run the dev server with
382
- $ pnpm dev
383
- `);
384
- process.exit(1);
519
+ missingArtifactError({
520
+ artifactName: 'Build artifact',
521
+ expectedPath: standaloneEntry,
522
+ buildCmd: 'pnpm build',
523
+ devCmd: 'pnpm dev',
524
+ });
385
525
  }
386
526
 
387
527
  async function report(opts) {
388
528
  const bundle = join(packageRoot, 'dist', 'report', 'index.mjs');
389
529
  if (!existsSync(bundle)) {
390
- console.error(`
391
- [ccgauge] Report bundle not found:
392
- ${bundle}
393
-
394
- If you installed ccgauge from npm: please reinstall — the published package
395
- should include the report bundle.
396
-
397
- If you are running from source: build it first with
398
- $ pnpm build:report
399
- or run the full build with
400
- $ pnpm build
401
- `);
402
- process.exit(1);
530
+ missingArtifactError({
531
+ artifactName: 'Report bundle',
532
+ expectedPath: bundle,
533
+ buildCmd: 'pnpm build:report',
534
+ });
403
535
  }
404
536
  const limit = parseInt(String(opts.limit ?? '10'), 10);
405
537
  const reportOpts = {
@@ -411,7 +543,7 @@ or run the full build with
411
543
  since: opts.since ? String(opts.since) : undefined,
412
544
  until: opts.until ? String(opts.until) : undefined,
413
545
  json: Boolean(opts.json),
414
- color: opts.color !== false && process.stdout.isTTY,
546
+ color: shouldUseColor({ forceOff: opts.color === false }),
415
547
  showTrend: opts.trend !== false,
416
548
  showBreakdown: opts.breakdown !== false,
417
549
  model: opts.model ? String(opts.model) : undefined,
@@ -423,7 +555,7 @@ or run the full build with
423
555
  const out = await mod.runReport(reportOpts);
424
556
  payload = out.endsWith('\n') ? out : out + '\n';
425
557
  } catch (err) {
426
- console.error(`[ccgauge] report failed: ${(err && err.message) || err}`);
558
+ console.error(`[ccgauge] error: report failed: ${(err && err.message) || err}`);
427
559
  process.exit(1);
428
560
  }
429
561
  // The indexer keeps fs watchers alive, which would block process exit.
@@ -442,19 +574,11 @@ or run the full build with
442
574
  async function startMcp(opts = {}) {
443
575
  const bundle = join(packageRoot, 'dist', 'mcp', 'server.mjs');
444
576
  if (!existsSync(bundle)) {
445
- console.error(`
446
- [ccgauge-mcp] Build artifact not found:
447
- ${bundle}
448
-
449
- If you installed ccgauge from npm: please reinstall — the published package should
450
- include the MCP server bundle.
451
-
452
- If you are running from source: build first with
453
- $ pnpm build:mcp
454
- or run the full build with
455
- $ pnpm build
456
- `);
457
- process.exit(1);
577
+ missingArtifactError({
578
+ artifactName: 'MCP server bundle',
579
+ expectedPath: bundle,
580
+ buildCmd: 'pnpm build:mcp',
581
+ });
458
582
  }
459
583
 
460
584
  // --check: don't actually run the JSON-RPC server — load the bundle,
@@ -463,7 +587,7 @@ or run the full build with
463
587
  if (opts.check) {
464
588
  const mod = await import(pathToFileURL(bundle).href);
465
589
  if (typeof mod.printCheck !== 'function') {
466
- console.error('[ccgauge-mcp] this bundle was built without --check support');
590
+ console.error('[ccgauge-mcp] error: this bundle was built without --check support');
467
591
  process.exit(1);
468
592
  }
469
593
  const code = await mod.printCheck();
@@ -475,18 +599,118 @@ or run the full build with
475
599
  // second Node process here is wasted memory/latency (LLM clients already
476
600
  // spawn `ccgauge mcp` per conversation), and forwarding signals across
477
601
  // processes is brittle (e.g. SIGHUP isn't covered by the old shim).
602
+ //
603
+ // CRITICAL: `runStdioServer()` resolves as soon as
604
+ // `server.connect(transport)` finishes the JSON-RPC handshake setup —
605
+ // the long-running stdio listener is what holds the process alive
606
+ // afterwards. We must NOT call `process.exit(0)` after the await
607
+ // returns, or we'd kill the process before any client `initialize`
608
+ // can land. The dist/mcp/server.mjs direct-entry path gets this right
609
+ // (entry.ts uses `.catch()` only); the CLI in-process wrapper used to
610
+ // exit here too — a regression introduced when we moved off the
611
+ // spawn-a-child design. Don't reintroduce.
478
612
  try {
479
613
  const mod = await import(pathToFileURL(bundle).href);
480
614
  if (typeof mod.runStdioServer !== 'function') {
481
- console.error('[ccgauge-mcp] bundle missing runStdioServer export');
615
+ console.error('[ccgauge-mcp] error: bundle missing runStdioServer export');
482
616
  process.exit(1);
483
617
  }
484
618
  await mod.runStdioServer();
485
- // runStdioServer keeps the loop alive via the stdio transport; if it
486
- // ever returns it means the transport closed cleanly.
619
+ // Function returned but didn't process.exit stdin listener still
620
+ // alive. Just let the event loop run; the transport calls
621
+ // process.exit(0) when stdin closes (see `shutdown` in
622
+ // lib/mcp/server.ts), which is the LLM client disconnecting.
623
+ } catch (err) {
624
+ console.error('[ccgauge-mcp] error: failed to start:', err?.message ?? err);
625
+ process.exit(1);
626
+ }
627
+ }
628
+
629
+ /** `ccgauge doctor` — print everything we'd ask the user to gather when
630
+ * debugging a "why doesn't it work" report, in one place:
631
+ * - version / platform
632
+ * - env vars that influence ccgauge behaviour
633
+ * - on-disk build artifacts (each can be rebuilt independently)
634
+ * - background service state (if any)
635
+ * - delegated MCP `--check` for indexer + per-provider scan stats
636
+ * Output is plain text (no colour) so it's easy to paste into a GitHub
637
+ * issue. */
638
+ async function doctor() {
639
+ const lines = [];
640
+ lines.push(`ccgauge: v${pkg.version ?? '?'}`);
641
+ lines.push(`node: ${process.version} ${process.platform}/${process.arch}`);
642
+ lines.push(`cwd: ${process.cwd()}`);
643
+ lines.push(`stateDir: ${STATE_DIR}`);
644
+ lines.push('');
645
+
646
+ const envKeys = [
647
+ 'CCGAUGE_CONFIG_DIR',
648
+ 'CCGAUGE_CODEX_DIR',
649
+ 'CCGAUGE_STATE_DIR',
650
+ 'CCGAUGE_MCP_PRETTY',
651
+ 'CCGAUGE_POLL_FALLBACK',
652
+ 'CLAUDE_CONFIG_DIR',
653
+ 'CODEX_HOME',
654
+ 'NO_COLOR',
655
+ 'FORCE_COLOR',
656
+ ];
657
+ const setEnvs = envKeys.filter((k) => process.env[k] !== undefined);
658
+ if (setEnvs.length > 0) {
659
+ lines.push('environment:');
660
+ for (const k of setEnvs) lines.push(` ${k}=${process.env[k]}`);
661
+ lines.push('');
662
+ } else {
663
+ lines.push('environment: (no ccgauge / NO_COLOR vars set)');
664
+ lines.push('');
665
+ }
666
+
667
+ const artifacts = [
668
+ ['Dashboard', join(packageRoot, '.next', 'standalone', 'server.js'), 'pnpm build'],
669
+ ['MCP bundle', join(packageRoot, 'dist', 'mcp', 'server.mjs'), 'pnpm build:mcp'],
670
+ ['Report bundle', join(packageRoot, 'dist', 'report', 'index.mjs'), 'pnpm build:report'],
671
+ ];
672
+ lines.push('artifacts:');
673
+ for (const [label, p, cmd] of artifacts) {
674
+ const ok = existsSync(p);
675
+ const status = ok ? 'OK ' : 'MISSING';
676
+ const hint = ok ? '' : ` (build with \`${cmd}\`)`;
677
+ lines.push(` ${status} ${label.padEnd(14)} ${p}${hint}`);
678
+ }
679
+ lines.push('');
680
+
681
+ const st = await readState();
682
+ if (st) {
683
+ const running = isProcessRunning(st.pid, st);
684
+ lines.push(`background: pid=${st.pid} ${running ? '(running)' : '(stale)'} port=${st.port} url=${st.url}`);
685
+ lines.push(` log=${st.logFile}`);
686
+ } else {
687
+ lines.push(`background: (none)`);
688
+ }
689
+ lines.push('');
690
+
691
+ // Print everything we've accumulated so the indexer probe's output
692
+ // appears below it (printCheck writes to stdout synchronously).
693
+ for (const l of lines) console.log(l);
694
+
695
+ // Delegate to the MCP bundle's printCheck() for indexer / providers
696
+ // detail — same info `ccgauge mcp --check` shows, in the same format.
697
+ const mcpBundle = join(packageRoot, 'dist', 'mcp', 'server.mjs');
698
+ if (!existsSync(mcpBundle)) {
699
+ console.log('indexer: (MCP bundle missing; rebuild to run the indexer probe)');
700
+ process.exit(0);
701
+ }
702
+ try {
703
+ const mod = await import(pathToFileURL(mcpBundle).href);
704
+ if (typeof mod.printCheck === 'function') {
705
+ const code = await mod.printCheck();
706
+ // Doctor exit code mirrors the indexer probe: 0 on success, non-0
707
+ // otherwise. Anything that printCheck couldn't decide we treat as
708
+ // success — doctor is a diagnostic tool, not a gate.
709
+ process.exit(typeof code === 'number' ? code : 0);
710
+ }
487
711
  process.exit(0);
488
712
  } catch (err) {
489
- console.error('[ccgauge-mcp] failed to start:', err?.message ?? err);
713
+ console.error(`[ccgauge] error: indexer probe failed: ${err?.message ?? err}`);
490
714
  process.exit(1);
491
715
  }
492
716
  }
@@ -504,6 +728,7 @@ async function resolvePort(opts) {
504
728
  const candidates = opts.strictPort
505
729
  ? preferred
506
730
  : [preferred, ...Array.from({ length: 19 }, (_, i) => preferred + i + 1).filter((p) => p <= 65535), 0];
731
+ const getPort = await loadGetPort();
507
732
  const port = await getPort({ port: candidates });
508
733
  if (opts.strictPort && port !== preferred) {
509
734
  throw new Error(`port ${preferred} is already in use`);
@@ -524,6 +749,20 @@ function makeServerEnv(opts, port) {
524
749
  return env;
525
750
  }
526
751
 
752
+ /** Read the last `lines` non-empty lines of a log file. Used as a hint
753
+ * in error messages when the spawned background server fails to come
754
+ * up — the actual root cause (port conflict, parse error, etc.) is in
755
+ * the log file and we'd rather not make the user go fishing for it. */
756
+ async function tailLog(logFile, lines = 5) {
757
+ try {
758
+ const content = await readFile(logFile, 'utf8');
759
+ const all = content.split(/\r?\n/);
760
+ return all.filter((l) => l.length > 0).slice(-lines).join('\n');
761
+ } catch {
762
+ return '';
763
+ }
764
+ }
765
+
527
766
  async function waitForUrl(target, timeoutMs) {
528
767
  const deadline = Date.now() + timeoutMs;
529
768
  let lastErr;
@@ -544,26 +783,42 @@ async function waitForUrl(target, timeoutMs) {
544
783
 
545
784
  async function tryOpen(url) {
546
785
  try {
786
+ const openBrowser = await loadOpenBrowser();
547
787
  await openBrowser(url);
548
788
  } catch {
549
789
  // ignore — user may be on remote without a browser
550
790
  }
551
791
  }
552
792
 
793
+ /** Build a small palette of ANSI escapes that collapse to '' when colour
794
+ * is off, so the same template literal works for both modes without an
795
+ * if/else per line. */
796
+ function ansiPalette(useColor) {
797
+ const seq = (...codes) => (useColor ? `\x1b[${codes.join(';')}m` : '');
798
+ return {
799
+ bold: seq(1),
800
+ cyan: seq(36),
801
+ dim: seq(2),
802
+ brand: seq(38, 2, 201, 100, 66),
803
+ reset: useColor ? '\x1b[0m' : '',
804
+ };
805
+ }
806
+
553
807
  function printReady(url, opts = {}) {
808
+ const c = ansiPalette(shouldUseColor());
554
809
  const banner = [
555
810
  '',
556
- ' \x1b[1m\x1b[38;2;201;100;66mccgauge\x1b[0m Local Usage Dashboard',
811
+ ` ${c.bold}${c.brand}ccgauge${c.reset} Local Usage Dashboard`,
557
812
  '',
558
- ` ➜ Local: \x1b[36m${url}\x1b[0m`,
813
+ ` ➜ Local: ${c.cyan}${url}${c.reset}`,
559
814
  opts.background
560
- ? ` ➜ PID: \x1b[2m${opts.pid}\x1b[0m`
561
- : ` ➜ Press \x1b[2mCtrl+C\x1b[0m to stop`,
815
+ ? ` ➜ PID: ${c.dim}${opts.pid}${c.reset}`
816
+ : ` ➜ Press ${c.dim}Ctrl+C${c.reset} to stop`,
562
817
  opts.background
563
- ? ` ➜ Log: \x1b[2m${opts.logFile}\x1b[0m`
818
+ ? ` ➜ Log: ${c.dim}${opts.logFile}${c.reset}`
564
819
  : '',
565
820
  opts.background
566
- ? ` ➜ Stop: \x1b[2mccgauge stop\x1b[0m`
821
+ ? ` ➜ Stop: ${c.dim}ccgauge stop${c.reset}`
567
822
  : '',
568
823
  '',
569
824
  ].filter(Boolean).join('\n');
@@ -615,14 +870,53 @@ async function removeState() {
615
870
  await rm(STATE_FILE, { force: true });
616
871
  }
617
872
 
618
- function isProcessRunning(pid) {
873
+ /** Best-effort approximation of system boot time, in ms since the epoch.
874
+ * Used as a `bootId` on persisted state so we can reject stale PIDs
875
+ * after a reboot (PID space gets recycled and would otherwise let us
876
+ * SIGTERM an unrelated process that happens to inherit the old PID). */
877
+ function bootId() {
878
+ return Math.floor(Date.now() - os.uptime() * 1000);
879
+ }
880
+
881
+ /** Optional identity check on top of the existing `process.kill(pid, 0)`.
882
+ * Pass `state` to additionally verify:
883
+ * 1. We're still on the same boot (uptime hasn't reset).
884
+ * 2. `ps -p <pid> -o command=` mentions `state.cmdMarker` — i.e. that
885
+ * pid actually points at *our* dashboard child and not some other
886
+ * process that re-inherited the PID.
887
+ * Failures of (2) are tolerated (`ps` missing, sandboxed exec, etc.)
888
+ * so we don't false-negative on minimal containers. */
889
+ function isProcessRunning(pid, state) {
619
890
  if (!pid) return false;
620
891
  try {
621
892
  process.kill(pid, 0);
622
- return true;
623
893
  } catch {
624
894
  return false;
625
895
  }
896
+ if (!state) return true;
897
+ // Reboot detection: bootId drifts by tens of ms across reads on the
898
+ // same boot (os.uptime() has float jitter), so use a generous window.
899
+ if (typeof state.bootId === 'number') {
900
+ if (Math.abs(bootId() - state.bootId) > 60_000) return false;
901
+ }
902
+ if (process.platform !== 'win32' && typeof state.cmdMarker === 'string' && state.cmdMarker) {
903
+ try {
904
+ const out = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
905
+ encoding: 'utf8',
906
+ stdio: ['ignore', 'pipe', 'ignore'],
907
+ timeout: 1_000,
908
+ });
909
+ if (!out.includes(state.cmdMarker)) return false;
910
+ } catch {
911
+ // `ps` not available or PID gone between kill(0) and exec — fall
912
+ // back to the kill(0) result we already have. We deliberately do
913
+ // NOT fail closed here: a missing `ps` is more common than a PID
914
+ // collision (PID reuse requires a reboot or wraparound), so the
915
+ // false-negative cost of "treat as not-running" outweighs the
916
+ // false-positive of "treat as running".
917
+ }
918
+ }
919
+ return true;
626
920
  }
627
921
 
628
922
  async function waitForProcessExit(pid, timeoutMs) {
@@ -653,8 +947,17 @@ async function followLog(logFile, offset) {
653
947
  const s = await stat(logFile);
654
948
  if (s.size < cursor) cursor = 0; // log was truncated / rotated
655
949
  if (s.size === cursor) return;
950
+ // Pin the upper bound to the size we saw in stat() — otherwise a
951
+ // chunk written *after* stat returns would slip into this read,
952
+ // and the next tick (which starts at `s.size`) would replay it.
953
+ // `end` is inclusive, hence the -1.
954
+ const endAt = s.size - 1;
656
955
  await new Promise((res, rej) => {
657
- const stream = createReadStream(logFile, { start: cursor, encoding: 'utf8' });
956
+ const stream = createReadStream(logFile, {
957
+ start: cursor,
958
+ end: endAt,
959
+ encoding: 'utf8',
960
+ });
658
961
  stream.on('data', (chunk) => process.stdout.write(chunk));
659
962
  stream.on('end', () => {
660
963
  cursor = s.size;