tokentracker-cli 0.67.0 → 0.68.1

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.
Files changed (52) hide show
  1. package/README.ja.md +2 -0
  2. package/README.ko.md +2 -0
  3. package/README.md +2 -0
  4. package/README.zh-CN.md +2 -0
  5. package/dashboard/dist/assets/{ActivityHeatmap-BLL6xjLG.js → ActivityHeatmap-stlH952K.js} +1 -1
  6. package/dashboard/dist/assets/{Card-BB6LiYM8.js → Card-CofgedFz.js} +1 -1
  7. package/dashboard/dist/assets/{ConfirmModal-CX5gv4tk.js → ConfirmModal-n09o_RU7.js} +1 -1
  8. package/dashboard/dist/assets/{DashboardPage-DMvTS8wX.js → DashboardPage-iBPCT4vZ.js} +1 -1
  9. package/dashboard/dist/assets/{DevicePage-vyJEBiMw.js → DevicePage-BGkZILd3.js} +1 -1
  10. package/dashboard/dist/assets/{DialogTitle-R2g9r9PR.js → DialogTitle-BbxEzA03.js} +1 -1
  11. package/dashboard/dist/assets/{FadeIn-CQUbHzNN.js → FadeIn-E5ehYYk1.js} +1 -1
  12. package/dashboard/dist/assets/{HeaderGithubStar-FwJX-pXB.js → HeaderGithubStar-CndrILKO.js} +1 -1
  13. package/dashboard/dist/assets/{IpCheckPage-D_-1u2LS.js → IpCheckPage-5E_xMiUW.js} +1 -1
  14. package/dashboard/dist/assets/{LandingPage-C-lWMwXP.js → LandingPage-CgTigoW1.js} +1 -1
  15. package/dashboard/dist/assets/{LeaderboardAvatar-DskcTB9C.js → LeaderboardAvatar-Dvo78IQE.js} +1 -1
  16. package/dashboard/dist/assets/{LeaderboardPage-BjS-vLBW.js → LeaderboardPage-C4ksvNko.js} +2 -2
  17. package/dashboard/dist/assets/{LeaderboardProfileModal-C6k4VVFS.js → LeaderboardProfileModal-UHYujOQG.js} +1 -1
  18. package/dashboard/dist/assets/{LeaderboardProfilePage-o4fbDnzy.js → LeaderboardProfilePage-CNXemSOF.js} +1 -1
  19. package/dashboard/dist/assets/{LimitsPage-qv3ovWi8.js → LimitsPage-DHAbTe14.js} +1 -1
  20. package/dashboard/dist/assets/{LocalOnlyNotice-C1qyMDpn.js → LocalOnlyNotice-DzocMcPr.js} +1 -1
  21. package/dashboard/dist/assets/{LoginPage-BKNFuYEQ.js → LoginPage-CVFvbmek.js} +1 -1
  22. package/dashboard/dist/assets/{PopoverPopup-leUqNKuk.js → PopoverPopup-CH29Dl4m.js} +1 -1
  23. package/dashboard/dist/assets/{ResetPasswordPage-D5yP5dZa.js → ResetPasswordPage-Oj3VwcUT.js} +1 -1
  24. package/dashboard/dist/assets/{Select-Bq8XjKo9.js → Select-COJepoOt.js} +1 -1
  25. package/dashboard/dist/assets/{SelectItemText-BUE4AAIY.js → SelectItemText-B1dgrCIE.js} +1 -1
  26. package/dashboard/dist/assets/{SettingsPage-DXTRXh8r.js → SettingsPage-CG0Eiwb0.js} +1 -1
  27. package/dashboard/dist/assets/{SkillsPage-Cz17a9ep.js → SkillsPage-DAht7mz-.js} +1 -1
  28. package/dashboard/dist/assets/{WidgetsPage-CSYCzAMh.js → WidgetsPage-CCunqqWC.js} +1 -1
  29. package/dashboard/dist/assets/{WrappedPage-BM2BvUUN.js → WrappedPage-taTQKVSr.js} +1 -1
  30. package/dashboard/dist/assets/{agent-logos-DfysM5Ft.js → agent-logos-CQnbfUYM.js} +1 -1
  31. package/dashboard/dist/assets/{arrow-up-right-KJ5nNRaR.js → arrow-up-right-gF_F8D6S.js} +1 -1
  32. package/dashboard/dist/assets/{download-DPMzNjm9.js → download-DD5R30tG.js} +1 -1
  33. package/dashboard/dist/assets/{main-V7ji8RkP.js → main-BS4wXJQk.js} +2 -2
  34. package/dashboard/dist/assets/{use-limits-display-prefs-BhRZ2Vbm.js → use-limits-display-prefs-CWkXT8WC.js} +1 -1
  35. package/dashboard/dist/assets/{use-native-settings-CUwxJneK.js → use-native-settings-CkkMdU8D.js} +1 -1
  36. package/dashboard/dist/assets/{use-ordered-list-Cg8uqKy8.js → use-ordered-list-DnvBYQP_.js} +1 -1
  37. package/dashboard/dist/assets/{use-quality-per-dollar-pref-DFYl9bWd.js → use-quality-per-dollar-pref-Cunr2tA3.js} +1 -1
  38. package/dashboard/dist/assets/{use-usage-limits-TD09WmDE.js → use-usage-limits-DO7Asd2u.js} +1 -1
  39. package/dashboard/dist/assets/{useCurrency-mDQb_32d.js → useCurrency-Bz7Vj74A.js} +1 -1
  40. package/dashboard/dist/assets/{useScrollLock-CP5GKZtC.js → useScrollLock-BnSHhdwy.js} +1 -1
  41. package/dashboard/dist/index.html +1 -1
  42. package/dashboard/dist/share.html +1 -1
  43. package/package.json +1 -1
  44. package/src/commands/serve.js +32 -1
  45. package/src/commands/status.js +2 -2
  46. package/src/commands/sync.js +151 -70
  47. package/src/lib/browser-auth.js +120 -20
  48. package/src/lib/install-resolver.js +62 -0
  49. package/src/lib/multi-install-parser.js +103 -0
  50. package/src/lib/pricing/seed-snapshot.json +1 -1
  51. package/src/lib/rollout.js +104 -40
  52. package/src/lib/wsl-probe.js +21 -0
@@ -5,6 +5,9 @@ const fssync = require("node:fs");
5
5
  const cp = require("node:child_process");
6
6
  const readline = require("node:readline");
7
7
 
8
+ const { resolveInstallPaths, ensureFlatCursor } = require("../lib/install-resolver");
9
+ const { multiInstallParse, mergeBothFileSources } = require("../lib/multi-install-parser");
10
+ const wsl = require("../lib/wsl-probe");
8
11
  const { ensureDir, readJson, writeJson, openLock } = require("../lib/fs");
9
12
  const {
10
13
  listRolloutFiles,
@@ -30,6 +33,9 @@ const {
30
33
  parseCursorApiIncremental,
31
34
  parseKiroIncremental,
32
35
  parseHermesIncremental,
36
+ gooseInstallOwnsCursor,
37
+ zedInstallOwnsCursor,
38
+ hermesInstallOwnsCursor,
33
39
  parseCopilotIncremental,
34
40
  resolveKimiWireFiles,
35
41
  parseKimiIncremental,
@@ -697,7 +703,9 @@ async function cmdSync(argv) {
697
703
  }
698
704
 
699
705
  // ── Kilo Code VS Code extension (Cline-style ui_messages.json) ──
700
- const kilocodeTaskFiles = sourceAllowed("kilocode") ? resolveKilocodeTaskFiles(process.env) : [];
706
+ const kilocodeTaskFiles = sourceAllowed("kilocode")
707
+ ? mergeBothFileSources({ resolveFiles: resolveKilocodeTaskFiles, env: process.env })
708
+ : [];
701
709
  let kilocodeResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
702
710
  if (kilocodeTaskFiles.length > 0) {
703
711
  if (progress?.enabled) {
@@ -728,32 +736,40 @@ async function cmdSync(argv) {
728
736
  // ── Goose (Block) — SQLite sessions with cumulative tokens per session ──
729
737
  const gooseDbPath = resolveGooseDbPath(process.env);
730
738
  let gooseResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
731
- if (sourceAllowed("goose") && gooseDbPath && fssync.existsSync(gooseDbPath)) {
732
- if (progress?.enabled) {
733
- progress.start(`Parsing Goose ${renderBar(0)} 0 sessions | buckets 0`);
734
- }
735
- try {
736
- gooseResult = await parseGooseIncremental({
737
- dbPath: gooseDbPath,
738
- cursors,
739
- queuePath,
740
- onProgress: (p) => {
741
- if (!progress?.enabled) return;
742
- const pct = p.total > 0 ? p.index / p.total : 1;
743
- progress.update(
744
- `Parsing Goose ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(
745
- p.total,
746
- )} sessions | buckets ${formatNumber(p.bucketsQueued)}`,
747
- );
748
- },
749
- });
750
- } catch (err) {
751
- warnProviderParseFailure("Goose", err, opts);
739
+ if (sourceAllowed("goose")) {
740
+ const gooseMode = wsl.getWslMode(process.env);
741
+ if (gooseMode === "both" && process.platform === "win32") {
742
+ const home = os.homedir();
743
+ const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
744
+ const nativeDb = path.join(appData, "goose", "sessions", "sessions.db");
745
+ const wslDir = wsl.shouldProbeWsl(process.env) ? wsl.discoverWslHome(".local/share/goose/sessions") : null;
746
+ const wslDb = wslDir ? path.join(wslDir, "sessions.db") : null;
747
+ const goosePaths = resolveInstallPaths({ nativeValue: nativeDb, wslValue: wslDb });
748
+ if (goosePaths.native || goosePaths.wsl) {
749
+ if (progress?.enabled) progress.start(`Parsing Goose ${renderBar(0)} 0 sessions | buckets 0`);
750
+ try {
751
+ gooseResult = await multiInstallParse({
752
+ paths: goosePaths, parserFn: parseGooseIncremental, providerName: "goose",
753
+ cursors, getParams: (p) => ({ dbPath: p }), queuePath, onProgress: gooseOnProgress,
754
+ detectInstall: gooseInstallOwnsCursor,
755
+ });
756
+ } catch (err) { warnProviderParseFailure("Goose", err, opts); }
757
+ }
758
+ } else if (gooseDbPath && fssync.existsSync(gooseDbPath)) {
759
+ if (progress?.enabled) progress.start(`Parsing Goose ${renderBar(0)} 0 sessions | buckets 0`);
760
+ ensureFlatCursor(cursors, "goose", process.env);
761
+ try {
762
+ gooseResult = await parseGooseIncremental({
763
+ dbPath: gooseDbPath, cursors, queuePath, onProgress: gooseOnProgress,
764
+ });
765
+ } catch (err) { warnProviderParseFailure("Goose", err, opts); }
752
766
  }
753
767
  }
754
768
 
755
769
  // ── Droid (Factory CLI) — passive reader for ~/.factory/sessions/*.settings.json ──
756
- const droidSettingsFiles = sourceAllowed("droid") ? listDroidSettingsFiles(process.env) : [];
770
+ const droidSettingsFiles = sourceAllowed("droid")
771
+ ? mergeBothFileSources({ resolveFiles: listDroidSettingsFiles, env: process.env })
772
+ : [];
757
773
  let droidResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
758
774
  if (droidSettingsFiles.length > 0) {
759
775
  if (progress?.enabled) {
@@ -788,27 +804,33 @@ async function cmdSync(argv) {
788
804
  // ── Zed Agent (all providers; cumulative-delta over SQLite threads) ──
789
805
  const zedDbPath = resolveZedDbPath(process.env);
790
806
  let zedResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
791
- if (sourceAllowed("zed") && zedDbPath && fssync.existsSync(zedDbPath)) {
792
- if (progress?.enabled) {
793
- progress.start(`Parsing Zed Agent ${renderBar(0)} 0 threads | buckets 0`);
794
- }
795
- try {
796
- zedResult = await parseZedIncremental({
797
- dbPath: zedDbPath,
798
- cursors,
799
- queuePath,
800
- onProgress: (p) => {
801
- if (!progress?.enabled) return;
802
- const pct = p.total > 0 ? p.index / p.total : 1;
803
- progress.update(
804
- `Parsing Zed Agent ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(
805
- p.total,
806
- )} threads | buckets ${formatNumber(p.bucketsQueued)}`,
807
- );
808
- },
809
- });
810
- } catch (err) {
811
- warnProviderParseFailure("Zed Agent", err, opts);
807
+ if (sourceAllowed("zed")) {
808
+ const zedMode = wsl.getWslMode(process.env);
809
+ if (zedMode === "both" && process.platform === "win32") {
810
+ const home = os.homedir();
811
+ const local = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local");
812
+ const nativeDb = path.join(local, "Zed", "threads", "threads.db");
813
+ const wslThreadsDir = wsl.shouldProbeWsl(process.env) ? wsl.discoverWslHome(".local/share/zed/threads") : null;
814
+ const wslDb = wslThreadsDir ? path.join(wslThreadsDir, "threads.db") : null;
815
+ const zedPaths = resolveInstallPaths({ nativeValue: nativeDb, wslValue: wslDb });
816
+ if (zedPaths.native || zedPaths.wsl) {
817
+ if (progress?.enabled) progress.start(`Parsing Zed Agent ${renderBar(0)} 0 threads | buckets 0`);
818
+ try {
819
+ zedResult = await multiInstallParse({
820
+ paths: zedPaths, parserFn: parseZedIncremental, providerName: "zed",
821
+ cursors, getParams: (p) => ({ dbPath: p }), queuePath, onProgress: zedOnProgress,
822
+ detectInstall: zedInstallOwnsCursor,
823
+ });
824
+ } catch (err) { warnProviderParseFailure("Zed Agent", err, opts); }
825
+ }
826
+ } else if (zedDbPath && fssync.existsSync(zedDbPath)) {
827
+ if (progress?.enabled) progress.start(`Parsing Zed Agent ${renderBar(0)} 0 threads | buckets 0`);
828
+ ensureFlatCursor(cursors, "zed", process.env);
829
+ try {
830
+ zedResult = await parseZedIncremental({
831
+ dbPath: zedDbPath, cursors, queuePath, onProgress: zedOnProgress,
832
+ });
833
+ } catch (err) { warnProviderParseFailure("Zed Agent", err, opts); }
812
834
  }
813
835
  }
814
836
 
@@ -920,29 +942,78 @@ async function cmdSync(argv) {
920
942
 
921
943
  // ── Hermes Agent (SQLite-based) ──
922
944
  let hermesResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
923
- const hermesPath = resolveHermesPath();
924
- if (sourceAllowed("hermes") && hermesPath && fssync.existsSync(hermesPath)) {
925
- if (progress?.enabled) {
926
- progress.start(`Parsing Hermes ${renderBar(0)} | buckets 0`);
927
- }
928
- try {
929
- hermesResult = await parseHermesIncremental({
930
- hermesPath,
931
- cursors,
932
- queuePath,
933
- onProgress: (p) => {
934
- if (!progress?.enabled) return;
935
- const pct = p.total > 0 ? p.index / p.total : 1;
936
- progress.update(
937
- `Parsing Hermes ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} sessions | buckets ${formatNumber(p.bucketsQueued)}`,
938
- );
939
- },
940
- });
941
- } catch (err) {
942
- warnProviderParseFailure("Hermes", err, opts);
945
+ if (sourceAllowed("hermes")) {
946
+ const override = process.env.TOKENTRACKER_HERMES_HOME;
947
+ const overridePath = typeof override === "string" && override.trim().length > 0 ? override.trim() : null;
948
+ if (overridePath) {
949
+ if (fssync.existsSync(overridePath)) {
950
+ if (progress?.enabled) {
951
+ progress.start(`Parsing Hermes ${renderBar(0)} | buckets 0`);
952
+ }
953
+ ensureFlatCursor(cursors, "hermes", process.env);
954
+ try {
955
+ hermesResult = await parseHermesIncremental({
956
+ hermesPath: overridePath,
957
+ cursors,
958
+ queuePath,
959
+ onProgress: hermesOnProgress,
960
+ });
961
+ } catch (err) {
962
+ warnProviderParseFailure("Hermes", err, opts);
963
+ }
964
+ }
965
+ } else {
966
+ const home = os.homedir();
967
+ const defaultPath = path.join(home, ".hermes");
968
+ const nativeValue = process.platform === "win32" && typeof process.env.LOCALAPPDATA === "string"
969
+ ? path.join(process.env.LOCALAPPDATA.trim(), "hermes") : defaultPath;
970
+ const hermesPaths = resolveInstallPaths({ nativeValue, wslDir: ".hermes" });
971
+ if (hermesPaths.native || hermesPaths.wsl) {
972
+ if (progress?.enabled) {
973
+ progress.start(`Parsing Hermes ${renderBar(0)} | buckets 0`);
974
+ }
975
+ try {
976
+ hermesResult = await multiInstallParse({
977
+ paths: hermesPaths,
978
+ parserFn: parseHermesIncremental,
979
+ providerName: "hermes",
980
+ cursors,
981
+ getParams: (path) => ({ hermesPath: path }),
982
+ queuePath,
983
+ onProgress: hermesOnProgress,
984
+ detectInstall: hermesInstallOwnsCursor,
985
+ });
986
+ } catch (err) {
987
+ warnProviderParseFailure("Hermes", err, opts);
988
+ }
989
+ }
943
990
  }
944
991
  }
945
992
 
993
+ function hermesOnProgress(p) {
994
+ if (!progress?.enabled) return;
995
+ const pct = p.total > 0 ? p.index / p.total : 1;
996
+ progress.update(
997
+ `Parsing Hermes ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} sessions | buckets ${formatNumber(p.bucketsQueued)}`,
998
+ );
999
+ }
1000
+
1001
+ function gooseOnProgress(p) {
1002
+ if (!progress?.enabled) return;
1003
+ const pct = p.total > 0 ? p.index / p.total : 1;
1004
+ progress.update(
1005
+ `Parsing Goose ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} sessions | buckets ${formatNumber(p.bucketsQueued)}`,
1006
+ );
1007
+ }
1008
+
1009
+ function zedOnProgress(p) {
1010
+ if (!progress?.enabled) return;
1011
+ const pct = p.total > 0 ? p.index / p.total : 1;
1012
+ progress.update(
1013
+ `Parsing Zed Agent ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} threads | buckets ${formatNumber(p.bucketsQueued)}`,
1014
+ );
1015
+ }
1016
+
946
1017
  // ── Kiro CLI (reads ~/Library/Application Support/kiro-cli/data.sqlite3
947
1018
  // AND live sessions under ~/.kiro/sessions/cli/{uuid}.json) ──
948
1019
  // Runs IN PARALLEL with the Kiro IDE branch above — NOT instead of it.
@@ -1005,7 +1076,9 @@ async function cmdSync(argv) {
1005
1076
 
1006
1077
  // ── Kimi Code official (@moonshot-ai/kimi-code, ~/.kimi-code) ──
1007
1078
  let kimiCodeResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
1008
- const kimiCodeWireFiles = sourceAllowed("kimi-code") ? resolveKimiCodeWireFiles(process.env) : [];
1079
+ const kimiCodeWireFiles = sourceAllowed("kimi-code")
1080
+ ? mergeBothFileSources({ resolveFiles: resolveKimiCodeWireFiles, env: process.env })
1081
+ : [];
1009
1082
  if (kimiCodeWireFiles.length > 0) {
1010
1083
  if (progress?.enabled) {
1011
1084
  progress.start(`Parsing Kimi Code (official) ${renderBar(0)} | buckets 0`);
@@ -1033,7 +1106,9 @@ async function cmdSync(argv) {
1033
1106
  // Tencent's CodeBuddy CLI is a Claude Code clone; no hook system, so we
1034
1107
  // tail the per-session JSONL conversation logs incrementally on each sync.
1035
1108
  let codebuddyResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
1036
- const codebuddyFiles = sourceAllowed("codebuddy") ? resolveCodebuddyProjectFiles(process.env) : [];
1109
+ const codebuddyFiles = sourceAllowed("codebuddy")
1110
+ ? mergeBothFileSources({ resolveFiles: resolveCodebuddyProjectFiles, env: process.env })
1111
+ : [];
1037
1112
  if (codebuddyFiles.length > 0) {
1038
1113
  if (progress?.enabled) {
1039
1114
  progress.start(`Parsing CodeBuddy ${renderBar(0)} | buckets 0`);
@@ -1063,7 +1138,9 @@ async function cmdSync(argv) {
1063
1138
  // sub-agent logs nest one level deeper, so the resolver recurses. See the
1064
1139
  // parser comment in rollout.js for the cache-aware token math.
1065
1140
  let workbuddyResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
1066
- const workbuddyFiles = sourceAllowed("workbuddy") ? resolveWorkbuddyProjectFiles(process.env) : [];
1141
+ const workbuddyFiles = sourceAllowed("workbuddy")
1142
+ ? mergeBothFileSources({ resolveFiles: resolveWorkbuddyProjectFiles, env: process.env })
1143
+ : [];
1067
1144
  if (workbuddyFiles.length > 0) {
1068
1145
  if (progress?.enabled) {
1069
1146
  progress.start(`Parsing WorkBuddy ${renderBar(0)} | buckets 0`);
@@ -1089,7 +1166,9 @@ async function cmdSync(argv) {
1089
1166
 
1090
1167
  // ── oh-my-pi (passive ~/.omp/agent/sessions/**/*.jsonl reader) ──
1091
1168
  let ompResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
1092
- const ompFiles = sourceAllowed("omp") ? resolveOmpSessionFiles(process.env) : [];
1169
+ const ompFiles = sourceAllowed("omp")
1170
+ ? mergeBothFileSources({ resolveFiles: resolveOmpSessionFiles, env: process.env })
1171
+ : [];
1093
1172
  if (ompFiles.length > 0) {
1094
1173
  if (progress?.enabled) {
1095
1174
  progress.start(`Parsing oh-my-pi ${renderBar(0)} | buckets 0`);
@@ -1120,7 +1199,7 @@ async function cmdSync(argv) {
1120
1199
  let piResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
1121
1200
  const piFiles = !sourceAllowed("pi") || piAgentDirCollidesWithOmp(process.env)
1122
1201
  ? []
1123
- : resolvePiSessionFiles(process.env);
1202
+ : mergeBothFileSources({ resolveFiles: resolvePiSessionFiles, env: process.env });
1124
1203
  if (piFiles.length > 0) {
1125
1204
  if (progress?.enabled) {
1126
1205
  progress.start(`Parsing pi ${renderBar(0)} | buckets 0`);
@@ -1146,7 +1225,9 @@ async function cmdSync(argv) {
1146
1225
 
1147
1226
  // ── Craft Agents (passive ~/.craft-agent + workspaces session.jsonl reader) ──
1148
1227
  let craftResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
1149
- const craftFiles = sourceAllowed("craft") ? resolveCraftSessionFiles(process.env) : [];
1228
+ const craftFiles = sourceAllowed("craft")
1229
+ ? mergeBothFileSources({ resolveFiles: resolveCraftSessionFiles, env: process.env })
1230
+ : [];
1150
1231
  if (craftFiles.length > 0) {
1151
1232
  if (progress?.enabled) {
1152
1233
  progress.start(`Parsing Craft ${renderBar(0)} | buckets 0`);
@@ -1,6 +1,8 @@
1
1
  const http = require("node:http");
2
2
  const crypto = require("node:crypto");
3
3
  const cp = require("node:child_process");
4
+ const fs = require("node:fs");
5
+ const path = require("node:path");
4
6
 
5
7
  const { DEFAULT_BASE_URL } = require("./runtime-config");
6
8
 
@@ -158,8 +160,103 @@ function buildBrowserList() {
158
160
  return [def, ...all.filter((b) => b !== def)];
159
161
  }
160
162
 
161
- function openInBrowser(url) {
162
- const platform = process.platform;
163
+ function isWslSession(env = process.env) {
164
+ return Boolean(
165
+ (typeof env.WSL_DISTRO_NAME === "string" && env.WSL_DISTRO_NAME.trim()) ||
166
+ (typeof env.WSL_INTEROP === "string" && env.WSL_INTEROP.trim()),
167
+ );
168
+ }
169
+
170
+ function hasGraphicalSession(env = process.env) {
171
+ return Boolean(
172
+ (typeof env.DISPLAY === "string" && env.DISPLAY.trim()) ||
173
+ (typeof env.WAYLAND_DISPLAY === "string" && env.WAYLAND_DISPLAY.trim()),
174
+ );
175
+ }
176
+
177
+ function isCommandAvailable(command, { env = process.env, platform = process.platform } = {}) {
178
+ if (typeof command !== "string" || !command.trim()) return false;
179
+
180
+ const pathEntries = String(env.PATH || "")
181
+ .split(path.delimiter)
182
+ .map((entry) => entry.trim())
183
+ .filter(Boolean);
184
+ const hasPathSeparator = command.includes("/") || command.includes("\\");
185
+ const suffixes = platform === "win32"
186
+ ? String(env.PATHEXT || ".EXE;.CMD;.BAT;.COM")
187
+ .split(";")
188
+ .map((ext) => ext.trim())
189
+ .filter(Boolean)
190
+ : [""];
191
+
192
+ const candidates = [];
193
+ if (hasPathSeparator) {
194
+ candidates.push(command);
195
+ } else {
196
+ for (const dir of pathEntries) {
197
+ if (platform === "win32") {
198
+ candidates.push(path.join(dir, command));
199
+ for (const ext of suffixes) {
200
+ candidates.push(path.join(dir, `${command}${ext}`));
201
+ }
202
+ } else {
203
+ candidates.push(path.join(dir, command));
204
+ }
205
+ }
206
+ }
207
+
208
+ for (const candidate of candidates) {
209
+ try {
210
+ fs.accessSync(candidate, fs.constants.X_OK);
211
+ return true;
212
+ } catch (_e) {}
213
+ }
214
+ return false;
215
+ }
216
+
217
+ function resolveBrowserLaunchCommand(
218
+ url,
219
+ {
220
+ platform = process.platform,
221
+ env = process.env,
222
+ commandExists = isCommandAvailable,
223
+ } = {},
224
+ ) {
225
+ if (platform === "win32") {
226
+ const escapedUrl = String(url).replace(/'/g, "''");
227
+ return {
228
+ command: "powershell.exe",
229
+ args: [
230
+ "-NoProfile",
231
+ "-NonInteractive",
232
+ "-ExecutionPolicy",
233
+ "Bypass",
234
+ "-Command",
235
+ `Start-Process -FilePath '${escapedUrl}'`,
236
+ ],
237
+ };
238
+ }
239
+
240
+ if (isWslSession(env) && commandExists("wslview", { env, platform })) {
241
+ return { command: "wslview", args: [url] };
242
+ }
243
+
244
+ if (!hasGraphicalSession(env)) return null;
245
+
246
+ const candidates = [
247
+ { command: "xdg-open", args: [url] },
248
+ { command: "gio", args: ["open", url] },
249
+ { command: "sensible-browser", args: [url] },
250
+ ];
251
+ for (const candidate of candidates) {
252
+ if (commandExists(candidate.command, { env, platform })) {
253
+ return candidate;
254
+ }
255
+ }
256
+ return null;
257
+ }
258
+
259
+ function openInBrowser(url, { platform = process.platform, env = process.env, spawn = cp.spawn, commandExists = isCommandAvailable } = {}) {
163
260
 
164
261
  if (platform === "darwin") {
165
262
  // On macOS, prefer reusing a matching tab in a supported running browser.
@@ -230,34 +327,33 @@ else
230
327
  open location "${url}"
231
328
  end if
232
329
  `;
233
- try {
234
- const child = cp.spawn("osascript", ["-e", script], { stdio: "ignore", detached: true });
235
- child.unref();
236
- } catch (_e) {
237
- // Fallback to plain open
330
+ if (commandExists("osascript", { env, platform })) {
238
331
  try {
239
- const child = cp.spawn("open", [url], { stdio: "ignore", detached: true });
332
+ const child = spawn("osascript", ["-e", script], { stdio: "ignore", detached: true });
240
333
  child.unref();
334
+ return true;
335
+ } catch (_e) {}
336
+ }
337
+
338
+ if (commandExists("open", { env, platform })) {
339
+ try {
340
+ const child = spawn("open", [url], { stdio: "ignore", detached: true });
341
+ child.unref();
342
+ return true;
241
343
  } catch (_e2) {}
242
344
  }
243
- return;
345
+ return false;
244
346
  }
245
347
 
246
- let cmd = null;
247
- let args = [];
248
-
249
- if (platform === "win32") {
250
- cmd = "cmd";
251
- args = ["/c", "start", "", url];
252
- } else {
253
- cmd = "xdg-open";
254
- args = [url];
255
- }
348
+ const launch = resolveBrowserLaunchCommand(url, { platform, env, commandExists });
349
+ if (!launch) return false;
256
350
 
257
351
  try {
258
- const child = cp.spawn(cmd, args, { stdio: "ignore", detached: true });
352
+ const child = spawn(launch.command, launch.args, { stdio: "ignore", detached: true });
259
353
  child.unref();
354
+ return true;
260
355
  } catch (_e) {}
356
+ return false;
261
357
  }
262
358
 
263
359
  function resolvePostAuthRedirect({ dashboardUrl, authUrl }) {
@@ -277,5 +373,9 @@ function resolvePostAuthRedirect({ dashboardUrl, authUrl }) {
277
373
 
278
374
  module.exports = {
279
375
  beginBrowserAuth,
376
+ hasGraphicalSession,
377
+ isCommandAvailable,
378
+ isWslSession,
280
379
  openInBrowser,
380
+ resolveBrowserLaunchCommand,
281
381
  };
@@ -0,0 +1,62 @@
1
+ const fssync = require("node:fs");
2
+ const wsl = require("./wsl-probe");
3
+
4
+ function resolveInstallPaths({ nativeValue, wslDir, wslValue } = {}, env = process.env, deps = {}) {
5
+ if (process.platform !== "win32") {
6
+ return { native: nativeValue ?? null, wsl: null };
7
+ }
8
+
9
+ const wslCandidate = wslValue !== undefined
10
+ ? (wsl.shouldProbeWsl(env) ? wslValue : null)
11
+ : (wslDir && wsl.shouldProbeWsl(env) ? wsl.discoverWslHome(wslDir, { ...deps, env }) : null);
12
+ const nativeCandidate = wsl.shouldProbeNative(env) && nativeValue
13
+ ? pathExists(nativeValue, deps.existsSync) : null;
14
+
15
+ return wsl.resolveAllWin32Paths({ nativeValue: nativeCandidate, wslValue: wslCandidate, env, platform: "win32" });
16
+ }
17
+
18
+ function pathExists(p, existsSync) {
19
+ try { return (existsSync || fssync.existsSync)(p) ? p : null; } catch (_e) { return null; }
20
+ }
21
+
22
+ // Migrate a flat (single-install) cursor to { native, wsl } namespaces.
23
+ // `activeKeys` names the namespaces seeded with a copy of the flat state; the
24
+ // others start empty so their install's full history backfills on first parse.
25
+ // Leaving a namespace empty is only safe when its install was NEVER counted
26
+ // under the flat cursor — the flat state holds the per-session dedup maps, and
27
+ // an already-counted install re-parsed without them double-counts everything.
28
+ // Callers that cannot prove which install the flat cursor tracked must seed
29
+ // ALL namespaces (the default): bounded backfill loss, never a double count.
30
+ function ensureNamespacedCursors(cursors, providerName, activeKeys = ["native", "wsl"]) {
31
+ const state = cursors[providerName] && typeof cursors[providerName] === "object" ? cursors[providerName] : {};
32
+
33
+ if (state.native !== undefined || state.wsl !== undefined) {
34
+ return state;
35
+ }
36
+
37
+ const keys = Array.isArray(activeKeys) ? activeKeys : [activeKeys];
38
+ cursors[providerName] = { native: {}, wsl: {} };
39
+ if (Object.keys(state).length > 0) {
40
+ for (const key of keys) {
41
+ cursors[providerName][key] = JSON.parse(JSON.stringify(state));
42
+ }
43
+ }
44
+ return cursors[providerName];
45
+ }
46
+
47
+ function ensureFlatCursor(cursors, providerName, env) {
48
+ const state = cursors[providerName];
49
+ if (!state || typeof state !== "object") return;
50
+ if (state.native === undefined && state.wsl === undefined) return;
51
+
52
+ const mode = wsl.getWslMode(env || process.env);
53
+ const preferWsl = mode === "wsl-first" || mode === "wsl-only";
54
+ const merged = preferWsl ? { ...state.native, ...state.wsl } : { ...state.wsl, ...state.native };
55
+ cursors[providerName] = merged;
56
+ }
57
+
58
+ module.exports = {
59
+ resolveInstallPaths,
60
+ ensureNamespacedCursors,
61
+ ensureFlatCursor,
62
+ };
@@ -0,0 +1,103 @@
1
+ const wsl = require("./wsl-probe");
2
+ const { ensureNamespacedCursors, ensureFlatCursor } = require("./install-resolver");
3
+
4
+ function emptyResult() {
5
+ return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
6
+ }
7
+
8
+ async function multiInstallParse({ paths, parserFn, providerName, cursors, getParams, onProgress, detectInstall, ...shared }) {
9
+ const installKeys = Object.keys(paths).filter(k => paths[k]);
10
+ if (installKeys.length === 0) return emptyResult();
11
+ const env = shared.env || process.env;
12
+
13
+ if (installKeys.length === 1) {
14
+ ensureFlatCursor(cursors, providerName, env);
15
+ return await parserFn({
16
+ ...getParams(paths[installKeys[0]], installKeys[0]),
17
+ ...shared,
18
+ cursors,
19
+ onProgress,
20
+ });
21
+ }
22
+
23
+ const activeKeys = resolveMigrationSeedKeys({ paths, installKeys, providerName, cursors, detectInstall });
24
+ const ns = ensureNamespacedCursors(cursors, providerName, activeKeys);
25
+ let recordsProcessed = 0;
26
+ let eventsAggregated = 0;
27
+ let bucketsQueued = 0;
28
+
29
+ for (let i = 0; i < installKeys.length; i++) {
30
+ const key = installKeys[i];
31
+ cursors[providerName] = ns[key];
32
+ try {
33
+ const result = await parserFn({
34
+ ...getParams(paths[key], key), ...shared, cursors,
35
+ onProgress: wrapProgress(onProgress, key),
36
+ });
37
+ ns[key] = cursors[providerName];
38
+ recordsProcessed += result.recordsProcessed || 0;
39
+ eventsAggregated += result.eventsAggregated || 0;
40
+ bucketsQueued += result.bucketsQueued || 0;
41
+ } catch (parseErr) {
42
+ cursors[providerName] = ns;
43
+ throw parseErr;
44
+ }
45
+ }
46
+ cursors[providerName] = ns;
47
+
48
+ return { recordsProcessed, eventsAggregated, bucketsQueued };
49
+ }
50
+
51
+ // Decide which namespaces the one-time flat→namespaced cursor migration seeds
52
+ // with the flat state. `detectInstall(installPath, flatState, installKey)` is
53
+ // a provider-specific probe answering "does this install's DB contain the
54
+ // flat cursor's session ids?". Only when EXACTLY ONE install matches do we
55
+ // know who owned the flat cursor — the other namespace then starts empty so
56
+ // its full history backfills. No probe, no match, multiple matches, or a
57
+ // probe error all fall back to seeding every namespace: an install re-parsed
58
+ // without its dedup state would double-count its entire history, and a
59
+ // bounded backfill gap is the cheaper failure.
60
+ function resolveMigrationSeedKeys({ paths, installKeys, providerName, cursors, detectInstall }) {
61
+ if (typeof detectInstall !== "function") return installKeys;
62
+ const state = cursors[providerName] && typeof cursors[providerName] === "object" ? cursors[providerName] : {};
63
+ const isFlat = state.native === undefined && state.wsl === undefined;
64
+ if (!isFlat || Object.keys(state).length === 0) return installKeys;
65
+
66
+ const hits = [];
67
+ for (const key of installKeys) {
68
+ let owns = false;
69
+ try {
70
+ owns = detectInstall(paths[key], state, key) === true;
71
+ } catch (_e) { }
72
+ if (owns) hits.push(key);
73
+ }
74
+ return hits.length === 1 ? hits : installKeys;
75
+ }
76
+
77
+ function wrapProgress(onProgress, installKey) {
78
+ if (!onProgress) return undefined;
79
+ return (p) => onProgress({ ...p, install: installKey });
80
+ }
81
+
82
+ function mergeBothFileSources({ resolveFiles, env }) {
83
+ const isBoth = process.platform === "win32" && wsl.getWslMode(env) === "both";
84
+ if (!isBoth) {
85
+ const files = resolveFiles(env);
86
+ return files;
87
+ }
88
+
89
+ const nativeEnv = { ...env, TOKENTRACKER_WSL_MODE: "native-only" };
90
+ const wslEnv = { ...env, TOKENTRACKER_WSL_MODE: "wsl-only" };
91
+
92
+ const nativeFiles = resolveFiles(nativeEnv);
93
+ const wslFiles = resolveFiles(wslEnv);
94
+
95
+ const seen = new Set();
96
+ const merged = [];
97
+ for (const f of [...nativeFiles, ...wslFiles]) {
98
+ if (!seen.has(f)) { seen.add(f); merged.push(f); }
99
+ }
100
+ return merged;
101
+ }
102
+
103
+ module.exports = { multiInstallParse, emptyResult, mergeBothFileSources };