@roastcodes/ttdash 6.1.0

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/server.js ADDED
@@ -0,0 +1,1895 @@
1
+ #!/usr/bin/env node
2
+
3
+ const http = require('http');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const readline = require('readline/promises');
8
+ const { spawn } = require('child_process');
9
+ const { parseArgs } = require('util');
10
+ const { normalizeIncomingData } = require('./usage-normalizer');
11
+ const { generatePdfReport } = require('./server/report');
12
+ const { version: APP_VERSION } = require('./package.json');
13
+
14
+ const ROOT = __dirname;
15
+ const STATIC_ROOT = path.join(ROOT, 'dist');
16
+ const APP_DIR_NAME = 'TTDash';
17
+ const APP_DIR_NAME_LINUX = 'ttdash';
18
+ const LEGACY_DATA_FILE = path.join(ROOT, 'data.json');
19
+ const RAW_CLI_ARGS = process.argv.slice(2);
20
+ const NORMALIZED_CLI_ARGS = normalizeCliArgs(RAW_CLI_ARGS);
21
+ const CLI_OPTIONS = parseCliArgs(RAW_CLI_ARGS);
22
+ const ENV_START_PORT = parseInt(process.env.PORT, 10);
23
+ const START_PORT = CLI_OPTIONS.port ?? (Number.isFinite(ENV_START_PORT) ? ENV_START_PORT : 3000);
24
+ const MAX_PORT = Math.min(START_PORT + 100, 65535);
25
+ const BIND_HOST = process.env.HOST || '127.0.0.1';
26
+ const API_PREFIX = '/port/5000/api';
27
+ const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB
28
+ const IS_WINDOWS = process.platform === 'win32';
29
+ const TOKTRACK_LOCAL_BIN = path.join(ROOT, 'node_modules', '.bin', IS_WINDOWS ? 'toktrack.cmd' : 'toktrack');
30
+ const SECURITY_HEADERS = {
31
+ 'X-Content-Type-Options': 'nosniff',
32
+ 'Referrer-Policy': 'no-referrer',
33
+ 'X-Frame-Options': 'DENY',
34
+ 'Cross-Origin-Opener-Policy': 'same-origin',
35
+ 'Content-Security-Policy': "default-src 'self'; connect-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'",
36
+ };
37
+ const APP_LABEL = 'TTDash';
38
+ const SETTINGS_BACKUP_KIND = 'ttdash-settings-backup';
39
+ const USAGE_BACKUP_KIND = 'ttdash-usage-backup';
40
+ const IS_BACKGROUND_CHILD = process.env.TTDASH_BACKGROUND_CHILD === '1';
41
+ const FORCE_OPEN_BROWSER = process.env.TTDASH_FORCE_OPEN_BROWSER === '1';
42
+ const BACKGROUND_START_TIMEOUT_MS = 15000;
43
+ const DASHBOARD_DATE_PRESETS = ['all', '7d', '30d', 'month', 'year'];
44
+ const DASHBOARD_SECTION_IDS = [
45
+ 'insights',
46
+ 'metrics',
47
+ 'today',
48
+ 'currentMonth',
49
+ 'activity',
50
+ 'forecastCache',
51
+ 'limits',
52
+ 'costAnalysis',
53
+ 'tokenAnalysis',
54
+ 'requestAnalysis',
55
+ 'advancedAnalysis',
56
+ 'comparisons',
57
+ 'tables',
58
+ ];
59
+ const DEFAULT_SETTINGS = {
60
+ language: 'de',
61
+ theme: 'dark',
62
+ providerLimits: {},
63
+ defaultFilters: {
64
+ viewMode: 'daily',
65
+ datePreset: 'all',
66
+ providers: [],
67
+ models: [],
68
+ },
69
+ sectionVisibility: Object.fromEntries(DASHBOARD_SECTION_IDS.map((sectionId) => [sectionId, true])),
70
+ sectionOrder: DASHBOARD_SECTION_IDS,
71
+ lastLoadedAt: null,
72
+ lastLoadSource: null,
73
+ };
74
+ let startupAutoLoadCompleted = false;
75
+ const RUNTIME_INSTANCE = {
76
+ id: process.env.TTDASH_INSTANCE_ID || `${process.pid}-${Date.now()}`,
77
+ pid: process.pid,
78
+ startedAt: new Date().toISOString(),
79
+ mode: IS_BACKGROUND_CHILD ? 'background' : 'foreground',
80
+ };
81
+ let runtimePort = null;
82
+ let runtimeUrl = null;
83
+
84
+ function normalizeCliArgs(args) {
85
+ return args.map((arg) => {
86
+ if (arg === '-no') {
87
+ return '--no-open';
88
+ }
89
+ if (arg === '-al') {
90
+ return '--auto-load';
91
+ }
92
+ if (arg === '-bg') {
93
+ return '--background';
94
+ }
95
+ return arg;
96
+ });
97
+ }
98
+
99
+ function printHelp() {
100
+ console.log(`TTDash v${APP_VERSION}`);
101
+ console.log('');
102
+ console.log('Usage:');
103
+ console.log(' ttdash [options]');
104
+ console.log(' ttdash stop');
105
+ console.log('');
106
+ console.log('Options:');
107
+ console.log(' -p, --port <port> Set the start port');
108
+ console.log(' -h, --help Show this help');
109
+ console.log(' -no, --no-open Disable browser auto-open');
110
+ console.log(' -al, --auto-load Run auto-import immediately on startup');
111
+ console.log(' -b, --background Start TTDash as a background process');
112
+ console.log('');
113
+ console.log('Examples:');
114
+ console.log(' ttdash --port 3010');
115
+ console.log(' ttdash -p 3010 -no');
116
+ console.log(' ttdash --auto-load');
117
+ console.log(' ttdash --background');
118
+ console.log(' ttdash stop');
119
+ console.log('');
120
+ console.log('Environment variables:');
121
+ console.log(' PORT=3010 ttdash');
122
+ console.log(' NO_OPEN_BROWSER=1 ttdash');
123
+ console.log(' HOST=127.0.0.1 ttdash');
124
+ }
125
+
126
+ function parseCliArgs(rawArgs) {
127
+ const args = normalizeCliArgs(rawArgs);
128
+
129
+ let parsed;
130
+ try {
131
+ parsed = parseArgs({
132
+ args,
133
+ allowPositionals: true,
134
+ strict: true,
135
+ options: {
136
+ port: {
137
+ type: 'string',
138
+ short: 'p',
139
+ },
140
+ help: {
141
+ type: 'boolean',
142
+ short: 'h',
143
+ },
144
+ 'no-open': {
145
+ type: 'boolean',
146
+ },
147
+ 'auto-load': {
148
+ type: 'boolean',
149
+ },
150
+ background: {
151
+ type: 'boolean',
152
+ short: 'b',
153
+ },
154
+ },
155
+ });
156
+ } catch (error) {
157
+ console.error(error.message);
158
+ console.log('');
159
+ printHelp();
160
+ process.exit(1);
161
+ }
162
+
163
+ if (parsed.values.help) {
164
+ printHelp();
165
+ process.exit(0);
166
+ }
167
+
168
+ let command = null;
169
+ if (parsed.positionals.length > 1) {
170
+ console.error(`Unknown invocation: ${parsed.positionals.join(' ')}`);
171
+ console.log('');
172
+ printHelp();
173
+ process.exit(1);
174
+ }
175
+
176
+ if (parsed.positionals.length === 1) {
177
+ if (parsed.positionals[0] !== 'stop') {
178
+ console.error(`Unknown command: ${parsed.positionals[0]}`);
179
+ console.log('');
180
+ printHelp();
181
+ process.exit(1);
182
+ }
183
+
184
+ command = 'stop';
185
+ }
186
+
187
+ let port;
188
+ if (parsed.values.port !== undefined) {
189
+ const parsedPort = Number.parseInt(parsed.values.port, 10);
190
+ if (!Number.isInteger(parsedPort) || parsedPort <= 0 || parsedPort > 65535) {
191
+ console.error(`Invalid port: ${parsed.values.port}`);
192
+ console.log('');
193
+ printHelp();
194
+ process.exit(1);
195
+ }
196
+ port = parsedPort;
197
+ }
198
+
199
+ return {
200
+ command,
201
+ port,
202
+ noOpen: Boolean(parsed.values['no-open']),
203
+ autoLoad: Boolean(parsed.values['auto-load']),
204
+ background: Boolean(parsed.values.background),
205
+ };
206
+ }
207
+
208
+ function resolveAppPaths() {
209
+ const homeDir = os.homedir();
210
+ const explicitPaths = {
211
+ dataDir: process.env.TTDASH_DATA_DIR,
212
+ configDir: process.env.TTDASH_CONFIG_DIR,
213
+ cacheDir: process.env.TTDASH_CACHE_DIR,
214
+ };
215
+ let platformPaths;
216
+
217
+ if (process.platform === 'darwin') {
218
+ const appSupportDir = path.join(homeDir, 'Library', 'Application Support', APP_DIR_NAME);
219
+ platformPaths = {
220
+ dataDir: appSupportDir,
221
+ configDir: appSupportDir,
222
+ cacheDir: path.join(homeDir, 'Library', 'Caches', APP_DIR_NAME),
223
+ };
224
+ } else if (IS_WINDOWS) {
225
+ platformPaths = {
226
+ dataDir: path.join(process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'), APP_DIR_NAME),
227
+ configDir: path.join(process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'), APP_DIR_NAME),
228
+ cacheDir: path.join(process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'), APP_DIR_NAME, 'Cache'),
229
+ };
230
+ } else {
231
+ const appName = APP_DIR_NAME_LINUX;
232
+ platformPaths = {
233
+ dataDir: path.join(process.env.XDG_DATA_HOME || path.join(homeDir, '.local', 'share'), appName),
234
+ configDir: path.join(process.env.XDG_CONFIG_HOME || path.join(homeDir, '.config'), appName),
235
+ cacheDir: path.join(process.env.XDG_CACHE_HOME || path.join(homeDir, '.cache'), appName),
236
+ };
237
+ }
238
+
239
+ return {
240
+ dataDir: explicitPaths.dataDir || platformPaths.dataDir,
241
+ configDir: explicitPaths.configDir || platformPaths.configDir,
242
+ cacheDir: explicitPaths.cacheDir || platformPaths.cacheDir,
243
+ };
244
+ }
245
+
246
+ const APP_PATHS = resolveAppPaths();
247
+ const DATA_FILE = path.join(APP_PATHS.dataDir, 'data.json');
248
+ const SETTINGS_FILE = path.join(APP_PATHS.configDir, 'settings.json');
249
+ const NPX_CACHE_DIR = path.join(APP_PATHS.cacheDir, 'npx-cache');
250
+ const BACKGROUND_INSTANCES_FILE = path.join(APP_PATHS.configDir, 'background-instances.json');
251
+ const BACKGROUND_LOG_DIR = path.join(APP_PATHS.cacheDir, 'background');
252
+ const BACKGROUND_INSTANCES_LOCK_DIR = path.join(APP_PATHS.configDir, 'background-instances.lock');
253
+ const BACKGROUND_INSTANCES_LOCK_TIMEOUT_MS = 5000;
254
+ const BACKGROUND_INSTANCES_LOCK_STALE_MS = 10000;
255
+
256
+ const MIME_TYPES = {
257
+ '.html': 'text/html; charset=utf-8',
258
+ '.css': 'text/css; charset=utf-8',
259
+ '.js': 'application/javascript; charset=utf-8',
260
+ '.json': 'application/json; charset=utf-8',
261
+ '.png': 'image/png',
262
+ '.jpg': 'image/jpeg',
263
+ '.jpeg': 'image/jpeg',
264
+ '.gif': 'image/gif',
265
+ '.svg': 'image/svg+xml',
266
+ '.ico': 'image/x-icon',
267
+ '.woff': 'font/woff',
268
+ '.woff2': 'font/woff2',
269
+ };
270
+
271
+ function ensureDir(dirPath) {
272
+ fs.mkdirSync(dirPath, { recursive: true });
273
+ }
274
+
275
+ function ensureAppDirs() {
276
+ ensureDir(APP_PATHS.dataDir);
277
+ ensureDir(APP_PATHS.configDir);
278
+ ensureDir(APP_PATHS.cacheDir);
279
+ ensureDir(NPX_CACHE_DIR);
280
+ ensureDir(BACKGROUND_LOG_DIR);
281
+ }
282
+
283
+ function writeJsonAtomic(filePath, data) {
284
+ ensureDir(path.dirname(filePath));
285
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
286
+ fs.writeFileSync(tempPath, JSON.stringify(data, null, 2));
287
+ fs.renameSync(tempPath, filePath);
288
+ }
289
+
290
+ function sleep(ms) {
291
+ return new Promise((resolve) => setTimeout(resolve, ms));
292
+ }
293
+
294
+ function formatDateTime(value) {
295
+ return new Intl.DateTimeFormat('de-CH', {
296
+ dateStyle: 'short',
297
+ timeStyle: 'medium',
298
+ }).format(new Date(value));
299
+ }
300
+
301
+ function isProcessRunning(pid) {
302
+ if (!Number.isInteger(pid) || pid <= 0) {
303
+ return false;
304
+ }
305
+
306
+ try {
307
+ process.kill(pid, 0);
308
+ return true;
309
+ } catch (error) {
310
+ return error && error.code === 'EPERM';
311
+ }
312
+ }
313
+
314
+ async function fetchRuntimeIdentity(url, timeoutMs = 1000) {
315
+ if (typeof url !== 'string' || !url.trim()) {
316
+ return null;
317
+ }
318
+
319
+ const controller = new AbortController();
320
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
321
+
322
+ try {
323
+ const response = await fetch(new URL('/api/runtime', `${url}/`), {
324
+ signal: controller.signal,
325
+ });
326
+
327
+ if (!response.ok) {
328
+ return null;
329
+ }
330
+
331
+ const payload = await response.json();
332
+ if (!payload || typeof payload !== 'object') {
333
+ return null;
334
+ }
335
+
336
+ return payload;
337
+ } catch {
338
+ return null;
339
+ } finally {
340
+ clearTimeout(timeout);
341
+ }
342
+ }
343
+
344
+ async function isBackgroundInstanceOwned(instance) {
345
+ if (!instance || typeof instance !== 'object') {
346
+ return false;
347
+ }
348
+
349
+ if (!isProcessRunning(instance.pid)) {
350
+ return false;
351
+ }
352
+
353
+ const runtime = await fetchRuntimeIdentity(instance.url);
354
+ if (!runtime || typeof runtime.id !== 'string') {
355
+ return false;
356
+ }
357
+
358
+ return runtime.id === instance.id
359
+ && runtime.pid === instance.pid
360
+ && runtime.port === instance.port;
361
+ }
362
+
363
+ function normalizeBackgroundInstance(value) {
364
+ if (!value || typeof value !== 'object') {
365
+ return null;
366
+ }
367
+
368
+ const pid = Number.parseInt(value.pid, 10);
369
+ const port = Number.parseInt(value.port, 10);
370
+ const startedAt = normalizeIsoTimestamp(value.startedAt);
371
+ const id = typeof value.id === 'string' && value.id.trim()
372
+ ? value.id.trim()
373
+ : null;
374
+ const url = typeof value.url === 'string' && value.url.trim()
375
+ ? value.url.trim()
376
+ : null;
377
+ const host = typeof value.host === 'string' && value.host.trim()
378
+ ? value.host.trim()
379
+ : BIND_HOST;
380
+
381
+ if (!id || !url || !startedAt || !Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port) || port <= 0) {
382
+ return null;
383
+ }
384
+
385
+ return {
386
+ id,
387
+ pid,
388
+ port,
389
+ url,
390
+ host,
391
+ startedAt,
392
+ logFile: typeof value.logFile === 'string' && value.logFile.trim()
393
+ ? value.logFile.trim()
394
+ : null,
395
+ };
396
+ }
397
+
398
+ function readBackgroundInstancesRaw() {
399
+ try {
400
+ const parsed = JSON.parse(fs.readFileSync(BACKGROUND_INSTANCES_FILE, 'utf-8'));
401
+ if (Array.isArray(parsed)) {
402
+ return parsed;
403
+ }
404
+ } catch {}
405
+
406
+ return [];
407
+ }
408
+
409
+ function writeBackgroundInstances(instances) {
410
+ writeJsonAtomic(BACKGROUND_INSTANCES_FILE, instances);
411
+ }
412
+
413
+ async function readBackgroundInstancesSnapshot() {
414
+ const normalized = readBackgroundInstancesRaw()
415
+ .map(normalizeBackgroundInstance)
416
+ .filter(Boolean);
417
+ const alive = [];
418
+
419
+ for (const instance of normalized) {
420
+ if (await isBackgroundInstanceOwned(instance)) {
421
+ alive.push(instance);
422
+ }
423
+ }
424
+
425
+ const changed = readBackgroundInstancesRaw().length !== alive.length;
426
+
427
+ alive.sort((left, right) => {
428
+ const byStartedAt = left.startedAt.localeCompare(right.startedAt);
429
+ if (byStartedAt !== 0) {
430
+ return byStartedAt;
431
+ }
432
+ return left.port - right.port;
433
+ });
434
+
435
+ return {
436
+ normalized,
437
+ alive,
438
+ changed,
439
+ };
440
+ }
441
+
442
+ async function getBackgroundInstances() {
443
+ return (await readBackgroundInstancesSnapshot()).alive;
444
+ }
445
+
446
+ async function withBackgroundInstancesLock(callback, timeoutMs = BACKGROUND_INSTANCES_LOCK_TIMEOUT_MS) {
447
+ const startedAt = Date.now();
448
+
449
+ while (true) {
450
+ try {
451
+ fs.mkdirSync(BACKGROUND_INSTANCES_LOCK_DIR);
452
+ break;
453
+ } catch (error) {
454
+ if (!error || error.code !== 'EEXIST') {
455
+ throw error;
456
+ }
457
+
458
+ let lockIsStale = false;
459
+ try {
460
+ const stats = fs.statSync(BACKGROUND_INSTANCES_LOCK_DIR);
461
+ lockIsStale = (Date.now() - stats.mtimeMs) > BACKGROUND_INSTANCES_LOCK_STALE_MS;
462
+ } catch {}
463
+
464
+ if (lockIsStale) {
465
+ try {
466
+ fs.rmSync(BACKGROUND_INSTANCES_LOCK_DIR, { recursive: true, force: true });
467
+ continue;
468
+ } catch {}
469
+ }
470
+
471
+ if (Date.now() - startedAt >= timeoutMs) {
472
+ throw new Error('Could not acquire background registry lock.');
473
+ }
474
+
475
+ await sleep(50);
476
+ }
477
+ }
478
+
479
+ try {
480
+ return await callback();
481
+ } finally {
482
+ try {
483
+ fs.rmSync(BACKGROUND_INSTANCES_LOCK_DIR, { recursive: true, force: true });
484
+ } catch {}
485
+ }
486
+ }
487
+
488
+ async function pruneBackgroundInstances() {
489
+ return withBackgroundInstancesLock(async () => {
490
+ const snapshot = await readBackgroundInstancesSnapshot();
491
+ if (snapshot.changed) {
492
+ writeBackgroundInstances(snapshot.alive);
493
+ }
494
+
495
+ return snapshot.alive;
496
+ });
497
+ }
498
+
499
+ async function registerBackgroundInstance(instance) {
500
+ return withBackgroundInstancesLock(async () => {
501
+ const instances = (await readBackgroundInstancesSnapshot()).alive;
502
+ const nextInstances = instances.filter((entry) => entry.pid !== instance.pid);
503
+ nextInstances.push(instance);
504
+ nextInstances.sort((left, right) => {
505
+ const byStartedAt = left.startedAt.localeCompare(right.startedAt);
506
+ if (byStartedAt !== 0) {
507
+ return byStartedAt;
508
+ }
509
+ return left.port - right.port;
510
+ });
511
+ writeBackgroundInstances(nextInstances);
512
+ });
513
+ }
514
+
515
+ async function unregisterBackgroundInstance(pid) {
516
+ return withBackgroundInstancesLock(async () => {
517
+ const instances = (await readBackgroundInstancesSnapshot()).alive;
518
+ const nextInstances = instances.filter((entry) => entry.pid !== pid);
519
+ if (nextInstances.length !== instances.length) {
520
+ writeBackgroundInstances(nextInstances);
521
+ }
522
+ });
523
+ }
524
+
525
+ function createBackgroundInstance({ port, url }) {
526
+ return {
527
+ id: RUNTIME_INSTANCE.id,
528
+ pid: RUNTIME_INSTANCE.pid,
529
+ port,
530
+ url,
531
+ host: BIND_HOST,
532
+ startedAt: RUNTIME_INSTANCE.startedAt,
533
+ logFile: process.env.TTDASH_BACKGROUND_LOG_FILE || null,
534
+ };
535
+ }
536
+
537
+ function buildBackgroundLogFilePath() {
538
+ return path.join(BACKGROUND_LOG_DIR, `server-${Date.now()}.log`);
539
+ }
540
+
541
+ async function waitForBackgroundInstance(pid, timeoutMs = BACKGROUND_START_TIMEOUT_MS) {
542
+ const startedAt = Date.now();
543
+
544
+ while (Date.now() - startedAt < timeoutMs) {
545
+ const instance = (await getBackgroundInstances()).find((entry) => entry.pid === pid);
546
+ if (instance) {
547
+ return instance;
548
+ }
549
+
550
+ if (!isProcessRunning(pid)) {
551
+ return null;
552
+ }
553
+
554
+ await new Promise((resolve) => setTimeout(resolve, 200));
555
+ }
556
+
557
+ return null;
558
+ }
559
+
560
+ async function waitForBackgroundInstanceExit(instance, timeoutMs = 5000) {
561
+ const startedAt = Date.now();
562
+
563
+ while (Date.now() - startedAt < timeoutMs) {
564
+ if (!(await isBackgroundInstanceOwned(instance))) {
565
+ return true;
566
+ }
567
+
568
+ await new Promise((resolve) => setTimeout(resolve, 150));
569
+ }
570
+
571
+ return !(await isBackgroundInstanceOwned(instance));
572
+ }
573
+
574
+ function formatBackgroundInstanceLabel(instance, index) {
575
+ const parts = [
576
+ `${index + 1}. ${instance.url}`,
577
+ `PID ${instance.pid}`,
578
+ `Port ${instance.port}`,
579
+ `started ${formatDateTime(instance.startedAt)}`,
580
+ ];
581
+
582
+ if (instance.logFile) {
583
+ parts.push(`log ${instance.logFile}`);
584
+ }
585
+
586
+ return parts.join(' | ');
587
+ }
588
+
589
+ async function promptForBackgroundInstance(instances) {
590
+ if (instances.length === 1) {
591
+ return instances[0];
592
+ }
593
+
594
+ console.log('Multiple TTDash background servers are running:');
595
+ instances.forEach((instance, index) => {
596
+ console.log(` ${formatBackgroundInstanceLabel(instance, index)}`);
597
+ });
598
+ console.log('');
599
+
600
+ const rl = readline.createInterface({
601
+ input: process.stdin,
602
+ output: process.stdout,
603
+ });
604
+
605
+ try {
606
+ while (true) {
607
+ const answer = (await rl.question(`Which instance should be stopped? [1-${instances.length}, Enter=cancel] `)).trim();
608
+
609
+ if (!answer) {
610
+ return null;
611
+ }
612
+
613
+ const selection = Number.parseInt(answer, 10);
614
+ if (Number.isInteger(selection) && selection >= 1 && selection <= instances.length) {
615
+ return instances[selection - 1];
616
+ }
617
+
618
+ console.log(`Invalid selection: ${answer}`);
619
+ }
620
+ } finally {
621
+ rl.close();
622
+ }
623
+ }
624
+
625
+ async function stopBackgroundInstance(instance) {
626
+ if (!(await isBackgroundInstanceOwned(instance))) {
627
+ await unregisterBackgroundInstance(instance.pid);
628
+ return {
629
+ status: 'already-stopped',
630
+ instance,
631
+ };
632
+ }
633
+
634
+ try {
635
+ process.kill(instance.pid, 'SIGTERM');
636
+ } catch (error) {
637
+ if (error && error.code === 'ESRCH') {
638
+ await unregisterBackgroundInstance(instance.pid);
639
+ return {
640
+ status: 'already-stopped',
641
+ instance,
642
+ };
643
+ }
644
+
645
+ if (error && error.code === 'EPERM') {
646
+ return {
647
+ status: 'forbidden',
648
+ instance,
649
+ };
650
+ }
651
+
652
+ throw error;
653
+ }
654
+
655
+ if (await waitForBackgroundInstanceExit(instance)) {
656
+ await unregisterBackgroundInstance(instance.pid);
657
+ return {
658
+ status: 'stopped',
659
+ instance,
660
+ };
661
+ }
662
+
663
+ return {
664
+ status: 'timeout',
665
+ instance,
666
+ };
667
+ }
668
+
669
+ async function runStopCommand() {
670
+ ensureAppDirs();
671
+
672
+ const instances = await pruneBackgroundInstances();
673
+ if (instances.length === 0) {
674
+ console.log('No running TTDash background servers found.');
675
+ return;
676
+ }
677
+
678
+ const selectedInstance = await promptForBackgroundInstance(instances);
679
+ if (!selectedInstance) {
680
+ console.log('Canceled.');
681
+ return;
682
+ }
683
+
684
+ const result = await stopBackgroundInstance(selectedInstance);
685
+ if (result.status === 'stopped') {
686
+ console.log(`Stopped TTDash background server: ${selectedInstance.url} (PID ${selectedInstance.pid})`);
687
+ return;
688
+ }
689
+
690
+ if (result.status === 'already-stopped') {
691
+ console.log(`Instance was already stopped and was removed from the registry: ${selectedInstance.url} (PID ${selectedInstance.pid})`);
692
+ return;
693
+ }
694
+
695
+ if (result.status === 'forbidden') {
696
+ console.error(`Could not stop TTDash background server (permission denied): ${selectedInstance.url} (PID ${selectedInstance.pid})`);
697
+ process.exitCode = 1;
698
+ return;
699
+ }
700
+
701
+ console.error(`TTDash background server did not respond to SIGTERM: ${selectedInstance.url} (PID ${selectedInstance.pid})`);
702
+ if (selectedInstance.logFile) {
703
+ console.error(`Log file: ${selectedInstance.logFile}`);
704
+ }
705
+ process.exitCode = 1;
706
+ }
707
+
708
+ function shouldBackgroundChildOpenBrowser() {
709
+ return !(CLI_OPTIONS.noOpen || process.env.NO_OPEN_BROWSER === '1' || process.env.CI === '1');
710
+ }
711
+
712
+ async function startInBackground() {
713
+ ensureAppDirs();
714
+
715
+ const logFile = buildBackgroundLogFilePath();
716
+ const childArgs = NORMALIZED_CLI_ARGS.filter((arg) => arg !== '--background');
717
+ const logFd = fs.openSync(logFile, 'a');
718
+
719
+ let child;
720
+ try {
721
+ child = spawn(process.execPath, [__filename, ...childArgs], {
722
+ detached: true,
723
+ stdio: ['ignore', logFd, logFd],
724
+ env: {
725
+ ...process.env,
726
+ TTDASH_BACKGROUND_CHILD: '1',
727
+ TTDASH_BACKGROUND_LOG_FILE: logFile,
728
+ TTDASH_FORCE_OPEN_BROWSER: shouldBackgroundChildOpenBrowser() ? '1' : '0',
729
+ },
730
+ });
731
+ } finally {
732
+ fs.closeSync(logFd);
733
+ }
734
+
735
+ child.unref();
736
+
737
+ const instance = await waitForBackgroundInstance(child.pid);
738
+ if (!instance) {
739
+ const logOutput = fs.existsSync(logFile)
740
+ ? fs.readFileSync(logFile, 'utf-8').trim()
741
+ : '';
742
+ throw new Error(logOutput || `Could not start TTDash as a background process. Log: ${logFile}`);
743
+ }
744
+
745
+ console.log('TTDash is running in the background.');
746
+ console.log(` URL: ${instance.url}`);
747
+ console.log(` PID: ${instance.pid}`);
748
+ console.log(` Log: ${logFile}`);
749
+ console.log('');
750
+ console.log('Stop it with:');
751
+ console.log(' ttdash stop');
752
+ }
753
+
754
+ function migrateLegacyDataFile() {
755
+ if (!fs.existsSync(LEGACY_DATA_FILE) || fs.existsSync(DATA_FILE)) {
756
+ return;
757
+ }
758
+
759
+ ensureDir(path.dirname(DATA_FILE));
760
+
761
+ try {
762
+ fs.renameSync(LEGACY_DATA_FILE, DATA_FILE);
763
+ console.log(`Migrating existing data to ${DATA_FILE}`);
764
+ } catch {
765
+ fs.copyFileSync(LEGACY_DATA_FILE, DATA_FILE);
766
+ try {
767
+ fs.unlinkSync(LEGACY_DATA_FILE);
768
+ } catch {}
769
+ console.log(`Copying existing data to ${DATA_FILE}`);
770
+ }
771
+ }
772
+
773
+ function normalizeLanguage(value) {
774
+ return value === 'en' ? 'en' : 'de';
775
+ }
776
+
777
+ function normalizeTheme(value) {
778
+ return value === 'light' ? 'light' : 'dark';
779
+ }
780
+
781
+ function normalizeViewMode(value) {
782
+ return value === 'monthly' || value === 'yearly' ? value : 'daily';
783
+ }
784
+
785
+ function normalizeDashboardDatePreset(value) {
786
+ return DASHBOARD_DATE_PRESETS.includes(value) ? value : 'all';
787
+ }
788
+
789
+ function normalizeLastLoadSource(value) {
790
+ return value === 'file' || value === 'auto-import' || value === 'cli-auto-load'
791
+ ? value
792
+ : null;
793
+ }
794
+
795
+ function normalizeIsoTimestamp(value) {
796
+ if (typeof value !== 'string') {
797
+ return null;
798
+ }
799
+
800
+ const timestamp = Date.parse(value);
801
+ if (!Number.isFinite(timestamp)) {
802
+ return null;
803
+ }
804
+
805
+ return new Date(timestamp).toISOString();
806
+ }
807
+
808
+ function sanitizeCurrency(value) {
809
+ if (typeof value !== 'number' || !Number.isFinite(value)) return 0;
810
+ return Math.max(0, Number(value.toFixed(2)));
811
+ }
812
+
813
+ function isPlainObject(value) {
814
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
815
+ }
816
+
817
+ function computeUsageTotals(daily) {
818
+ return daily.reduce((totals, day) => ({
819
+ inputTokens: totals.inputTokens + (day.inputTokens || 0),
820
+ outputTokens: totals.outputTokens + (day.outputTokens || 0),
821
+ cacheCreationTokens: totals.cacheCreationTokens + (day.cacheCreationTokens || 0),
822
+ cacheReadTokens: totals.cacheReadTokens + (day.cacheReadTokens || 0),
823
+ thinkingTokens: totals.thinkingTokens + (day.thinkingTokens || 0),
824
+ totalCost: totals.totalCost + (day.totalCost || 0),
825
+ totalTokens: totals.totalTokens + (day.totalTokens || 0),
826
+ requestCount: totals.requestCount + (day.requestCount || 0),
827
+ }), {
828
+ inputTokens: 0,
829
+ outputTokens: 0,
830
+ cacheCreationTokens: 0,
831
+ cacheReadTokens: 0,
832
+ thinkingTokens: 0,
833
+ totalCost: 0,
834
+ totalTokens: 0,
835
+ requestCount: 0,
836
+ });
837
+ }
838
+
839
+ function sortStrings(values) {
840
+ return [...new Set((Array.isArray(values) ? values : []).filter((value) => typeof value === 'string' && value.trim()))]
841
+ .sort((left, right) => left.localeCompare(right));
842
+ }
843
+
844
+ function canonicalizeModelBreakdown(entry) {
845
+ return {
846
+ modelName: typeof entry?.modelName === 'string' ? entry.modelName : '',
847
+ inputTokens: Number(entry?.inputTokens) || 0,
848
+ outputTokens: Number(entry?.outputTokens) || 0,
849
+ cacheCreationTokens: Number(entry?.cacheCreationTokens) || 0,
850
+ cacheReadTokens: Number(entry?.cacheReadTokens) || 0,
851
+ thinkingTokens: Number(entry?.thinkingTokens) || 0,
852
+ cost: Number(entry?.cost) || 0,
853
+ requestCount: Number(entry?.requestCount) || 0,
854
+ };
855
+ }
856
+
857
+ function canonicalizeUsageDay(day) {
858
+ return {
859
+ date: typeof day?.date === 'string' ? day.date : '',
860
+ inputTokens: Number(day?.inputTokens) || 0,
861
+ outputTokens: Number(day?.outputTokens) || 0,
862
+ cacheCreationTokens: Number(day?.cacheCreationTokens) || 0,
863
+ cacheReadTokens: Number(day?.cacheReadTokens) || 0,
864
+ thinkingTokens: Number(day?.thinkingTokens) || 0,
865
+ totalTokens: Number(day?.totalTokens) || 0,
866
+ totalCost: Number(day?.totalCost) || 0,
867
+ requestCount: Number(day?.requestCount) || 0,
868
+ modelsUsed: sortStrings(day?.modelsUsed),
869
+ modelBreakdowns: (Array.isArray(day?.modelBreakdowns) ? day.modelBreakdowns : [])
870
+ .map(canonicalizeModelBreakdown)
871
+ .sort((left, right) => left.modelName.localeCompare(right.modelName)),
872
+ };
873
+ }
874
+
875
+ function areUsageDaysEquivalent(left, right) {
876
+ return JSON.stringify(canonicalizeUsageDay(left)) === JSON.stringify(canonicalizeUsageDay(right));
877
+ }
878
+
879
+ function extractSettingsImportPayload(payload) {
880
+ if (!isPlainObject(payload)) {
881
+ throw new Error('Uploaded JSON is not a settings backup file.');
882
+ }
883
+
884
+ if (payload.kind === SETTINGS_BACKUP_KIND) {
885
+ if (!Object.prototype.hasOwnProperty.call(payload, 'settings')) {
886
+ throw new Error('The settings backup file does not contain any settings.');
887
+ }
888
+ if (!isPlainObject(payload.settings)) {
889
+ throw new Error('The settings backup file has an invalid settings payload.');
890
+ }
891
+ return payload.settings;
892
+ }
893
+
894
+ if (typeof payload.kind === 'string' && payload.kind === USAGE_BACKUP_KIND) {
895
+ throw new Error('This is a data backup file, not a settings file.');
896
+ }
897
+
898
+ throw new Error('Uploaded JSON is not a settings backup file.');
899
+ }
900
+
901
+ function extractUsageImportPayload(payload) {
902
+ if (!isPlainObject(payload)) {
903
+ return payload;
904
+ }
905
+
906
+ if (payload.kind === USAGE_BACKUP_KIND) {
907
+ if (!Object.prototype.hasOwnProperty.call(payload, 'data')) {
908
+ throw new Error('The usage backup file does not contain any usage data.');
909
+ }
910
+ return payload.data;
911
+ }
912
+
913
+ if (typeof payload.kind === 'string' && payload.kind === SETTINGS_BACKUP_KIND) {
914
+ throw new Error('This is a settings backup file, not a data file.');
915
+ }
916
+
917
+ return payload;
918
+ }
919
+
920
+ function mergeUsageData(currentData, importedData) {
921
+ const current = currentData && Array.isArray(currentData.daily) && currentData.daily.length > 0
922
+ ? normalizeIncomingData(currentData)
923
+ : null;
924
+
925
+ if (!current) {
926
+ return {
927
+ data: importedData,
928
+ summary: {
929
+ importedDays: importedData.daily.length,
930
+ addedDays: importedData.daily.length,
931
+ unchangedDays: 0,
932
+ conflictingDays: 0,
933
+ totalDays: importedData.daily.length,
934
+ },
935
+ };
936
+ }
937
+
938
+ const currentByDate = new Map(current.daily.map((day) => [day.date, day]));
939
+ let addedDays = 0;
940
+ let unchangedDays = 0;
941
+ let conflictingDays = 0;
942
+
943
+ for (const importedDay of importedData.daily) {
944
+ const existingDay = currentByDate.get(importedDay.date);
945
+ if (!existingDay) {
946
+ currentByDate.set(importedDay.date, importedDay);
947
+ addedDays += 1;
948
+ continue;
949
+ }
950
+
951
+ if (areUsageDaysEquivalent(existingDay, importedDay)) {
952
+ unchangedDays += 1;
953
+ continue;
954
+ }
955
+
956
+ conflictingDays += 1;
957
+ }
958
+
959
+ const mergedDaily = [...currentByDate.values()].sort((left, right) => left.date.localeCompare(right.date));
960
+
961
+ return {
962
+ data: {
963
+ daily: mergedDaily,
964
+ totals: computeUsageTotals(mergedDaily),
965
+ },
966
+ summary: {
967
+ importedDays: importedData.daily.length,
968
+ addedDays,
969
+ unchangedDays,
970
+ conflictingDays,
971
+ totalDays: mergedDaily.length,
972
+ },
973
+ };
974
+ }
975
+
976
+ function normalizeProviderLimitConfig(value) {
977
+ if (!value || typeof value !== 'object') {
978
+ return {
979
+ hasSubscription: false,
980
+ subscriptionPrice: 0,
981
+ monthlyLimit: 0,
982
+ };
983
+ }
984
+
985
+ return {
986
+ hasSubscription: Boolean(value.hasSubscription),
987
+ subscriptionPrice: sanitizeCurrency(value.subscriptionPrice),
988
+ monthlyLimit: sanitizeCurrency(value.monthlyLimit),
989
+ };
990
+ }
991
+
992
+ function normalizeProviderLimits(value) {
993
+ if (!value || typeof value !== 'object') {
994
+ return {};
995
+ }
996
+
997
+ const next = {};
998
+ for (const [provider, config] of Object.entries(value)) {
999
+ next[provider] = normalizeProviderLimitConfig(config);
1000
+ }
1001
+ return next;
1002
+ }
1003
+
1004
+ function normalizeStringList(value) {
1005
+ if (!Array.isArray(value)) {
1006
+ return [];
1007
+ }
1008
+
1009
+ return [...new Set(value
1010
+ .filter((entry) => typeof entry === 'string')
1011
+ .map((entry) => entry.trim())
1012
+ .filter(Boolean))];
1013
+ }
1014
+
1015
+ function normalizeDefaultFilters(value) {
1016
+ const source = value && typeof value === 'object' ? value : {};
1017
+
1018
+ return {
1019
+ viewMode: normalizeViewMode(source.viewMode),
1020
+ datePreset: normalizeDashboardDatePreset(source.datePreset),
1021
+ providers: normalizeStringList(source.providers),
1022
+ models: normalizeStringList(source.models),
1023
+ };
1024
+ }
1025
+
1026
+ function normalizeSectionVisibility(value) {
1027
+ const source = value && typeof value === 'object' ? value : {};
1028
+ const next = {};
1029
+
1030
+ for (const sectionId of DASHBOARD_SECTION_IDS) {
1031
+ next[sectionId] = typeof source[sectionId] === 'boolean'
1032
+ ? source[sectionId]
1033
+ : true;
1034
+ }
1035
+
1036
+ return next;
1037
+ }
1038
+
1039
+ function normalizeSectionOrder(value) {
1040
+ if (!Array.isArray(value)) {
1041
+ return [...DASHBOARD_SECTION_IDS];
1042
+ }
1043
+
1044
+ const incoming = value.filter((sectionId) => (
1045
+ typeof sectionId === 'string' && DASHBOARD_SECTION_IDS.includes(sectionId)
1046
+ ));
1047
+ const uniqueIncoming = [...new Set(incoming)];
1048
+ const missing = DASHBOARD_SECTION_IDS.filter((sectionId) => !uniqueIncoming.includes(sectionId));
1049
+
1050
+ return [...uniqueIncoming, ...missing];
1051
+ }
1052
+
1053
+ function normalizeSettings(value) {
1054
+ const source = value && typeof value === 'object' ? value : {};
1055
+ return {
1056
+ language: normalizeLanguage(source.language),
1057
+ theme: normalizeTheme(source.theme),
1058
+ providerLimits: normalizeProviderLimits(source.providerLimits),
1059
+ defaultFilters: normalizeDefaultFilters(source.defaultFilters),
1060
+ sectionVisibility: normalizeSectionVisibility(source.sectionVisibility),
1061
+ sectionOrder: normalizeSectionOrder(source.sectionOrder),
1062
+ lastLoadedAt: normalizeIsoTimestamp(source.lastLoadedAt),
1063
+ lastLoadSource: normalizeLastLoadSource(source.lastLoadSource),
1064
+ };
1065
+ }
1066
+
1067
+ function toSettingsResponse(settings) {
1068
+ return {
1069
+ ...normalizeSettings(settings),
1070
+ cliAutoLoadActive: startupAutoLoadCompleted,
1071
+ };
1072
+ }
1073
+
1074
+ function openBrowser(url) {
1075
+ if (!shouldOpenBrowser()) {
1076
+ return;
1077
+ }
1078
+
1079
+ const platform = process.platform;
1080
+ const command = platform === 'darwin'
1081
+ ? 'open'
1082
+ : platform === 'win32'
1083
+ ? 'cmd'
1084
+ : 'xdg-open';
1085
+ const args = platform === 'win32'
1086
+ ? ['/c', 'start', '', url]
1087
+ : [url];
1088
+
1089
+ const child = spawn(command, args, {
1090
+ detached: true,
1091
+ stdio: 'ignore',
1092
+ });
1093
+ child.on('error', () => {});
1094
+ child.unref();
1095
+ }
1096
+
1097
+ function shouldOpenBrowser() {
1098
+ if (CLI_OPTIONS.noOpen || process.env.NO_OPEN_BROWSER === '1' || process.env.CI === '1') {
1099
+ return false;
1100
+ }
1101
+
1102
+ if (FORCE_OPEN_BROWSER) {
1103
+ return true;
1104
+ }
1105
+
1106
+ return Boolean(process.stdout.isTTY);
1107
+ }
1108
+
1109
+ function formatCurrency(value) {
1110
+ return new Intl.NumberFormat('de-CH', {
1111
+ style: 'currency',
1112
+ currency: 'USD',
1113
+ minimumFractionDigits: value >= 100 ? 0 : 2,
1114
+ maximumFractionDigits: value >= 100 ? 0 : 2,
1115
+ }).format(value || 0);
1116
+ }
1117
+
1118
+ function formatInteger(value) {
1119
+ return new Intl.NumberFormat('de-CH').format(value || 0);
1120
+ }
1121
+
1122
+ function describeDataFile() {
1123
+ if (!fs.existsSync(DATA_FILE)) {
1124
+ return 'no local file found';
1125
+ }
1126
+
1127
+ try {
1128
+ const normalized = readData();
1129
+ if (!normalized) {
1130
+ return 'present, but unreadable';
1131
+ }
1132
+
1133
+ const totalCost = formatCurrency(normalized.totals?.totalCost || 0);
1134
+ const totalTokens = formatInteger(normalized.totals?.totalTokens || 0);
1135
+ const dailyCount = formatInteger(normalized.daily?.length || 0);
1136
+ return `${dailyCount} days, ${totalCost}, ${totalTokens} tokens`;
1137
+ } catch {
1138
+ return 'present, but unreadable';
1139
+ }
1140
+ }
1141
+
1142
+ function printStartupSummary(url, port) {
1143
+ const browserMode = shouldOpenBrowser()
1144
+ ? 'enabled'
1145
+ : 'disabled';
1146
+ const autoLoadMode = CLI_OPTIONS.autoLoad
1147
+ ? 'enabled'
1148
+ : 'disabled';
1149
+ const runtimeMode = IS_BACKGROUND_CHILD
1150
+ ? 'background'
1151
+ : 'foreground';
1152
+
1153
+ console.log('');
1154
+ console.log(`${APP_LABEL} v${APP_VERSION} is ready`);
1155
+ console.log(` URL: ${url}`);
1156
+ console.log(` API: ${url}/api/usage`);
1157
+ console.log(` Port: ${port}`);
1158
+ console.log(` Host: ${BIND_HOST}`);
1159
+ console.log(` Mode: ${runtimeMode}`);
1160
+ console.log(` Static Root: ${STATIC_ROOT}`);
1161
+ console.log(` Data File: ${DATA_FILE}`);
1162
+ console.log(` Settings File: ${SETTINGS_FILE}`);
1163
+ if (IS_BACKGROUND_CHILD && process.env.TTDASH_BACKGROUND_LOG_FILE) {
1164
+ console.log(` Log File: ${process.env.TTDASH_BACKGROUND_LOG_FILE}`);
1165
+ }
1166
+ console.log(` Data Status: ${describeDataFile()}`);
1167
+ console.log(` Browser Open: ${browserMode}`);
1168
+ console.log(` Auto-Load: ${autoLoadMode}`);
1169
+ console.log('');
1170
+ console.log('Available ways to load data:');
1171
+ console.log(' 1. Start auto-import from the app');
1172
+ console.log(' 2. Import toktrack JSON via upload');
1173
+ console.log('');
1174
+ console.log('Useful commands:');
1175
+ console.log(` ttdash --port ${port}`);
1176
+ console.log(` ttdash --port ${port} --no-open`);
1177
+ console.log(' ttdash --background');
1178
+ console.log(' ttdash stop');
1179
+ console.log(` NO_OPEN_BROWSER=1 PORT=${port} node server.js`);
1180
+ console.log(` curl ${url}/api/usage`);
1181
+ console.log('');
1182
+ }
1183
+
1184
+ function getCacheControl(filePath) {
1185
+ if (filePath.includes(path.sep + 'assets' + path.sep)) {
1186
+ return 'public, max-age=31536000, immutable';
1187
+ }
1188
+ if (filePath.endsWith('.html')) {
1189
+ return 'no-cache';
1190
+ }
1191
+ return 'public, max-age=86400';
1192
+ }
1193
+
1194
+ function serveFile(res, reqPath) {
1195
+ const ext = path.extname(reqPath).toLowerCase();
1196
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
1197
+
1198
+ fs.readFile(reqPath, (err, data) => {
1199
+ if (err) {
1200
+ if (err.code === 'ENOENT') {
1201
+ fs.readFile(path.join(STATIC_ROOT, 'index.html'), (err2, html) => {
1202
+ if (err2) {
1203
+ res.writeHead(500);
1204
+ res.end('Internal Server Error');
1205
+ return;
1206
+ }
1207
+ res.writeHead(200, {
1208
+ 'Content-Type': 'text/html; charset=utf-8',
1209
+ 'Cache-Control': 'no-cache',
1210
+ ...SECURITY_HEADERS,
1211
+ });
1212
+ res.end(html);
1213
+ });
1214
+ return;
1215
+ }
1216
+ res.writeHead(500);
1217
+ res.end('Internal Server Error');
1218
+ return;
1219
+ }
1220
+ res.writeHead(200, {
1221
+ 'Content-Type': contentType,
1222
+ 'Cache-Control': getCacheControl(reqPath),
1223
+ ...SECURITY_HEADERS,
1224
+ });
1225
+ res.end(data);
1226
+ });
1227
+ }
1228
+
1229
+ // --- API helpers ---
1230
+
1231
+ function readData() {
1232
+ try {
1233
+ return normalizeIncomingData(JSON.parse(fs.readFileSync(DATA_FILE, 'utf-8')));
1234
+ } catch {
1235
+ return null;
1236
+ }
1237
+ }
1238
+
1239
+ function writeData(data) {
1240
+ writeJsonAtomic(DATA_FILE, data);
1241
+ }
1242
+
1243
+ function readSettings() {
1244
+ try {
1245
+ return toSettingsResponse(JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf-8')));
1246
+ } catch {
1247
+ return toSettingsResponse({
1248
+ ...DEFAULT_SETTINGS,
1249
+ providerLimits: {},
1250
+ });
1251
+ }
1252
+ }
1253
+
1254
+ function writeSettings(settings) {
1255
+ writeJsonAtomic(SETTINGS_FILE, normalizeSettings(settings));
1256
+ }
1257
+
1258
+ function updateSettings(patch) {
1259
+ const current = readSettings();
1260
+ const next = {
1261
+ ...current,
1262
+ ...(patch && typeof patch === 'object' ? patch : {}),
1263
+ };
1264
+
1265
+ if (patch && Object.prototype.hasOwnProperty.call(patch, 'providerLimits')) {
1266
+ next.providerLimits = normalizeProviderLimits(patch.providerLimits);
1267
+ } else {
1268
+ next.providerLimits = current.providerLimits;
1269
+ }
1270
+
1271
+ next.language = normalizeLanguage(next.language);
1272
+ next.theme = normalizeTheme(next.theme);
1273
+
1274
+ writeSettings(next);
1275
+ return toSettingsResponse(next);
1276
+ }
1277
+
1278
+ function recordDataLoad(source) {
1279
+ const current = readSettings();
1280
+ const next = {
1281
+ ...current,
1282
+ lastLoadedAt: new Date().toISOString(),
1283
+ lastLoadSource: source,
1284
+ };
1285
+
1286
+ writeSettings(next);
1287
+ return toSettingsResponse(next);
1288
+ }
1289
+
1290
+ function clearDataLoadState() {
1291
+ const current = readSettings();
1292
+ const next = {
1293
+ ...current,
1294
+ lastLoadedAt: null,
1295
+ lastLoadSource: null,
1296
+ };
1297
+
1298
+ writeSettings(next);
1299
+ return toSettingsResponse(next);
1300
+ }
1301
+
1302
+ function readBody(req) {
1303
+ return new Promise((resolve, reject) => {
1304
+ const chunks = [];
1305
+ let totalSize = 0;
1306
+ req.on('data', (c) => {
1307
+ totalSize += c.length;
1308
+ if (totalSize > MAX_BODY_SIZE) {
1309
+ req.destroy();
1310
+ reject(new Error('Payload too large'));
1311
+ return;
1312
+ }
1313
+ chunks.push(c);
1314
+ });
1315
+ req.on('end', () => {
1316
+ try {
1317
+ resolve(JSON.parse(Buffer.concat(chunks).toString()));
1318
+ } catch (e) {
1319
+ reject(e);
1320
+ }
1321
+ });
1322
+ req.on('error', reject);
1323
+ });
1324
+ }
1325
+
1326
+ function json(res, status, data) {
1327
+ res.writeHead(status, {
1328
+ 'Content-Type': 'application/json; charset=utf-8',
1329
+ ...SECURITY_HEADERS,
1330
+ });
1331
+ res.end(JSON.stringify(data));
1332
+ }
1333
+
1334
+ function sendFile(res, status, headers, filePath) {
1335
+ const stream = fs.createReadStream(filePath);
1336
+ res.writeHead(status, {
1337
+ ...headers,
1338
+ ...SECURITY_HEADERS,
1339
+ });
1340
+ stream.on('error', () => {
1341
+ if (!res.headersSent) {
1342
+ res.writeHead(500, SECURITY_HEADERS);
1343
+ res.end('Internal Server Error');
1344
+ return;
1345
+ }
1346
+ res.destroy();
1347
+ });
1348
+ stream.pipe(res);
1349
+ }
1350
+
1351
+ function resolveApiPath(pathname) {
1352
+ if (pathname.startsWith(API_PREFIX + '/')) {
1353
+ return pathname.slice(API_PREFIX.length);
1354
+ }
1355
+ if (pathname === API_PREFIX) {
1356
+ return '/';
1357
+ }
1358
+ if (pathname.startsWith('/api/')) {
1359
+ return pathname.slice(4);
1360
+ }
1361
+ if (pathname === '/api') {
1362
+ return '/';
1363
+ }
1364
+ return null;
1365
+ }
1366
+
1367
+ // --- SSE helpers ---
1368
+
1369
+ function sendSSE(res, event, data) {
1370
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
1371
+ }
1372
+
1373
+ let autoImportRunning = false;
1374
+
1375
+ function shouldUseShell(command) {
1376
+ return IS_WINDOWS && /\.(cmd|bat)$/i.test(command);
1377
+ }
1378
+
1379
+ function spawnCommand(command, args, options = {}) {
1380
+ return spawn(command, args, {
1381
+ ...options,
1382
+ shell: options.shell ?? shouldUseShell(command),
1383
+ windowsHide: options.windowsHide ?? true,
1384
+ });
1385
+ }
1386
+
1387
+ function commandExists(command, args = ['--version']) {
1388
+ return new Promise((resolve) => {
1389
+ const child = spawnCommand(command, args, { stdio: 'ignore' });
1390
+ child.on('error', () => resolve(false));
1391
+ child.on('close', (code) => resolve(code === 0));
1392
+ });
1393
+ }
1394
+
1395
+ async function resolveToktrackRunner() {
1396
+ if (fs.existsSync(TOKTRACK_LOCAL_BIN)) {
1397
+ return {
1398
+ command: TOKTRACK_LOCAL_BIN,
1399
+ prefixArgs: [],
1400
+ env: process.env,
1401
+ method: 'local',
1402
+ label: 'local toktrack',
1403
+ displayCommand: 'node_modules/.bin/toktrack daily --json',
1404
+ };
1405
+ }
1406
+
1407
+ if (await commandExists(IS_WINDOWS ? 'bun.exe' : 'bun')) {
1408
+ return {
1409
+ command: IS_WINDOWS ? 'bun.exe' : 'bunx',
1410
+ prefixArgs: IS_WINDOWS ? ['x', 'toktrack'] : ['toktrack'],
1411
+ env: process.env,
1412
+ method: 'bunx',
1413
+ label: 'bunx',
1414
+ displayCommand: 'bunx toktrack daily --json',
1415
+ };
1416
+ }
1417
+
1418
+ if (await commandExists(IS_WINDOWS ? 'npx.cmd' : 'npx')) {
1419
+ return {
1420
+ command: IS_WINDOWS ? 'npx.cmd' : 'npx',
1421
+ prefixArgs: ['--yes', 'toktrack'],
1422
+ env: {
1423
+ ...process.env,
1424
+ npm_config_cache: NPX_CACHE_DIR,
1425
+ },
1426
+ method: 'npm',
1427
+ label: 'npm exec',
1428
+ displayCommand: 'npx --yes toktrack daily --json',
1429
+ };
1430
+ }
1431
+
1432
+ return null;
1433
+ }
1434
+
1435
+ function runToktrack(runner, args, { streamStderr = false, onStderr, signalOnClose } = {}) {
1436
+ return new Promise((resolve, reject) => {
1437
+ const child = spawnCommand(runner.command, [...runner.prefixArgs, ...args], {
1438
+ stdio: ['ignore', 'pipe', 'pipe'],
1439
+ env: runner.env,
1440
+ });
1441
+
1442
+ let stdout = '';
1443
+ let stderr = '';
1444
+
1445
+ if (signalOnClose) {
1446
+ signalOnClose(() => child.kill('SIGTERM'));
1447
+ }
1448
+
1449
+ child.stdout.on('data', (chunk) => {
1450
+ stdout += chunk.toString();
1451
+ });
1452
+
1453
+ child.stderr.on('data', (chunk) => {
1454
+ const line = chunk.toString();
1455
+ stderr += line;
1456
+ if (streamStderr && onStderr && line.trim()) {
1457
+ onStderr(line.trimEnd());
1458
+ }
1459
+ });
1460
+
1461
+ child.on('error', reject);
1462
+ child.on('close', (code) => {
1463
+ if (code === 0) {
1464
+ resolve(stdout.trimEnd());
1465
+ return;
1466
+ }
1467
+ reject(new Error(stderr.trim() || `Could not start ${runner.label}.`));
1468
+ });
1469
+ });
1470
+ }
1471
+
1472
+ async function performAutoImport({
1473
+ source = 'auto-import',
1474
+ onCheck = () => {},
1475
+ onProgress = () => {},
1476
+ onOutput = () => {},
1477
+ signalOnClose,
1478
+ } = {}) {
1479
+ if (autoImportRunning) {
1480
+ throw new Error('An auto-import is already running. Please wait.');
1481
+ }
1482
+
1483
+ autoImportRunning = true;
1484
+ let progressSeconds = 0;
1485
+ const progressInterval = setInterval(() => {
1486
+ progressSeconds += 5;
1487
+ onOutput(`Processing usage data... (${progressSeconds}s)`);
1488
+ }, 5000);
1489
+
1490
+ try {
1491
+ onCheck({ tool: 'toktrack', status: 'checking' });
1492
+ onProgress({ message: 'Starting local toktrack import...' });
1493
+
1494
+ const runner = await resolveToktrackRunner();
1495
+ if (!runner) {
1496
+ onCheck({ tool: 'toktrack', status: 'not_found' });
1497
+ throw new Error('No local toktrack, Bun, or npm exec installation found.');
1498
+ }
1499
+
1500
+ const versionResult = await runToktrack(runner, ['--version']);
1501
+ onCheck({
1502
+ tool: 'toktrack',
1503
+ status: 'found',
1504
+ method: runner.label,
1505
+ version: String(versionResult).replace(/^toktrack\s+/, ''),
1506
+ });
1507
+ onProgress({ message: `Loading usage data via ${runner.displayCommand}...` });
1508
+
1509
+ const rawJson = await runToktrack(runner, ['daily', '--json'], {
1510
+ streamStderr: true,
1511
+ onStderr: (line) => {
1512
+ onOutput(line);
1513
+ },
1514
+ signalOnClose,
1515
+ });
1516
+
1517
+ const normalized = normalizeIncomingData(JSON.parse(rawJson));
1518
+ writeData(normalized);
1519
+ recordDataLoad(source);
1520
+
1521
+ return {
1522
+ days: normalized.daily.length,
1523
+ totalCost: normalized.totals.totalCost,
1524
+ };
1525
+ } finally {
1526
+ clearInterval(progressInterval);
1527
+ autoImportRunning = false;
1528
+ }
1529
+ }
1530
+
1531
+ async function runStartupAutoLoad({ source = 'cli-auto-load' } = {}) {
1532
+ console.log('Auto-load enabled, starting import...');
1533
+
1534
+ try {
1535
+ const result = await performAutoImport({
1536
+ source,
1537
+ onCheck: (event) => {
1538
+ if (event.status === 'found') {
1539
+ console.log(`toktrack found (${event.method}, v${event.version})`);
1540
+ }
1541
+ },
1542
+ onProgress: (event) => {
1543
+ console.log(event.message);
1544
+ },
1545
+ onOutput: (line) => {
1546
+ console.log(line);
1547
+ },
1548
+ });
1549
+
1550
+ startupAutoLoadCompleted = true;
1551
+ console.log(`Auto-load complete: imported ${result.days} days, ${formatCurrency(result.totalCost)}.`);
1552
+ } catch (error) {
1553
+ console.error(`Auto-load failed: ${error.message}`);
1554
+ console.error('Dashboard will start without newly imported data.');
1555
+ }
1556
+ }
1557
+
1558
+ // --- Server ---
1559
+
1560
+ const server = http.createServer(async (req, res) => {
1561
+ const url = new URL(req.url, 'http://localhost');
1562
+ const pathname = decodeURIComponent(url.pathname);
1563
+
1564
+ // API routing
1565
+ const apiPath = resolveApiPath(pathname);
1566
+
1567
+ if (apiPath === '/usage') {
1568
+ if (req.method === 'GET') {
1569
+ const data = readData();
1570
+ return json(res, 200, data || {
1571
+ daily: [],
1572
+ totals: {
1573
+ inputTokens: 0,
1574
+ outputTokens: 0,
1575
+ cacheCreationTokens: 0,
1576
+ cacheReadTokens: 0,
1577
+ thinkingTokens: 0,
1578
+ totalCost: 0,
1579
+ totalTokens: 0,
1580
+ requestCount: 0,
1581
+ },
1582
+ });
1583
+ }
1584
+ if (req.method === 'DELETE') {
1585
+ try { fs.unlinkSync(DATA_FILE); } catch {}
1586
+ clearDataLoadState();
1587
+ return json(res, 200, { success: true });
1588
+ }
1589
+ return json(res, 405, { message: 'Method Not Allowed' });
1590
+ }
1591
+
1592
+ if (apiPath === '/runtime') {
1593
+ if (req.method !== 'GET') {
1594
+ return json(res, 405, { message: 'Method Not Allowed' });
1595
+ }
1596
+
1597
+ return json(res, 200, {
1598
+ id: RUNTIME_INSTANCE.id,
1599
+ pid: RUNTIME_INSTANCE.pid,
1600
+ startedAt: RUNTIME_INSTANCE.startedAt,
1601
+ mode: RUNTIME_INSTANCE.mode,
1602
+ port: runtimePort,
1603
+ url: runtimeUrl,
1604
+ });
1605
+ }
1606
+
1607
+ if (apiPath === '/settings') {
1608
+ if (req.method === 'GET') {
1609
+ return json(res, 200, readSettings());
1610
+ }
1611
+
1612
+ if (req.method === 'DELETE') {
1613
+ try { fs.unlinkSync(SETTINGS_FILE); } catch {}
1614
+ return json(res, 200, { success: true, settings: readSettings() });
1615
+ }
1616
+
1617
+ if (req.method === 'PATCH') {
1618
+ try {
1619
+ const body = await readBody(req);
1620
+ return json(res, 200, updateSettings(body));
1621
+ } catch (e) {
1622
+ return json(res, 400, { message: e.message || 'Invalid settings request' });
1623
+ }
1624
+ }
1625
+
1626
+ return json(res, 405, { message: 'Method Not Allowed' });
1627
+ }
1628
+
1629
+ if (apiPath === '/settings/import') {
1630
+ if (req.method !== 'POST') {
1631
+ return json(res, 405, { message: 'Method Not Allowed' });
1632
+ }
1633
+
1634
+ try {
1635
+ const body = await readBody(req);
1636
+ const importedSettings = normalizeSettings(extractSettingsImportPayload(body));
1637
+ writeSettings(importedSettings);
1638
+ return json(res, 200, toSettingsResponse(importedSettings));
1639
+ } catch (e) {
1640
+ return json(res, 400, { message: e.message || 'Invalid settings file' });
1641
+ }
1642
+ }
1643
+
1644
+ if (apiPath === '/upload') {
1645
+ if (req.method === 'POST') {
1646
+ try {
1647
+ const body = await readBody(req);
1648
+ const normalized = normalizeIncomingData(body);
1649
+ writeData(normalized);
1650
+ recordDataLoad('file');
1651
+ const days = normalized.daily.length;
1652
+ const totalCost = normalized.totals.totalCost;
1653
+ return json(res, 200, { days, totalCost });
1654
+ } catch (e) {
1655
+ const status = e.message === 'Payload too large' ? 413 : 400;
1656
+ const message = e.message === 'Payload too large'
1657
+ ? 'File too large (max. 10 MB)'
1658
+ : e.message || 'Invalid JSON';
1659
+ return json(res, status, { message });
1660
+ }
1661
+ }
1662
+ return json(res, 405, { message: 'Method Not Allowed' });
1663
+ }
1664
+
1665
+ if (apiPath === '/usage/import') {
1666
+ if (req.method !== 'POST') {
1667
+ return json(res, 405, { message: 'Method Not Allowed' });
1668
+ }
1669
+
1670
+ try {
1671
+ const body = await readBody(req);
1672
+ const importedData = normalizeIncomingData(extractUsageImportPayload(body));
1673
+ const currentData = readData();
1674
+ const result = mergeUsageData(currentData, importedData);
1675
+ writeData(result.data);
1676
+ recordDataLoad('file');
1677
+ return json(res, 200, result.summary);
1678
+ } catch (e) {
1679
+ return json(res, 400, { message: e.message || 'Invalid usage backup file' });
1680
+ }
1681
+ }
1682
+
1683
+ if (apiPath === '/auto-import/stream') {
1684
+ if (req.method !== 'GET') {
1685
+ return json(res, 405, { message: 'Method Not Allowed' });
1686
+ }
1687
+
1688
+ res.writeHead(200, {
1689
+ 'Content-Type': 'text/event-stream',
1690
+ 'Cache-Control': 'no-cache',
1691
+ 'Connection': 'keep-alive',
1692
+ 'X-Accel-Buffering': 'no',
1693
+ ...SECURITY_HEADERS,
1694
+ });
1695
+
1696
+ let aborted = false;
1697
+ req.on('close', () => { aborted = true; });
1698
+
1699
+ try {
1700
+ const result = await performAutoImport({
1701
+ source: 'auto-import',
1702
+ onCheck: (event) => {
1703
+ if (!aborted) {
1704
+ sendSSE(res, 'check', event);
1705
+ }
1706
+ },
1707
+ onProgress: (event) => {
1708
+ if (!aborted) {
1709
+ sendSSE(res, 'progress', event);
1710
+ }
1711
+ },
1712
+ onOutput: (line) => {
1713
+ if (!aborted) {
1714
+ sendSSE(res, 'stderr', { line });
1715
+ }
1716
+ },
1717
+ signalOnClose: (close) => {
1718
+ req.on('close', close);
1719
+ },
1720
+ });
1721
+
1722
+ if (aborted) { return; }
1723
+
1724
+ sendSSE(res, 'success', result);
1725
+ sendSSE(res, 'done', {});
1726
+ res.end();
1727
+ } catch (err) {
1728
+ if (aborted) { return; }
1729
+ sendSSE(res, 'error', { message: `Error: ${err.message}` });
1730
+ sendSSE(res, 'done', {});
1731
+ res.end();
1732
+ }
1733
+ return;
1734
+ }
1735
+
1736
+ if (apiPath === '/report/pdf') {
1737
+ if (req.method !== 'POST') {
1738
+ return json(res, 405, { message: 'Method Not Allowed' });
1739
+ }
1740
+
1741
+ const data = readData();
1742
+ if (!data || !Array.isArray(data.daily) || data.daily.length === 0) {
1743
+ return json(res, 400, { message: 'No data available for the report.' });
1744
+ }
1745
+
1746
+ let body = {};
1747
+ try {
1748
+ body = await readBody(req);
1749
+ } catch (e) {
1750
+ const status = e.message === 'Payload too large' ? 413 : 400;
1751
+ return json(res, status, { message: e.message === 'Payload too large' ? 'Report request too large' : 'Invalid report request' });
1752
+ }
1753
+
1754
+ try {
1755
+ const result = await generatePdfReport(data.daily, body || {});
1756
+ const cleanup = () => {
1757
+ try {
1758
+ fs.rmSync(result.tempDir, { recursive: true, force: true });
1759
+ } catch {}
1760
+ };
1761
+
1762
+ res.on('close', cleanup);
1763
+ res.on('finish', cleanup);
1764
+
1765
+ return sendFile(res, 200, {
1766
+ 'Content-Type': 'application/pdf',
1767
+ 'Content-Disposition': `attachment; filename="${result.filename}"`,
1768
+ }, result.pdfPath);
1769
+ } catch (error) {
1770
+ const message = error && error.message ? error.message : 'PDF generation failed';
1771
+ const status = error && error.code === 'TYPST_MISSING' ? 503 : 500;
1772
+ return json(res, status, { message });
1773
+ }
1774
+ }
1775
+
1776
+ if (apiPath !== null) {
1777
+ return json(res, 404, { message: 'API endpoint not found' });
1778
+ }
1779
+
1780
+ // Static file serving
1781
+ const safePath = pathname === '/' ? '/index.html' : pathname;
1782
+ const filePath = path.resolve(STATIC_ROOT, `.${safePath}`);
1783
+
1784
+ if (!filePath.startsWith(path.resolve(STATIC_ROOT) + path.sep) && filePath !== path.resolve(STATIC_ROOT, 'index.html')) {
1785
+ return json(res, 403, { message: 'Access denied' });
1786
+ }
1787
+
1788
+ serveFile(res, filePath);
1789
+ });
1790
+
1791
+ function tryListen(port) {
1792
+ return new Promise((resolve, reject) => {
1793
+ if (port > MAX_PORT) {
1794
+ reject(new Error(`No free port found (${START_PORT}-${MAX_PORT})`));
1795
+ return;
1796
+ }
1797
+
1798
+ const onError = (err) => {
1799
+ server.off('listening', onListening);
1800
+ if (err.code === 'EADDRINUSE') {
1801
+ if (port >= MAX_PORT) {
1802
+ reject(new Error(`No free port found (${START_PORT}-${MAX_PORT})`));
1803
+ return;
1804
+ }
1805
+ console.log(`Port ${port} is in use, trying ${port + 1}...`);
1806
+ resolve(tryListen(port + 1));
1807
+ } else {
1808
+ reject(err);
1809
+ }
1810
+ };
1811
+
1812
+ const onListening = () => {
1813
+ server.off('error', onError);
1814
+ resolve(port);
1815
+ };
1816
+
1817
+ server.once('error', onError);
1818
+ server.once('listening', onListening);
1819
+ server.listen(port, BIND_HOST);
1820
+ });
1821
+ }
1822
+
1823
+ async function start() {
1824
+ ensureAppDirs();
1825
+ migrateLegacyDataFile();
1826
+
1827
+ const port = await tryListen(START_PORT);
1828
+ const browserHost = BIND_HOST === '0.0.0.0' ? 'localhost' : BIND_HOST;
1829
+ const url = `http://${browserHost}:${port}`;
1830
+ runtimePort = port;
1831
+ runtimeUrl = url;
1832
+
1833
+ if (IS_BACKGROUND_CHILD) {
1834
+ await registerBackgroundInstance(createBackgroundInstance({ port, url }));
1835
+ }
1836
+
1837
+ if (CLI_OPTIONS.autoLoad) {
1838
+ await runStartupAutoLoad({
1839
+ source: 'cli-auto-load',
1840
+ });
1841
+ }
1842
+
1843
+ printStartupSummary(url, port);
1844
+ openBrowser(url);
1845
+ }
1846
+
1847
+ async function runCli() {
1848
+ if (CLI_OPTIONS.command === 'stop') {
1849
+ await runStopCommand();
1850
+ return;
1851
+ }
1852
+
1853
+ if (CLI_OPTIONS.background && !IS_BACKGROUND_CHILD) {
1854
+ await startInBackground();
1855
+ return;
1856
+ }
1857
+
1858
+ await start();
1859
+ }
1860
+
1861
+ runCli().catch((error) => {
1862
+ Promise.resolve()
1863
+ .then(async () => {
1864
+ if (IS_BACKGROUND_CHILD) {
1865
+ await unregisterBackgroundInstance(process.pid);
1866
+ }
1867
+ })
1868
+ .finally(() => {
1869
+ console.error(error);
1870
+ process.exit(1);
1871
+ });
1872
+ });
1873
+
1874
+ // Graceful shutdown on Ctrl+C / kill
1875
+ function shutdown(signal) {
1876
+ console.log(`\n${signal} received, shutting down server...`);
1877
+ server.close(async () => {
1878
+ if (IS_BACKGROUND_CHILD) {
1879
+ await unregisterBackgroundInstance(process.pid);
1880
+ }
1881
+ console.log('Server stopped.');
1882
+ process.exit(0);
1883
+ });
1884
+ // Force exit after 3s if connections don't close
1885
+ setTimeout(async () => {
1886
+ if (IS_BACKGROUND_CHILD) {
1887
+ await unregisterBackgroundInstance(process.pid);
1888
+ }
1889
+ console.log('Forcing shutdown.');
1890
+ process.exit(0);
1891
+ }, 3000);
1892
+ }
1893
+
1894
+ process.on('SIGINT', () => shutdown('SIGINT'));
1895
+ process.on('SIGTERM', () => shutdown('SIGTERM'));