@roastcodes/ttdash 6.1.4 → 6.1.6

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 CHANGED
@@ -26,13 +26,19 @@ const BIND_HOST = process.env.HOST || '127.0.0.1';
26
26
  const API_PREFIX = '/port/5000/api';
27
27
  const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB
28
28
  const IS_WINDOWS = process.platform === 'win32';
29
- const TOKTRACK_LOCAL_BIN = path.join(ROOT, 'node_modules', '.bin', IS_WINDOWS ? 'toktrack.cmd' : 'toktrack');
29
+ const TOKTRACK_LOCAL_BIN = path.join(
30
+ ROOT,
31
+ 'node_modules',
32
+ '.bin',
33
+ IS_WINDOWS ? 'toktrack.cmd' : 'toktrack',
34
+ );
30
35
  const SECURITY_HEADERS = {
31
36
  'X-Content-Type-Options': 'nosniff',
32
37
  'Referrer-Policy': 'no-referrer',
33
38
  'X-Frame-Options': 'DENY',
34
39
  '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'",
40
+ 'Content-Security-Policy':
41
+ "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
42
  };
37
43
  const APP_LABEL = 'TTDash';
38
44
  const SETTINGS_BACKUP_KIND = 'ttdash-settings-backup';
@@ -66,7 +72,9 @@ const DEFAULT_SETTINGS = {
66
72
  providers: [],
67
73
  models: [],
68
74
  },
69
- sectionVisibility: Object.fromEntries(DASHBOARD_SECTION_IDS.map((sectionId) => [sectionId, true])),
75
+ sectionVisibility: Object.fromEntries(
76
+ DASHBOARD_SECTION_IDS.map((sectionId) => [sectionId, true]),
77
+ ),
70
78
  sectionOrder: DASHBOARD_SECTION_IDS,
71
79
  lastLoadedAt: null,
72
80
  lastLoadSource: null,
@@ -223,14 +231,27 @@ function resolveAppPaths() {
223
231
  };
224
232
  } else if (IS_WINDOWS) {
225
233
  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'),
234
+ dataDir: path.join(
235
+ process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'),
236
+ APP_DIR_NAME,
237
+ ),
238
+ configDir: path.join(
239
+ process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'),
240
+ APP_DIR_NAME,
241
+ ),
242
+ cacheDir: path.join(
243
+ process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'),
244
+ APP_DIR_NAME,
245
+ 'Cache',
246
+ ),
229
247
  };
230
248
  } else {
231
249
  const appName = APP_DIR_NAME_LINUX;
232
250
  platformPaths = {
233
- dataDir: path.join(process.env.XDG_DATA_HOME || path.join(homeDir, '.local', 'share'), appName),
251
+ dataDir: path.join(
252
+ process.env.XDG_DATA_HOME || path.join(homeDir, '.local', 'share'),
253
+ appName,
254
+ ),
234
255
  configDir: path.join(process.env.XDG_CONFIG_HOME || path.join(homeDir, '.config'), appName),
235
256
  cacheDir: path.join(process.env.XDG_CACHE_HOME || path.join(homeDir, '.cache'), appName),
236
257
  };
@@ -355,9 +376,9 @@ async function isBackgroundInstanceOwned(instance) {
355
376
  return false;
356
377
  }
357
378
 
358
- return runtime.id === instance.id
359
- && runtime.pid === instance.pid
360
- && runtime.port === instance.port;
379
+ return (
380
+ runtime.id === instance.id && runtime.pid === instance.pid && runtime.port === instance.port
381
+ );
361
382
  }
362
383
 
363
384
  function normalizeBackgroundInstance(value) {
@@ -368,17 +389,19 @@ function normalizeBackgroundInstance(value) {
368
389
  const pid = Number.parseInt(value.pid, 10);
369
390
  const port = Number.parseInt(value.port, 10);
370
391
  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) {
392
+ const id = typeof value.id === 'string' && value.id.trim() ? value.id.trim() : null;
393
+ const url = typeof value.url === 'string' && value.url.trim() ? value.url.trim() : null;
394
+ const host = typeof value.host === 'string' && value.host.trim() ? value.host.trim() : BIND_HOST;
395
+
396
+ if (
397
+ !id ||
398
+ !url ||
399
+ !startedAt ||
400
+ !Number.isInteger(pid) ||
401
+ pid <= 0 ||
402
+ !Number.isInteger(port) ||
403
+ port <= 0
404
+ ) {
382
405
  return null;
383
406
  }
384
407
 
@@ -389,9 +412,8 @@ function normalizeBackgroundInstance(value) {
389
412
  url,
390
413
  host,
391
414
  startedAt,
392
- logFile: typeof value.logFile === 'string' && value.logFile.trim()
393
- ? value.logFile.trim()
394
- : null,
415
+ logFile:
416
+ typeof value.logFile === 'string' && value.logFile.trim() ? value.logFile.trim() : null,
395
417
  };
396
418
  }
397
419
 
@@ -401,7 +423,9 @@ function readBackgroundInstancesRaw() {
401
423
  if (Array.isArray(parsed)) {
402
424
  return parsed;
403
425
  }
404
- } catch {}
426
+ } catch {
427
+ // Ignore missing or invalid background registry state.
428
+ }
405
429
 
406
430
  return [];
407
431
  }
@@ -411,9 +435,7 @@ function writeBackgroundInstances(instances) {
411
435
  }
412
436
 
413
437
  async function readBackgroundInstancesSnapshot() {
414
- const normalized = readBackgroundInstancesRaw()
415
- .map(normalizeBackgroundInstance)
416
- .filter(Boolean);
438
+ const normalized = readBackgroundInstancesRaw().map(normalizeBackgroundInstance).filter(Boolean);
417
439
  const alive = [];
418
440
 
419
441
  for (const instance of normalized) {
@@ -443,7 +465,10 @@ async function getBackgroundInstances() {
443
465
  return (await readBackgroundInstancesSnapshot()).alive;
444
466
  }
445
467
 
446
- async function withBackgroundInstancesLock(callback, timeoutMs = BACKGROUND_INSTANCES_LOCK_TIMEOUT_MS) {
468
+ async function withBackgroundInstancesLock(
469
+ callback,
470
+ timeoutMs = BACKGROUND_INSTANCES_LOCK_TIMEOUT_MS,
471
+ ) {
447
472
  const startedAt = Date.now();
448
473
 
449
474
  while (true) {
@@ -458,18 +483,22 @@ async function withBackgroundInstancesLock(callback, timeoutMs = BACKGROUND_INST
458
483
  let lockIsStale = false;
459
484
  try {
460
485
  const stats = fs.statSync(BACKGROUND_INSTANCES_LOCK_DIR);
461
- lockIsStale = (Date.now() - stats.mtimeMs) > BACKGROUND_INSTANCES_LOCK_STALE_MS;
462
- } catch {}
486
+ lockIsStale = Date.now() - stats.mtimeMs > BACKGROUND_INSTANCES_LOCK_STALE_MS;
487
+ } catch {
488
+ // Ignore stat races while the lock directory is changing.
489
+ }
463
490
 
464
491
  if (lockIsStale) {
465
492
  try {
466
493
  fs.rmSync(BACKGROUND_INSTANCES_LOCK_DIR, { recursive: true, force: true });
467
494
  continue;
468
- } catch {}
495
+ } catch {
496
+ // Ignore lock cleanup races and retry until timeout.
497
+ }
469
498
  }
470
499
 
471
500
  if (Date.now() - startedAt >= timeoutMs) {
472
- throw new Error('Could not acquire background registry lock.');
501
+ throw new Error('Could not acquire background registry lock.', { cause: error });
473
502
  }
474
503
 
475
504
  await sleep(50);
@@ -481,7 +510,9 @@ async function withBackgroundInstancesLock(callback, timeoutMs = BACKGROUND_INST
481
510
  } finally {
482
511
  try {
483
512
  fs.rmSync(BACKGROUND_INSTANCES_LOCK_DIR, { recursive: true, force: true });
484
- } catch {}
513
+ } catch {
514
+ // Ignore cleanup races after the lock holder exits.
515
+ }
485
516
  }
486
517
  }
487
518
 
@@ -604,7 +635,11 @@ async function promptForBackgroundInstance(instances) {
604
635
 
605
636
  try {
606
637
  while (true) {
607
- const answer = (await rl.question(`Which instance should be stopped? [1-${instances.length}, Enter=cancel] `)).trim();
638
+ const answer = (
639
+ await rl.question(
640
+ `Which instance should be stopped? [1-${instances.length}, Enter=cancel] `,
641
+ )
642
+ ).trim();
608
643
 
609
644
  if (!answer) {
610
645
  return null;
@@ -683,22 +718,30 @@ async function runStopCommand() {
683
718
 
684
719
  const result = await stopBackgroundInstance(selectedInstance);
685
720
  if (result.status === 'stopped') {
686
- console.log(`Stopped TTDash background server: ${selectedInstance.url} (PID ${selectedInstance.pid})`);
721
+ console.log(
722
+ `Stopped TTDash background server: ${selectedInstance.url} (PID ${selectedInstance.pid})`,
723
+ );
687
724
  return;
688
725
  }
689
726
 
690
727
  if (result.status === 'already-stopped') {
691
- console.log(`Instance was already stopped and was removed from the registry: ${selectedInstance.url} (PID ${selectedInstance.pid})`);
728
+ console.log(
729
+ `Instance was already stopped and was removed from the registry: ${selectedInstance.url} (PID ${selectedInstance.pid})`,
730
+ );
692
731
  return;
693
732
  }
694
733
 
695
734
  if (result.status === 'forbidden') {
696
- console.error(`Could not stop TTDash background server (permission denied): ${selectedInstance.url} (PID ${selectedInstance.pid})`);
735
+ console.error(
736
+ `Could not stop TTDash background server (permission denied): ${selectedInstance.url} (PID ${selectedInstance.pid})`,
737
+ );
697
738
  process.exitCode = 1;
698
739
  return;
699
740
  }
700
741
 
701
- console.error(`TTDash background server did not respond to SIGTERM: ${selectedInstance.url} (PID ${selectedInstance.pid})`);
742
+ console.error(
743
+ `TTDash background server did not respond to SIGTERM: ${selectedInstance.url} (PID ${selectedInstance.pid})`,
744
+ );
702
745
  if (selectedInstance.logFile) {
703
746
  console.error(`Log file: ${selectedInstance.logFile}`);
704
747
  }
@@ -736,9 +779,7 @@ async function startInBackground() {
736
779
 
737
780
  const instance = await waitForBackgroundInstance(child.pid);
738
781
  if (!instance) {
739
- const logOutput = fs.existsSync(logFile)
740
- ? fs.readFileSync(logFile, 'utf-8').trim()
741
- : '';
782
+ const logOutput = fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf-8').trim() : '';
742
783
  throw new Error(logOutput || `Could not start TTDash as a background process. Log: ${logFile}`);
743
784
  }
744
785
 
@@ -765,7 +806,9 @@ function migrateLegacyDataFile() {
765
806
  fs.copyFileSync(LEGACY_DATA_FILE, DATA_FILE);
766
807
  try {
767
808
  fs.unlinkSync(LEGACY_DATA_FILE);
768
- } catch {}
809
+ } catch {
810
+ // Ignore best-effort cleanup failures after copying legacy data.
811
+ }
769
812
  console.log(`Copying existing data to ${DATA_FILE}`);
770
813
  }
771
814
  }
@@ -787,9 +830,7 @@ function normalizeDashboardDatePreset(value) {
787
830
  }
788
831
 
789
832
  function normalizeLastLoadSource(value) {
790
- return value === 'file' || value === 'auto-import' || value === 'cli-auto-load'
791
- ? value
792
- : null;
833
+ return value === 'file' || value === 'auto-import' || value === 'cli-auto-load' ? value : null;
793
834
  }
794
835
 
795
836
  function normalizeIsoTimestamp(value) {
@@ -815,30 +856,38 @@ function isPlainObject(value) {
815
856
  }
816
857
 
817
858
  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
- });
859
+ return daily.reduce(
860
+ (totals, day) => ({
861
+ inputTokens: totals.inputTokens + (day.inputTokens || 0),
862
+ outputTokens: totals.outputTokens + (day.outputTokens || 0),
863
+ cacheCreationTokens: totals.cacheCreationTokens + (day.cacheCreationTokens || 0),
864
+ cacheReadTokens: totals.cacheReadTokens + (day.cacheReadTokens || 0),
865
+ thinkingTokens: totals.thinkingTokens + (day.thinkingTokens || 0),
866
+ totalCost: totals.totalCost + (day.totalCost || 0),
867
+ totalTokens: totals.totalTokens + (day.totalTokens || 0),
868
+ requestCount: totals.requestCount + (day.requestCount || 0),
869
+ }),
870
+ {
871
+ inputTokens: 0,
872
+ outputTokens: 0,
873
+ cacheCreationTokens: 0,
874
+ cacheReadTokens: 0,
875
+ thinkingTokens: 0,
876
+ totalCost: 0,
877
+ totalTokens: 0,
878
+ requestCount: 0,
879
+ },
880
+ );
837
881
  }
838
882
 
839
883
  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));
884
+ return [
885
+ ...new Set(
886
+ (Array.isArray(values) ? values : []).filter(
887
+ (value) => typeof value === 'string' && value.trim(),
888
+ ),
889
+ ),
890
+ ].sort((left, right) => left.localeCompare(right));
842
891
  }
843
892
 
844
893
  function canonicalizeModelBreakdown(entry) {
@@ -918,9 +967,10 @@ function extractUsageImportPayload(payload) {
918
967
  }
919
968
 
920
969
  function mergeUsageData(currentData, importedData) {
921
- const current = currentData && Array.isArray(currentData.daily) && currentData.daily.length > 0
922
- ? normalizeIncomingData(currentData)
923
- : null;
970
+ const current =
971
+ currentData && Array.isArray(currentData.daily) && currentData.daily.length > 0
972
+ ? normalizeIncomingData(currentData)
973
+ : null;
924
974
 
925
975
  if (!current) {
926
976
  return {
@@ -956,7 +1006,9 @@ function mergeUsageData(currentData, importedData) {
956
1006
  conflictingDays += 1;
957
1007
  }
958
1008
 
959
- const mergedDaily = [...currentByDate.values()].sort((left, right) => left.date.localeCompare(right.date));
1009
+ const mergedDaily = [...currentByDate.values()].sort((left, right) =>
1010
+ left.date.localeCompare(right.date),
1011
+ );
960
1012
 
961
1013
  return {
962
1014
  data: {
@@ -1006,10 +1058,14 @@ function normalizeStringList(value) {
1006
1058
  return [];
1007
1059
  }
1008
1060
 
1009
- return [...new Set(value
1010
- .filter((entry) => typeof entry === 'string')
1011
- .map((entry) => entry.trim())
1012
- .filter(Boolean))];
1061
+ return [
1062
+ ...new Set(
1063
+ value
1064
+ .filter((entry) => typeof entry === 'string')
1065
+ .map((entry) => entry.trim())
1066
+ .filter(Boolean),
1067
+ ),
1068
+ ];
1013
1069
  }
1014
1070
 
1015
1071
  function normalizeDefaultFilters(value) {
@@ -1028,9 +1084,7 @@ function normalizeSectionVisibility(value) {
1028
1084
  const next = {};
1029
1085
 
1030
1086
  for (const sectionId of DASHBOARD_SECTION_IDS) {
1031
- next[sectionId] = typeof source[sectionId] === 'boolean'
1032
- ? source[sectionId]
1033
- : true;
1087
+ next[sectionId] = typeof source[sectionId] === 'boolean' ? source[sectionId] : true;
1034
1088
  }
1035
1089
 
1036
1090
  return next;
@@ -1041,9 +1095,9 @@ function normalizeSectionOrder(value) {
1041
1095
  return [...DASHBOARD_SECTION_IDS];
1042
1096
  }
1043
1097
 
1044
- const incoming = value.filter((sectionId) => (
1045
- typeof sectionId === 'string' && DASHBOARD_SECTION_IDS.includes(sectionId)
1046
- ));
1098
+ const incoming = value.filter(
1099
+ (sectionId) => typeof sectionId === 'string' && DASHBOARD_SECTION_IDS.includes(sectionId),
1100
+ );
1047
1101
  const uniqueIncoming = [...new Set(incoming)];
1048
1102
  const missing = DASHBOARD_SECTION_IDS.filter((sectionId) => !uniqueIncoming.includes(sectionId));
1049
1103
 
@@ -1077,14 +1131,8 @@ function openBrowser(url) {
1077
1131
  }
1078
1132
 
1079
1133
  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];
1134
+ const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
1135
+ const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
1088
1136
 
1089
1137
  const child = spawn(command, args, {
1090
1138
  detached: true,
@@ -1140,15 +1188,9 @@ function describeDataFile() {
1140
1188
  }
1141
1189
 
1142
1190
  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';
1191
+ const browserMode = shouldOpenBrowser() ? 'enabled' : 'disabled';
1192
+ const autoLoadMode = CLI_OPTIONS.autoLoad ? 'enabled' : 'disabled';
1193
+ const runtimeMode = IS_BACKGROUND_CHILD ? 'background' : 'foreground';
1152
1194
 
1153
1195
  console.log('');
1154
1196
  console.log(`${APP_LABEL} v${APP_VERSION} is ready`);
@@ -1331,21 +1373,13 @@ function json(res, status, data) {
1331
1373
  res.end(JSON.stringify(data));
1332
1374
  }
1333
1375
 
1334
- function sendFile(res, status, headers, filePath) {
1335
- const stream = fs.createReadStream(filePath);
1376
+ function sendBuffer(res, status, headers, buffer) {
1336
1377
  res.writeHead(status, {
1378
+ 'Content-Length': buffer.length,
1337
1379
  ...headers,
1338
1380
  ...SECURITY_HEADERS,
1339
1381
  });
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);
1382
+ res.end(buffer);
1349
1383
  }
1350
1384
 
1351
1385
  function resolveApiPath(pathname) {
@@ -1376,6 +1410,22 @@ function shouldUseShell(command) {
1376
1410
  return IS_WINDOWS && /\.(cmd|bat)$/i.test(command);
1377
1411
  }
1378
1412
 
1413
+ function getExecutableName(baseName, isWindows = IS_WINDOWS) {
1414
+ if (!isWindows) {
1415
+ return baseName;
1416
+ }
1417
+
1418
+ switch (baseName) {
1419
+ case 'bun':
1420
+ case 'bunx':
1421
+ return 'bun.exe';
1422
+ case 'npx':
1423
+ return 'npx.cmd';
1424
+ default:
1425
+ return baseName;
1426
+ }
1427
+ }
1428
+
1379
1429
  function spawnCommand(command, args, options = {}) {
1380
1430
  return spawn(command, args, {
1381
1431
  ...options,
@@ -1404,9 +1454,9 @@ async function resolveToktrackRunner() {
1404
1454
  };
1405
1455
  }
1406
1456
 
1407
- if (await commandExists(IS_WINDOWS ? 'bun.exe' : 'bun')) {
1457
+ if (await commandExists(getExecutableName('bun'))) {
1408
1458
  return {
1409
- command: IS_WINDOWS ? 'bun.exe' : 'bunx',
1459
+ command: getExecutableName('bunx'),
1410
1460
  prefixArgs: IS_WINDOWS ? ['x', 'toktrack'] : ['toktrack'],
1411
1461
  env: process.env,
1412
1462
  method: 'bunx',
@@ -1415,9 +1465,9 @@ async function resolveToktrackRunner() {
1415
1465
  };
1416
1466
  }
1417
1467
 
1418
- if (await commandExists(IS_WINDOWS ? 'npx.cmd' : 'npx')) {
1468
+ if (await commandExists(getExecutableName('npx'))) {
1419
1469
  return {
1420
- command: IS_WINDOWS ? 'npx.cmd' : 'npx',
1470
+ command: getExecutableName('npx'),
1421
1471
  prefixArgs: ['--yes', 'toktrack'],
1422
1472
  env: {
1423
1473
  ...process.env,
@@ -1548,7 +1598,9 @@ async function runStartupAutoLoad({ source = 'cli-auto-load' } = {}) {
1548
1598
  });
1549
1599
 
1550
1600
  startupAutoLoadCompleted = true;
1551
- console.log(`Auto-load complete: imported ${result.days} days, ${formatCurrency(result.totalCost)}.`);
1601
+ console.log(
1602
+ `Auto-load complete: imported ${result.days} days, ${formatCurrency(result.totalCost)}.`,
1603
+ );
1552
1604
  } catch (error) {
1553
1605
  console.error(`Auto-load failed: ${error.message}`);
1554
1606
  console.error('Dashboard will start without newly imported data.');
@@ -1567,22 +1619,30 @@ const server = http.createServer(async (req, res) => {
1567
1619
  if (apiPath === '/usage') {
1568
1620
  if (req.method === 'GET') {
1569
1621
  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,
1622
+ return json(
1623
+ res,
1624
+ 200,
1625
+ data || {
1626
+ daily: [],
1627
+ totals: {
1628
+ inputTokens: 0,
1629
+ outputTokens: 0,
1630
+ cacheCreationTokens: 0,
1631
+ cacheReadTokens: 0,
1632
+ thinkingTokens: 0,
1633
+ totalCost: 0,
1634
+ totalTokens: 0,
1635
+ requestCount: 0,
1636
+ },
1581
1637
  },
1582
- });
1638
+ );
1583
1639
  }
1584
1640
  if (req.method === 'DELETE') {
1585
- try { fs.unlinkSync(DATA_FILE); } catch {}
1641
+ try {
1642
+ fs.unlinkSync(DATA_FILE);
1643
+ } catch {
1644
+ // Ignore missing data files during reset.
1645
+ }
1586
1646
  clearDataLoadState();
1587
1647
  return json(res, 200, { success: true });
1588
1648
  }
@@ -1610,7 +1670,11 @@ const server = http.createServer(async (req, res) => {
1610
1670
  }
1611
1671
 
1612
1672
  if (req.method === 'DELETE') {
1613
- try { fs.unlinkSync(SETTINGS_FILE); } catch {}
1673
+ try {
1674
+ fs.unlinkSync(SETTINGS_FILE);
1675
+ } catch {
1676
+ // Ignore missing settings files during reset.
1677
+ }
1614
1678
  return json(res, 200, { success: true, settings: readSettings() });
1615
1679
  }
1616
1680
 
@@ -1653,9 +1717,10 @@ const server = http.createServer(async (req, res) => {
1653
1717
  return json(res, 200, { days, totalCost });
1654
1718
  } catch (e) {
1655
1719
  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';
1720
+ const message =
1721
+ e.message === 'Payload too large'
1722
+ ? 'File too large (max. 10 MB)'
1723
+ : e.message || 'Invalid JSON';
1659
1724
  return json(res, status, { message });
1660
1725
  }
1661
1726
  }
@@ -1688,13 +1753,15 @@ const server = http.createServer(async (req, res) => {
1688
1753
  res.writeHead(200, {
1689
1754
  'Content-Type': 'text/event-stream',
1690
1755
  'Cache-Control': 'no-cache',
1691
- 'Connection': 'keep-alive',
1756
+ Connection: 'keep-alive',
1692
1757
  'X-Accel-Buffering': 'no',
1693
1758
  ...SECURITY_HEADERS,
1694
1759
  });
1695
1760
 
1696
1761
  let aborted = false;
1697
- req.on('close', () => { aborted = true; });
1762
+ req.on('close', () => {
1763
+ aborted = true;
1764
+ });
1698
1765
 
1699
1766
  try {
1700
1767
  const result = await performAutoImport({
@@ -1719,13 +1786,17 @@ const server = http.createServer(async (req, res) => {
1719
1786
  },
1720
1787
  });
1721
1788
 
1722
- if (aborted) { return; }
1789
+ if (aborted) {
1790
+ return;
1791
+ }
1723
1792
 
1724
1793
  sendSSE(res, 'success', result);
1725
1794
  sendSSE(res, 'done', {});
1726
1795
  res.end();
1727
1796
  } catch (err) {
1728
- if (aborted) { return; }
1797
+ if (aborted) {
1798
+ return;
1799
+ }
1729
1800
  sendSSE(res, 'error', { message: `Error: ${err.message}` });
1730
1801
  sendSSE(res, 'done', {});
1731
1802
  res.end();
@@ -1743,29 +1814,28 @@ const server = http.createServer(async (req, res) => {
1743
1814
  return json(res, 400, { message: 'No data available for the report.' });
1744
1815
  }
1745
1816
 
1746
- let body = {};
1817
+ let body;
1747
1818
  try {
1748
1819
  body = await readBody(req);
1749
1820
  } catch (e) {
1750
1821
  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' });
1822
+ return json(res, status, {
1823
+ message:
1824
+ e.message === 'Payload too large' ? 'Report request too large' : 'Invalid report request',
1825
+ });
1752
1826
  }
1753
1827
 
1754
1828
  try {
1755
1829
  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);
1830
+ return sendBuffer(
1831
+ res,
1832
+ 200,
1833
+ {
1834
+ 'Content-Type': 'application/pdf',
1835
+ 'Content-Disposition': `attachment; filename="${result.filename}"`,
1836
+ },
1837
+ result.buffer,
1838
+ );
1769
1839
  } catch (error) {
1770
1840
  const message = error && error.message ? error.message : 'PDF generation failed';
1771
1841
  const status = error && error.code === 'TYPST_MISSING' ? 503 : 500;
@@ -1781,43 +1851,68 @@ const server = http.createServer(async (req, res) => {
1781
1851
  const safePath = pathname === '/' ? '/index.html' : pathname;
1782
1852
  const filePath = path.resolve(STATIC_ROOT, `.${safePath}`);
1783
1853
 
1784
- if (!filePath.startsWith(path.resolve(STATIC_ROOT) + path.sep) && filePath !== path.resolve(STATIC_ROOT, 'index.html')) {
1854
+ if (
1855
+ !filePath.startsWith(path.resolve(STATIC_ROOT) + path.sep) &&
1856
+ filePath !== path.resolve(STATIC_ROOT, 'index.html')
1857
+ ) {
1785
1858
  return json(res, 403, { message: 'Access denied' });
1786
1859
  }
1787
1860
 
1788
1861
  serveFile(res, filePath);
1789
1862
  });
1790
1863
 
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
- }
1864
+ function createNoFreePortError(rangeStartPort, maxPort) {
1865
+ return new Error(`No free port found (${rangeStartPort}-${maxPort})`);
1866
+ }
1867
+
1868
+ async function listenOnAvailablePort(
1869
+ serverInstance,
1870
+ port,
1871
+ maxPort,
1872
+ bindHost,
1873
+ log = console.log,
1874
+ rangeStartPort = port,
1875
+ ) {
1876
+ if (port > maxPort) {
1877
+ throw createNoFreePortError(rangeStartPort, maxPort);
1878
+ }
1879
+
1880
+ for (let currentPort = port; currentPort <= maxPort; currentPort += 1) {
1881
+ try {
1882
+ await new Promise((resolve, reject) => {
1883
+ const onError = (err) => {
1884
+ serverInstance.off('listening', onListening);
1885
+ reject(err);
1886
+ };
1887
+
1888
+ const onListening = () => {
1889
+ serverInstance.off('error', onError);
1890
+ resolve();
1891
+ };
1892
+
1893
+ serverInstance.once('error', onError);
1894
+ serverInstance.once('listening', onListening);
1895
+ serverInstance.listen(currentPort, bindHost);
1896
+ });
1797
1897
 
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;
1898
+ return currentPort;
1899
+ } catch (err) {
1900
+ if (err && err.code === 'EADDRINUSE') {
1901
+ if (currentPort >= maxPort) {
1902
+ throw createNoFreePortError(rangeStartPort, maxPort);
1804
1903
  }
1805
- console.log(`Port ${port} is in use, trying ${port + 1}...`);
1806
- resolve(tryListen(port + 1));
1807
- } else {
1808
- reject(err);
1904
+ log(`Port ${currentPort} is in use, trying ${currentPort + 1}...`);
1905
+ continue;
1809
1906
  }
1810
- };
1907
+ throw err;
1908
+ }
1909
+ }
1811
1910
 
1812
- const onListening = () => {
1813
- server.off('error', onError);
1814
- resolve(port);
1815
- };
1911
+ throw createNoFreePortError(rangeStartPort, maxPort);
1912
+ }
1816
1913
 
1817
- server.once('error', onError);
1818
- server.once('listening', onListening);
1819
- server.listen(port, BIND_HOST);
1820
- });
1914
+ function tryListen(port) {
1915
+ return listenOnAvailablePort(server, port, MAX_PORT, BIND_HOST, console.log, START_PORT);
1821
1916
  }
1822
1917
 
1823
1918
  async function start() {
@@ -1858,18 +1953,40 @@ async function runCli() {
1858
1953
  await start();
1859
1954
  }
1860
1955
 
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
- });
1956
+ function registerShutdownHandlers() {
1957
+ process.on('SIGINT', () => shutdown('SIGINT'));
1958
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
1959
+ }
1960
+
1961
+ function bootstrapCli() {
1962
+ runCli().catch((error) => {
1963
+ Promise.resolve()
1964
+ .then(async () => {
1965
+ if (IS_BACKGROUND_CHILD) {
1966
+ await unregisterBackgroundInstance(process.pid);
1967
+ }
1968
+ })
1969
+ .finally(() => {
1970
+ console.error(error);
1971
+ process.exit(1);
1972
+ });
1973
+ });
1974
+
1975
+ registerShutdownHandlers();
1976
+ }
1977
+
1978
+ module.exports = {
1979
+ bootstrapCli,
1980
+ runCli,
1981
+ __test__: {
1982
+ getExecutableName,
1983
+ listenOnAvailablePort,
1984
+ },
1985
+ };
1986
+
1987
+ if (require.main === module) {
1988
+ bootstrapCli();
1989
+ }
1873
1990
 
1874
1991
  // Graceful shutdown on Ctrl+C / kill
1875
1992
  function shutdown(signal) {
@@ -1890,6 +2007,3 @@ function shutdown(signal) {
1890
2007
  process.exit(0);
1891
2008
  }, 3000);
1892
2009
  }
1893
-
1894
- process.on('SIGINT', () => shutdown('SIGINT'));
1895
- process.on('SIGTERM', () => shutdown('SIGTERM'));