@roastcodes/ttdash 6.1.5 → 6.1.7

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,23 +1373,6 @@ 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);
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
1376
  function sendBuffer(res, status, headers, buffer) {
1352
1377
  res.writeHead(status, {
1353
1378
  'Content-Length': buffer.length,
@@ -1385,6 +1410,22 @@ function shouldUseShell(command) {
1385
1410
  return IS_WINDOWS && /\.(cmd|bat)$/i.test(command);
1386
1411
  }
1387
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
+
1388
1429
  function spawnCommand(command, args, options = {}) {
1389
1430
  return spawn(command, args, {
1390
1431
  ...options,
@@ -1413,9 +1454,9 @@ async function resolveToktrackRunner() {
1413
1454
  };
1414
1455
  }
1415
1456
 
1416
- if (await commandExists(IS_WINDOWS ? 'bun.exe' : 'bun')) {
1457
+ if (await commandExists(getExecutableName('bun'))) {
1417
1458
  return {
1418
- command: IS_WINDOWS ? 'bun.exe' : 'bunx',
1459
+ command: getExecutableName('bunx'),
1419
1460
  prefixArgs: IS_WINDOWS ? ['x', 'toktrack'] : ['toktrack'],
1420
1461
  env: process.env,
1421
1462
  method: 'bunx',
@@ -1424,9 +1465,9 @@ async function resolveToktrackRunner() {
1424
1465
  };
1425
1466
  }
1426
1467
 
1427
- if (await commandExists(IS_WINDOWS ? 'npx.cmd' : 'npx')) {
1468
+ if (await commandExists(getExecutableName('npx'))) {
1428
1469
  return {
1429
- command: IS_WINDOWS ? 'npx.cmd' : 'npx',
1470
+ command: getExecutableName('npx'),
1430
1471
  prefixArgs: ['--yes', 'toktrack'],
1431
1472
  env: {
1432
1473
  ...process.env,
@@ -1557,7 +1598,9 @@ async function runStartupAutoLoad({ source = 'cli-auto-load' } = {}) {
1557
1598
  });
1558
1599
 
1559
1600
  startupAutoLoadCompleted = true;
1560
- 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
+ );
1561
1604
  } catch (error) {
1562
1605
  console.error(`Auto-load failed: ${error.message}`);
1563
1606
  console.error('Dashboard will start without newly imported data.');
@@ -1576,22 +1619,30 @@ const server = http.createServer(async (req, res) => {
1576
1619
  if (apiPath === '/usage') {
1577
1620
  if (req.method === 'GET') {
1578
1621
  const data = readData();
1579
- return json(res, 200, data || {
1580
- daily: [],
1581
- totals: {
1582
- inputTokens: 0,
1583
- outputTokens: 0,
1584
- cacheCreationTokens: 0,
1585
- cacheReadTokens: 0,
1586
- thinkingTokens: 0,
1587
- totalCost: 0,
1588
- totalTokens: 0,
1589
- 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
+ },
1590
1637
  },
1591
- });
1638
+ );
1592
1639
  }
1593
1640
  if (req.method === 'DELETE') {
1594
- try { fs.unlinkSync(DATA_FILE); } catch {}
1641
+ try {
1642
+ fs.unlinkSync(DATA_FILE);
1643
+ } catch {
1644
+ // Ignore missing data files during reset.
1645
+ }
1595
1646
  clearDataLoadState();
1596
1647
  return json(res, 200, { success: true });
1597
1648
  }
@@ -1619,7 +1670,11 @@ const server = http.createServer(async (req, res) => {
1619
1670
  }
1620
1671
 
1621
1672
  if (req.method === 'DELETE') {
1622
- try { fs.unlinkSync(SETTINGS_FILE); } catch {}
1673
+ try {
1674
+ fs.unlinkSync(SETTINGS_FILE);
1675
+ } catch {
1676
+ // Ignore missing settings files during reset.
1677
+ }
1623
1678
  return json(res, 200, { success: true, settings: readSettings() });
1624
1679
  }
1625
1680
 
@@ -1662,9 +1717,10 @@ const server = http.createServer(async (req, res) => {
1662
1717
  return json(res, 200, { days, totalCost });
1663
1718
  } catch (e) {
1664
1719
  const status = e.message === 'Payload too large' ? 413 : 400;
1665
- const message = e.message === 'Payload too large'
1666
- ? 'File too large (max. 10 MB)'
1667
- : 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';
1668
1724
  return json(res, status, { message });
1669
1725
  }
1670
1726
  }
@@ -1697,13 +1753,15 @@ const server = http.createServer(async (req, res) => {
1697
1753
  res.writeHead(200, {
1698
1754
  'Content-Type': 'text/event-stream',
1699
1755
  'Cache-Control': 'no-cache',
1700
- 'Connection': 'keep-alive',
1756
+ Connection: 'keep-alive',
1701
1757
  'X-Accel-Buffering': 'no',
1702
1758
  ...SECURITY_HEADERS,
1703
1759
  });
1704
1760
 
1705
1761
  let aborted = false;
1706
- req.on('close', () => { aborted = true; });
1762
+ req.on('close', () => {
1763
+ aborted = true;
1764
+ });
1707
1765
 
1708
1766
  try {
1709
1767
  const result = await performAutoImport({
@@ -1728,13 +1786,17 @@ const server = http.createServer(async (req, res) => {
1728
1786
  },
1729
1787
  });
1730
1788
 
1731
- if (aborted) { return; }
1789
+ if (aborted) {
1790
+ return;
1791
+ }
1732
1792
 
1733
1793
  sendSSE(res, 'success', result);
1734
1794
  sendSSE(res, 'done', {});
1735
1795
  res.end();
1736
1796
  } catch (err) {
1737
- if (aborted) { return; }
1797
+ if (aborted) {
1798
+ return;
1799
+ }
1738
1800
  sendSSE(res, 'error', { message: `Error: ${err.message}` });
1739
1801
  sendSSE(res, 'done', {});
1740
1802
  res.end();
@@ -1752,20 +1814,28 @@ const server = http.createServer(async (req, res) => {
1752
1814
  return json(res, 400, { message: 'No data available for the report.' });
1753
1815
  }
1754
1816
 
1755
- let body = {};
1817
+ let body;
1756
1818
  try {
1757
1819
  body = await readBody(req);
1758
1820
  } catch (e) {
1759
1821
  const status = e.message === 'Payload too large' ? 413 : 400;
1760
- 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
+ });
1761
1826
  }
1762
1827
 
1763
1828
  try {
1764
1829
  const result = await generatePdfReport(data.daily, body || {});
1765
- return sendBuffer(res, 200, {
1766
- 'Content-Type': 'application/pdf',
1767
- 'Content-Disposition': `attachment; filename="${result.filename}"`,
1768
- }, result.buffer);
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'));