@caius_kong/ccusage-dashboard 0.2.11 → 0.2.12

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/README.md CHANGED
@@ -50,11 +50,17 @@ for you to open.
50
50
  npx @caius_kong/ccusage-dashboard --port 9000 # change port
51
51
  npx @caius_kong/ccusage-dashboard --budget 500 # monthly budget cap (default $300)
52
52
  npx @caius_kong/ccusage-dashboard --no-warm # skip background warm-up
53
+ npx @caius_kong/ccusage-dashboard --daemon # run in the background (terminal exits, server keeps running)
54
+ npx @caius_kong/ccusage-dashboard --stop # stop a background instance
53
55
  ```
54
56
 
55
57
  Once started, the launcher prints the dashboard URL — open it in your browser
56
58
  (no browser is auto-launched).
57
59
 
60
+ **`--daemon`** starts the server detached so you can close the terminal and it keeps
61
+ running; run `--stop` to shut it down. Running `--daemon` again reuses the running
62
+ instance instead of starting a second one.
63
+
58
64
  ## What it shows
59
65
 
60
66
  | | |
package/bin/ccusage-ui.js CHANGED
@@ -6,8 +6,16 @@
6
6
  * the local python3, forwarding any CLI args. The server itself shells out to
7
7
  * ccusage for all numbers, so the dashboard always matches ccusage.
8
8
  *
9
- * Startup UX: once the server is up, a clear URL banner is printed so the
10
- * user can click/copy it to open the dashboard. No background magic.
9
+ * Modes:
10
+ * foreground (default) — the server runs attached to this terminal; Ctrl+C
11
+ * stops it. A URL banner is printed when it is up.
12
+ * --daemon — start the server detached in the background, print
13
+ * the URL, and exit immediately. The server keeps
14
+ * running after this terminal closes. If a server is
15
+ * already up on the port, it reuses it instead of
16
+ * starting a second one.
17
+ *
18
+ * Stop a daemon with: npx @caius_kong/ccusage-dashboard --stop
11
19
  */
12
20
  'use strict';
13
21
 
@@ -15,6 +23,7 @@ const { spawn, spawnSync } = require('node:child_process');
15
23
  const path = require('node:path');
16
24
  const fs = require('node:fs');
17
25
  const http = require('node:http');
26
+ const os = require('node:os');
18
27
 
19
28
  // Data files live in package/lib (installed layout) or the repo root (dev layout).
20
29
  function resolveLibFile(name) {
@@ -45,7 +54,7 @@ if (!py) {
45
54
  process.exit(1);
46
55
  }
47
56
 
48
- // Parse the user's --port / --host so the banner points at the real URL.
57
+ // --- arg parsing -----------------------------------------------------------
49
58
  const rawArgs = process.argv.slice(2);
50
59
  function argValue(name, fallback) {
51
60
  const i = rawArgs.indexOf(name);
@@ -54,23 +63,131 @@ function argValue(name, fallback) {
54
63
  const host = argValue('--host', '127.0.0.1');
55
64
  const port = parseInt(argValue('--port', '8799'), 10);
56
65
  const url = `http://${host}:${port}`;
66
+ const wantDaemon = rawArgs.includes('--daemon');
67
+ const wantStop = rawArgs.includes('--stop');
57
68
 
58
- const args = rawArgs.slice();
69
+ // Filter launcher-only flags before forwarding to server.py.
70
+ const serverArgs = rawArgs.filter((a) => !['--daemon', '--stop'].includes(a));
59
71
 
60
- const child = spawn(py, [serverPy, ...args], { stdio: 'inherit', env: { ...process.env } });
72
+ // --- daemon pid file (per host:port) ----------------------------------------
73
+ function pidFile() {
74
+ const safeHost = host.replace(/[^a-z0-9]/gi, '_');
75
+ return path.join(os.tmpdir(), `ccusage-dashboard-${safeHost}-${port}.pid`);
76
+ }
77
+
78
+ function readDaemonPid() {
79
+ try {
80
+ const pid = parseInt(fs.readFileSync(pidFile(), 'utf8').trim(), 10);
81
+ return Number.isFinite(pid) ? pid : null;
82
+ } catch { return null; }
83
+ }
84
+
85
+ function daemonAlive(pid) {
86
+ if (!pid) return false;
87
+ try { process.kill(pid, 0); return true; } catch { return false; }
88
+ }
89
+
90
+ function isServerUp() {
91
+ return new Promise((resolve) => {
92
+ const req = http.get(url + '/api/health', { timeout: 1200 }, (res) => {
93
+ resolve(res.statusCode === 200);
94
+ });
95
+ req.on('error', () => resolve(false));
96
+ });
97
+ }
61
98
 
62
- // Once the server answers /, print a clear URL banner for manual opening.
63
- let bannerPrinted = false;
64
99
  function printBanner() {
65
- if (bannerPrinted) return;
66
- bannerPrinted = true;
67
100
  const line = '─'.repeat(Math.max(20, url.length + 6));
68
101
  console.log('');
69
102
  console.log(line);
70
103
  console.log(` Dashboard is running → ${url}`);
71
104
  console.log(` Open it manually: ${url}`);
72
105
  console.log(line);
73
- console.log('');
106
+ if (wantDaemon) {
107
+ console.log(` (background mode — stop with: npx @caius_kong/ccusage-dashboard --stop)`);
108
+ console.log('');
109
+ }
110
+ }
111
+
112
+ async function waitForUp(timeoutMs) {
113
+ const start = Date.now();
114
+ while (Date.now() - start < timeoutMs) {
115
+ if (await isServerUp()) return true;
116
+ await new Promise((r) => setTimeout(r, 300));
117
+ }
118
+ return false;
119
+ }
120
+
121
+ // --- stop -------------------------------------------------------------------
122
+ async function handleStop() {
123
+ const pid = readDaemonPid();
124
+ if (pid && daemonAlive(pid)) {
125
+ try { process.kill(pid, 'SIGTERM'); } catch {}
126
+ console.log(`Stopping dashboard (pid ${pid}) …`);
127
+ const t0 = Date.now();
128
+ while (Date.now() - t0 < 5000 && await isServerUp()) {
129
+ await new Promise((r) => setTimeout(r, 300));
130
+ }
131
+ try { fs.unlinkSync(pidFile()); } catch {}
132
+ console.log('Dashboard stopped.');
133
+ process.exit(0);
134
+ }
135
+ console.log('No running dashboard instance found.');
136
+ process.exit(0);
137
+ }
138
+
139
+ if (wantStop) {
140
+ handleStop();
141
+ return;
142
+ }
143
+
144
+ // --- daemon mode ------------------------------------------------------------
145
+ async function handleDaemon() {
146
+ // Reuse an already-running instance on this port.
147
+ if (await isServerUp()) {
148
+ console.log(`A dashboard is already running at ${url} — reusing it.`);
149
+ printBanner();
150
+ process.exit(0);
151
+ }
152
+ // If the pid file points at a dead pid, clear it.
153
+ const oldPid = readDaemonPid();
154
+ if (oldPid && !daemonAlive(oldPid)) {
155
+ try { fs.unlinkSync(pidFile()); } catch {}
156
+ }
157
+ // Start detached; the server outlives this launcher.
158
+ const out = fs.openSync(path.join(os.tmpdir(), `ccusage-dashboard-${port}.log`), 'a');
159
+ const child = spawn(py, [serverPy, ...serverArgs], {
160
+ detached: true,
161
+ stdio: ['ignore', out, out],
162
+ env: { ...process.env },
163
+ });
164
+ child.unref();
165
+ fs.writeFileSync(pidFile(), String(child.pid));
166
+ const up = await waitForUp(15000);
167
+ if (!up) {
168
+ console.error(`[ccusage-dashboard] server did not come up within 15s — check ${path.join(os.tmpdir(), `ccusage-dashboard-${port}.log`)}`);
169
+ try { process.kill(child.pid, 'SIGTERM'); } catch {}
170
+ process.exit(1);
171
+ }
172
+ console.log(`ccusage-dashboard started in background (pid ${child.pid}).`);
173
+ printBanner();
174
+ process.exit(0);
175
+ }
176
+
177
+ if (wantDaemon) {
178
+ handleDaemon();
179
+ return;
180
+ }
181
+
182
+ // --- foreground mode (default) ----------------------------------------------
183
+ const child = spawn(py, [serverPy, ...serverArgs], { stdio: 'inherit', env: { ...process.env } });
184
+
185
+ // Once the server answers /, print a clear URL banner for manual opening.
186
+ let bannerPrinted = false;
187
+ function printBannerFg() {
188
+ if (bannerPrinted) return;
189
+ bannerPrinted = true;
190
+ printBanner();
74
191
  }
75
192
 
76
193
  let checked = false;
@@ -78,7 +195,7 @@ function pingAndBanner() {
78
195
  if (checked) return;
79
196
  checked = true;
80
197
  const req = http.get(url + '/api/health', { timeout: 1500 }, (res) => {
81
- if (res.statusCode === 200) printBanner();
198
+ if (res.statusCode === 200) printBannerFg();
82
199
  else setTimeout(pingAndBanner, 500);
83
200
  });
84
201
  req.on('error', () => setTimeout(pingAndBanner, 500));
@@ -89,4 +206,4 @@ setTimeout(pingAndBanner, 800); // give the python server a beat to bind
89
206
  for (const sig of ['SIGINT', 'SIGTERM']) {
90
207
  process.on(sig, () => { if (!child.killed) child.kill(sig); });
91
208
  }
92
- child.on('exit', (code) => process.exit(code == null ? 0 : code));
209
+ child.on('exit', (code) => process.exit(code == null ? 0 : code));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caius_kong/ccusage-dashboard",
3
- "version": "0.2.11",
3
+ "version": "0.2.12",
4
4
  "description": "One-command local dashboard for ccusage: today/week/month/custom cost by model, 30-day trend, monthly budget alert. Numbers straight from ccusage.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",