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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/app-build-manifest.json +33 -33
- package/.next/standalone/.next/app-path-routes-manifest.json +9 -9
- package/.next/standalone/.next/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/_not-found/page.js +2 -2
- package/.next/standalone/.next/server/app/api/projects/route.js +1 -1
- package/.next/standalone/.next/server/app/api/projects/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/sessions/route.js +1 -1
- package/.next/standalone/.next/server/app/api/sessions/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/usage/route.js +1 -1
- package/.next/standalone/.next/server/app/api/usage/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/models/page.js +2 -2
- package/.next/standalone/.next/server/app/models/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page.js +2 -2
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/[id]/page.js +2 -2
- package/.next/standalone/.next/server/app/projects/[id]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js +1 -1
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/sessions/[id]/page.js +2 -2
- package/.next/standalone/.next/server/app/sessions/[id]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/sessions/page.js +1 -1
- package/.next/standalone/.next/server/app/sessions/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page.js +1 -1
- package/.next/standalone/.next/server/app/usage/page.js +3 -3
- package/.next/standalone/.next/server/app/usage/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app-paths-manifest.json +9 -9
- package/.next/standalone/.next/server/chunks/287.js +1 -0
- package/.next/standalone/.next/server/chunks/567.js +2 -2
- package/.next/standalone/.next/server/chunks/730.js +1 -0
- package/.next/standalone/.next/server/functions-config-manifest.json +2 -2
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/node_modules/next/node_modules/postcss/package.json +0 -0
- package/.next/standalone/package.json +1 -1
- package/.next/standalone/public/codex-logo.png +0 -0
- package/CHANGELOG.md +223 -0
- package/README.md +6 -2
- package/README.zh-CN.md +6 -2
- package/bin/cli.mjs +394 -91
- package/dist/mcp/server.mjs +16 -16
- package/dist/report/index.mjs +183 -28
- package/package.json +22 -24
- package/.next/standalone/.next/server/chunks/971.js +0 -1
- /package/.next/standalone/.next/static/{ir1LZCnQKkiNUVXLprtzh → kdpS1dOlXPsnKYuNBuMt9}/_buildManifest.js +0 -0
- /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 {
|
|
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
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 (!
|
|
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
|
-
|
|
180
|
-
|
|
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 (
|
|
188
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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',
|
|
231
|
-
process.on('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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
//
|
|
486
|
-
//
|
|
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(
|
|
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
|
-
|
|
811
|
+
` ${c.bold}${c.brand}ccgauge${c.reset} Local Usage Dashboard`,
|
|
557
812
|
'',
|
|
558
|
-
` ➜ Local:
|
|
813
|
+
` ➜ Local: ${c.cyan}${url}${c.reset}`,
|
|
559
814
|
opts.background
|
|
560
|
-
? ` ➜ PID:
|
|
561
|
-
: ` ➜ Press
|
|
815
|
+
? ` ➜ PID: ${c.dim}${opts.pid}${c.reset}`
|
|
816
|
+
: ` ➜ Press ${c.dim}Ctrl+C${c.reset} to stop`,
|
|
562
817
|
opts.background
|
|
563
|
-
? ` ➜ Log:
|
|
818
|
+
? ` ➜ Log: ${c.dim}${opts.logFile}${c.reset}`
|
|
564
819
|
: '',
|
|
565
820
|
opts.background
|
|
566
|
-
? ` ➜ Stop:
|
|
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
|
-
|
|
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, {
|
|
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;
|