tokentracker-cli 0.89.0 → 0.90.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/bin/tracker.js +9 -1
  2. package/dashboard/dist/assets/{AchievementBadge-BpDy2XSt.js → AchievementBadge-BDPa7twL.js} +1 -1
  3. package/dashboard/dist/assets/{AchievementsPage-Z3rNbZAL.js → AchievementsPage-CQoThMTm.js} +2 -2
  4. package/dashboard/dist/assets/{AchievementsSection-Cawmrzgp.js → AchievementsSection-D4Uij6_V.js} +1 -1
  5. package/dashboard/dist/assets/{BadgeDetailModal-D8IPOyzs.js → BadgeDetailModal-DIyhyfZn.js} +1 -1
  6. package/dashboard/dist/assets/{Card-Bco5i8Fn.js → Card-C9Qq3dRv.js} +1 -1
  7. package/dashboard/dist/assets/{ClawdAnimated-RQvg50Wr.js → ClawdAnimated-DvNwXeAg.js} +1 -1
  8. package/dashboard/dist/assets/{CommandPalette-DWLyd7P_.js → CommandPalette-Bx_XQD25.js} +1 -1
  9. package/dashboard/dist/assets/{CommunityStatsModal-DfjsjuRG.js → CommunityStatsModal-CJN0niLH.js} +1 -1
  10. package/dashboard/dist/assets/{ConfirmModal-DNuSWkt3.js → ConfirmModal-CCEwK_z5.js} +1 -1
  11. package/dashboard/dist/assets/{Controls-DdLHdkEz.js → Controls-h-L9P_cI.js} +1 -1
  12. package/dashboard/dist/assets/{DashboardPage-DN54xqEE.js → DashboardPage-lskJQLFc.js} +1 -1
  13. package/dashboard/dist/assets/{DevicePage-ydAtX5oZ.js → DevicePage-CxwatSX3.js} +1 -1
  14. package/dashboard/dist/assets/{DialogClose-C40Az_dd.js → DialogClose-C3z3Trr2.js} +1 -1
  15. package/dashboard/dist/assets/DialogDescription-NmqWPZeZ.js +1 -0
  16. package/dashboard/dist/assets/{DialogTitle-CCg6RvL8.js → DialogTitle-BlKXVRCZ.js} +1 -1
  17. package/dashboard/dist/assets/{FadeIn-a7dOxD0I.js → FadeIn-BWJwSozb.js} +1 -1
  18. package/dashboard/dist/assets/{HeaderGithubStar-DmH6JYQF.js → HeaderGithubStar-iQIqNWX8.js} +1 -1
  19. package/dashboard/dist/assets/{HoverTooltip-D3yTRqfx.js → HoverTooltip-Doqf4LkR.js} +1 -1
  20. package/dashboard/dist/assets/{IpCheckPage-DH4av7RV.js → IpCheckPage-CAQOcumi.js} +1 -1
  21. package/dashboard/dist/assets/{LandingPage-B1fuvkPh.js → LandingPage-C55ZuQ4S.js} +2 -2
  22. package/dashboard/dist/assets/{LeaderboardAvatar-C-tKTfIs.js → LeaderboardAvatar-AwdzkUva.js} +1 -1
  23. package/dashboard/dist/assets/{LeaderboardPage-CL7Cre5j.js → LeaderboardPage-De-XqDgp.js} +2 -2
  24. package/dashboard/dist/assets/{LeaderboardProfileModal-CqRvLdEB.js → LeaderboardProfileModal-Bs1jCBZR.js} +3 -3
  25. package/dashboard/dist/assets/{LeaderboardProfilePage-BDjW5z87.js → LeaderboardProfilePage-B7hvba9_.js} +1 -1
  26. package/dashboard/dist/assets/LimitsPage-bYc_9RQC.js +5 -0
  27. package/dashboard/dist/assets/{LocalOnlyNotice-CQDKfXwB.js → LocalOnlyNotice-BWFxeB2d.js} +1 -1
  28. package/dashboard/dist/assets/{LoginCard-CptsVdtq.js → LoginCard-BEhQV-kF.js} +1 -1
  29. package/dashboard/dist/assets/{LoginModal-8V4Vsgpr.js → LoginModal-BnIpMaiu.js} +1 -1
  30. package/dashboard/dist/assets/{LoginPage-DIwzC_95.js → LoginPage-lOoS6omw.js} +1 -1
  31. package/dashboard/dist/assets/{PetPage-CXSq_C6b.js → PetPage-CBxkB0FQ.js} +1 -1
  32. package/dashboard/dist/assets/{PopoverPopup-C_6kkB4_.js → PopoverPopup-mRFMXjMo.js} +1 -1
  33. package/dashboard/dist/assets/{ProviderIcon-BKGMzdEg.js → ProviderIcon-CYUc_5_Z.js} +1 -1
  34. package/dashboard/dist/assets/{ResetPasswordPage-BThSQVj7.js → ResetPasswordPage-BydyI8Te.js} +1 -1
  35. package/dashboard/dist/assets/{SegmentedControl-DdIw-7OY.js → SegmentedControl-LfSKYbfp.js} +1 -1
  36. package/dashboard/dist/assets/{Select-BuxVjVTV.js → Select-UiEhxqur.js} +1 -1
  37. package/dashboard/dist/assets/{SelectItemText-B7AP9a2t.js → SelectItemText-DiVTS64c.js} +1 -1
  38. package/dashboard/dist/assets/{ServiceStatusPage-Db6XpKGJ.js → ServiceStatusPage-krg3Du1w.js} +1 -1
  39. package/dashboard/dist/assets/{SessionsPage-CIvvinQm.js → SessionsPage-D28m0rcm.js} +1 -1
  40. package/dashboard/dist/assets/SettingsPage-DiVhaU-X.js +2 -0
  41. package/dashboard/dist/assets/{SkillsPage-2rr2nzvc.js → SkillsPage-CXg55JZ3.js} +1 -1
  42. package/dashboard/dist/assets/{TokenGalaxy-WS2JBmzs.js → TokenGalaxy-CymWb45G.js} +1 -1
  43. package/dashboard/dist/assets/{ToolbarRootContext-B5s-ucZR.js → ToolbarRootContext-D8SpeV_Z.js} +1 -1
  44. package/dashboard/dist/assets/{TrendMonitor-DfgZi0b3.js → TrendMonitor-ZI8UVyUl.js} +1 -1
  45. package/dashboard/dist/assets/{WidgetsPage-WWaK3S0S.js → WidgetsPage-3HI5iI7R.js} +1 -1
  46. package/dashboard/dist/assets/{WrappedPage-C3C85NLm.js → WrappedPage-BK8eyxWF.js} +1 -1
  47. package/dashboard/dist/assets/{__vite-browser-external-B-CAZ5HB.js → __vite-browser-external-B7TLlrta.js} +63 -18
  48. package/dashboard/dist/assets/{agent-logos-Do1kFgsi.js → agent-logos-BVrhjlzq.js} +1 -1
  49. package/dashboard/dist/assets/{arrow-up-right-DbomORWZ.js → arrow-up-right-BH3-SjFz.js} +1 -1
  50. package/dashboard/dist/assets/{check-DzEvx9pJ.js → check-B9cFJZgF.js} +1 -1
  51. package/dashboard/dist/assets/{chevron-right-DZTnF2NM.js → chevron-right-Bo86g4wO.js} +1 -1
  52. package/dashboard/dist/assets/{copy-BS7rQ_jR.js → copy-j7Scp8t1.js} +1 -1
  53. package/dashboard/dist/assets/{download-DlWVR14_.js → download-BUt01rg_.js} +1 -1
  54. package/dashboard/dist/assets/{external-link-CtVBRLbf.js → external-link-DzxpdqJI.js} +1 -1
  55. package/dashboard/dist/assets/{flame-Bd3FUTdZ.js → flame-CkRd9KBe.js} +1 -1
  56. package/dashboard/dist/assets/{format-tokens--QLNTkZB.js → format-tokens-B1pCqj3T.js} +1 -1
  57. package/dashboard/dist/assets/{icons-Cs8SXqmU.js → icons-DbEfSumr.js} +1 -1
  58. package/dashboard/dist/assets/{index-Cj7qHXa4.js → index-3-T0VHz-.js} +1 -1
  59. package/dashboard/dist/assets/{index-DoTYGePn.js → index-5a1G4YuE.js} +1 -1
  60. package/dashboard/dist/assets/{index-CfS3tuU1.js → index-CSGxiVQp.js} +1 -1
  61. package/dashboard/dist/assets/{info-CsAt6S8y.js → info-nIQW2hmu.js} +1 -1
  62. package/dashboard/dist/assets/limits-providers-DUfDGI3w.js +1 -0
  63. package/dashboard/dist/assets/{link-2-CjC85wrH.js → link-2-BIma2gRd.js} +1 -1
  64. package/dashboard/dist/assets/{loader-circle-Cf0h8jN7.js → loader-circle-DNwHxZy4.js} +1 -1
  65. package/dashboard/dist/assets/{maximize-2-DSLJns7Q.js → maximize-2-RnscHIM8.js} +1 -1
  66. package/dashboard/dist/assets/{modifiers.esm-2DRBUt-i.js → modifiers.esm-Blvm8Jbj.js} +1 -1
  67. package/dashboard/dist/assets/{monitor-smartphone-DitUxFt7.js → monitor-smartphone-BH-q52Ze.js} +1 -1
  68. package/dashboard/dist/assets/{provider-display-Dm_uiVV1.js → provider-display-C_BMuv2U.js} +1 -1
  69. package/dashboard/dist/assets/{react-CExpcnBO.js → react-CsQaHrby.js} +1 -1
  70. package/dashboard/dist/assets/{search-S2Q8oj2Y.js → search-DlK17ul0.js} +1 -1
  71. package/dashboard/dist/assets/{skills-api-C3mlKc8x.js → skills-api-DaCUcoqM.js} +1 -1
  72. package/dashboard/dist/assets/{sortable.esm-22qRLWIc.js → sortable.esm-CJh9Zk1m.js} +1 -1
  73. package/dashboard/dist/assets/{store-DktMCl4T.js → store-DByB-W50.js} +1 -1
  74. package/dashboard/dist/assets/{terminal-DS6Tqgok.js → terminal-C5HhEMI5.js} +1 -1
  75. package/dashboard/dist/assets/{trash-2-BHgzn2Bd.js → trash-2-9Vxl6ZF4.js} +1 -1
  76. package/dashboard/dist/assets/{use-community-stats-DqQQKHPK.js → use-community-stats-DTQlvI-1.js} +1 -1
  77. package/dashboard/dist/assets/{use-limits-display-prefs-C2fbOm_s.js → use-limits-display-prefs-Du018pOW.js} +1 -1
  78. package/dashboard/dist/assets/{use-ordered-list-BnIpnofG.js → use-ordered-list-D5RWUoYn.js} +1 -1
  79. package/dashboard/dist/assets/use-reduced-motion-BsIV3-9R.js +1 -0
  80. package/dashboard/dist/assets/{use-session-efficiency-pref-B78vQ2pp.js → use-session-efficiency-pref-fttmU-6p.js} +1 -1
  81. package/dashboard/dist/assets/{use-transform-DJeSBZmb.js → use-transform-BErbwrSs.js} +1 -1
  82. package/dashboard/dist/assets/{use-usage-limits-BkSQGzCV.js → use-usage-limits-C6Npx5q3.js} +1 -1
  83. package/dashboard/dist/assets/{useCurrency-BY7ZJ-XD.js → useCurrency-DAj-AqX-.js} +1 -1
  84. package/dashboard/dist/assets/{useScrollLock-CHsj-WN5.js → useScrollLock-BhgbzYuS.js} +1 -1
  85. package/dashboard/dist/assets/{useTokenFormat-CVJJAs3R.js → useTokenFormat-jEoapvgD.js} +1 -1
  86. package/dashboard/dist/assets/{zap-Dtudxu92.js → zap-uQ4fo5lp.js} +1 -1
  87. package/dashboard/dist/brand-logos/volcano-ark.svg +14 -0
  88. package/dashboard/dist/index.html +1 -1
  89. package/dashboard/dist/ip-check.html +1 -1
  90. package/dashboard/dist/leaderboard.html +1 -1
  91. package/dashboard/dist/share.html +1 -1
  92. package/package.json +1 -1
  93. package/src/commands/status.js +3 -3
  94. package/src/lib/ark-coding-plan-limits.js +462 -0
  95. package/src/lib/command-runner.js +286 -0
  96. package/src/lib/cursor-config.js +84 -86
  97. package/src/lib/fs.js +11 -2
  98. package/src/lib/local-api.js +137 -0
  99. package/src/lib/pricing/seed-snapshot.json +1 -1
  100. package/src/lib/proxy-env.js +397 -44
  101. package/src/lib/proxy-settings.js +163 -0
  102. package/src/lib/rollout.js +80 -39
  103. package/src/lib/usage-limits.js +40 -142
  104. package/dashboard/dist/assets/DialogDescription-CNfc42U-.js +0 -1
  105. package/dashboard/dist/assets/LimitsPage-D533J_we.js +0 -4
  106. package/dashboard/dist/assets/SettingsPage-DZo5FGJE.js +0 -1
  107. package/dashboard/dist/assets/limits-providers-Czh_yu22.js +0 -1
  108. package/dashboard/dist/assets/use-reduced-motion-oDsUC_PJ.js +0 -1
@@ -0,0 +1,286 @@
1
+ "use strict";
2
+
3
+ // Shared command runner for local-CLI limit providers.
4
+ //
5
+ // Extracted from usage-limits.js (which keeps a require of it) so
6
+ // ark-coding-plan-limits.js and future providers don't have to copy it.
7
+ // This is a superset of both former copies: the original's `completeWhen`
8
+ // early-settlement hook, plus the hardening that previously only lived in
9
+ // the ark copy — an abort `signal` wired into the spawn lifecycle, a
10
+ // `platform` override with `where.exe` discovery on native Windows, and a
11
+ // byte-capped maxBuffer for piped spawn output.
12
+
13
+ const cp = require("node:child_process");
14
+ const fs = require("node:fs");
15
+ const os = require("node:os");
16
+ const path = require("node:path");
17
+
18
+ function runCommand(commandRunner, command, args, options = {}) {
19
+ const merged = {
20
+ encoding: "utf8",
21
+ maxBuffer: 10 * 1024 * 1024,
22
+ useShell: false,
23
+ ...options,
24
+ };
25
+ if (typeof commandRunner === "function") {
26
+ return Promise.resolve(commandRunner(command, args, merged));
27
+ }
28
+
29
+ const {
30
+ timeout,
31
+ maxBuffer,
32
+ completeWhen,
33
+ completionGraceMs = 250,
34
+ killProcessGroup = false,
35
+ platform = process.platform,
36
+ signal,
37
+ useShell = false,
38
+ ...spawnOptions
39
+ } = merged;
40
+ return new Promise((resolve) => {
41
+ if (signal?.aborted) {
42
+ const error = new Error(`spawn ${command} aborted`);
43
+ error.name = "AbortError";
44
+ resolve({ status: null, stdout: "", stderr: "", error });
45
+ return;
46
+ }
47
+
48
+ const useProcessGroup = killProcessGroup && platform !== "win32";
49
+ // `useShell` is opt-in per call site. It exists for npm's Windows .cmd
50
+ // shims, which Node's spawn cannot execute directly — but shell
51
+ // execution means cmd.exe re-parses the joined command line, so any
52
+ // argument carrying shell metacharacters (e.g. a powershell -Command
53
+ // script with `|`) would be split. Default false keeps direct spawns,
54
+ // which is what every pre-existing usage-limits call site expects.
55
+ // Under shell execution quote the command unconditionally: cmd.exe
56
+ // metacharacters are not limited to whitespace (`C:\Users\a&b\...`
57
+ // has none yet splits at `&`), and Windows account names allow them.
58
+ const shellCommand = useShell && !command.startsWith('"')
59
+ ? `"${command}"`
60
+ : command;
61
+ let child;
62
+ try {
63
+ child = cp.spawn(shellCommand, args, {
64
+ ...spawnOptions,
65
+ detached: useProcessGroup || spawnOptions.detached,
66
+ shell: useShell,
67
+ stdio: ["ignore", "pipe", "pipe"],
68
+ });
69
+ } catch (error) {
70
+ resolve({ status: null, stdout: "", stderr: "", error });
71
+ return;
72
+ }
73
+
74
+ let stdout = "";
75
+ let stderr = "";
76
+ let outputBytes = 0;
77
+ let settled = false;
78
+ let timedOut = false;
79
+ let timer = null;
80
+ let hardTimer = null;
81
+ let completionTimer = null;
82
+ let abortListener = null;
83
+
84
+ const settle = ({ status = null, error = null } = {}) => {
85
+ if (settled) return;
86
+ settled = true;
87
+ if (timer) clearTimeout(timer);
88
+ if (hardTimer) clearTimeout(hardTimer);
89
+ if (completionTimer) clearTimeout(completionTimer);
90
+ if (abortListener) signal?.removeEventListener("abort", abortListener);
91
+ let finalError = error;
92
+ if (!finalError && timedOut) {
93
+ finalError = new Error(`spawn ${command} ETIMEDOUT`);
94
+ finalError.code = "ETIMEDOUT";
95
+ }
96
+ const result = { status, stdout, stderr };
97
+ if (finalError) result.error = finalError;
98
+ resolve(result);
99
+ };
100
+
101
+ const signalChild = (killSignal) => {
102
+ try {
103
+ if (useProcessGroup && Number.isInteger(child.pid)) {
104
+ process.kill(-child.pid, killSignal);
105
+ } else {
106
+ child.kill(killSignal);
107
+ }
108
+ } catch (_error) {}
109
+ };
110
+
111
+ const stopChild = ({ timeoutExpired = false } = {}) => {
112
+ if (settled) return;
113
+ if (timeoutExpired) timedOut = true;
114
+ signalChild("SIGTERM");
115
+ // A CLI may leave descendants or inherited stdio alive after SIGTERM.
116
+ // Escalate after a short grace period and settle even if close never fires.
117
+ hardTimer = setTimeout(() => {
118
+ signalChild("SIGKILL");
119
+ settle({ status: null });
120
+ }, 1000);
121
+ if (typeof hardTimer.unref === "function") hardTimer.unref();
122
+ };
123
+
124
+ const scheduleCompletion = () => {
125
+ if (typeof completeWhen !== "function" || settled) return;
126
+ let complete = false;
127
+ try {
128
+ complete = Boolean(completeWhen(stdout, stderr));
129
+ } catch (_error) {}
130
+ if (!complete) return;
131
+ if (completionTimer) clearTimeout(completionTimer);
132
+ completionTimer = setTimeout(
133
+ () => stopChild(),
134
+ Math.max(0, Number(completionGraceMs) || 0),
135
+ );
136
+ };
137
+
138
+ const appendOutput = (key, chunk) => {
139
+ if (settled) return;
140
+ if (key === "stdout") stdout += chunk;
141
+ else stderr += chunk;
142
+ outputBytes += Buffer.byteLength(chunk, "utf8");
143
+ // Unlike exec/execFile, spawn does not apply a maxBuffer guard to piped
144
+ // streams. Enforce the combined byte cap here so a verbose CLI cannot
145
+ // grow this process without bound.
146
+ if (outputBytes > maxBuffer) {
147
+ const error = new Error(`spawn ${command} maxBuffer length exceeded`);
148
+ error.code = "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
149
+ signalChild("SIGKILL");
150
+ settle({ status: null, error });
151
+ return;
152
+ }
153
+ scheduleCompletion();
154
+ };
155
+
156
+ child.stdout?.setEncoding("utf8");
157
+ child.stderr?.setEncoding("utf8");
158
+ child.stdout?.on("data", (chunk) => appendOutput("stdout", chunk));
159
+ child.stderr?.on("data", (chunk) => appendOutput("stderr", chunk));
160
+ child.on("error", (error) => settle({ status: null, error }));
161
+ child.on("close", (code) => settle({ status: timedOut ? null : code }));
162
+
163
+ if (signal) {
164
+ abortListener = () => stopChild();
165
+ signal.addEventListener("abort", abortListener, { once: true });
166
+ if (signal.aborted) abortListener();
167
+ }
168
+ if (Number.isFinite(timeout) && timeout > 0) {
169
+ timer = setTimeout(() => stopChild({ timeoutExpired: true }), timeout);
170
+ }
171
+ });
172
+ }
173
+
174
+ // Locate a binary on PATH. Unix uses `which`; native Windows ships no `which`
175
+ // (it has `where.exe` instead), so blindly spawning `which` there returns
176
+ // ENOENT and every provider would report itself unconfigured even when the
177
+ // binary is installed and signed in.
178
+ async function whichBinary(binary, { commandRunner, platform = process.platform, signal } = {}) {
179
+ const probe = platform === "win32" ? "where" : "which";
180
+ const result = await runCommand(commandRunner, probe, [binary], {
181
+ timeout: 2000,
182
+ signal,
183
+ platform,
184
+ killProcessGroup: true,
185
+ });
186
+ if (result?.error || result?.status !== 0) return null;
187
+ const stdout = typeof result?.stdout === "string" ? result.stdout.trim() : "";
188
+ if (!stdout) return null;
189
+ // `where` on Windows emits CRLF and may list several matches across PATH
190
+ // entries; take the first line without the trailing `\r`, or the polluted
191
+ // path would fail at spawn time.
192
+ return stdout
193
+ .split(/\r?\n/)
194
+ .map((line) => line.trim())
195
+ .find(Boolean) || null;
196
+ }
197
+
198
+ async function isBinaryAvailable(binary, { commandRunner, platform, signal } = {}) {
199
+ return (await whichBinary(binary, { commandRunner, platform, signal })) !== null;
200
+ }
201
+
202
+ // Expand a versioned install root (e.g. ~/.nvm/versions/node/<ver>/ or
203
+ // fnm's node-versions/<ver>/installation/) into its per-version bin
204
+ // directories. Returns [] when the root does not exist.
205
+ function versionedBinDirs(root, inner) {
206
+ try {
207
+ return fs.readdirSync(root, { withFileTypes: true })
208
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
209
+ .sort((a, b) => b.name.localeCompare(a.name, "en", { numeric: true }))
210
+ .map((entry) => path.join(root, entry.name, ...inner));
211
+ } catch (_error) {
212
+ return [];
213
+ }
214
+ }
215
+
216
+ // Directories where globally installed CLIs commonly live even when the
217
+ // current process has a minimal PATH (e.g. a Finder-launched macOS app does
218
+ // not inherit the login-shell PATH, so Homebrew and npm global binaries are
219
+ // unreachable through `which`). Probed with statSync — no spawn.
220
+ function commonGlobalBinDirectories({ home = os.homedir(), platform = process.platform } = {}) {
221
+ if (platform === "win32") {
222
+ return [
223
+ path.join(home, "AppData", "Roaming", "npm"),
224
+ ];
225
+ }
226
+ return [
227
+ "/opt/homebrew/bin",
228
+ "/usr/local/bin",
229
+ path.join(home, ".npm-global", "bin"),
230
+ // volta keeps shims for every global install here
231
+ path.join(home, ".volta", "bin"),
232
+ // nvm / fnm install npm globals under a per-version prefix that a
233
+ // minimal PATH never sees; newest version first.
234
+ ...versionedBinDirs(path.join(home, ".nvm", "versions", "node"), ["bin"]),
235
+ ...versionedBinDirs(path.join(home, ".local", "share", "fnm", "node-versions"), ["installation", "bin"]),
236
+ ...(platform === "darwin"
237
+ ? versionedBinDirs(path.join(home, "Library", "Application Support", "fnm", "node-versions"), ["installation", "bin"])
238
+ : []),
239
+ ];
240
+ }
241
+
242
+ // Probe `binary` inside the given directories with statSync — no spawn.
243
+ // Exported so providers can treat a hit as install evidence without
244
+ // paying for a `which` process.
245
+ function statBinaryInDirs(binary, searchDirs, platform = process.platform) {
246
+ for (const dir of searchDirs) {
247
+ const candidate = path.join(dir, binary);
248
+ for (const suffix of platform === "win32" ? ["", ".cmd", ".exe"] : [""]) {
249
+ try {
250
+ if (fs.statSync(candidate + suffix).isFile()) return candidate + suffix;
251
+ } catch (_error) {}
252
+ }
253
+ }
254
+ return null;
255
+ }
256
+
257
+ /**
258
+ * Resolve a binary to an absolute path: `which`/`where` first, then a
259
+ * spawn-free probe of the common global-install directories above. Returns
260
+ * null when the binary cannot be found. Callers should spawn the returned
261
+ * path (not the bare name): a bare name goes through PATH search again —
262
+ * and on Windows cmd.exe searches the current directory first, which would
263
+ * let an `arkcli.bat` dropped in the server cwd hijack the spawn.
264
+ */
265
+ async function resolveBinaryPath(binary, { commandRunner, home, platform = process.platform, signal, globalBinDirs } = {}) {
266
+ let resolved = null;
267
+ try {
268
+ resolved = await whichBinary(binary, { commandRunner, platform, signal });
269
+ } catch (_error) {
270
+ resolved = null;
271
+ }
272
+ if (resolved) return resolved;
273
+ const searchDirs = Array.isArray(globalBinDirs)
274
+ ? globalBinDirs
275
+ : commonGlobalBinDirectories({ home, platform });
276
+ return statBinaryInDirs(binary, searchDirs, platform);
277
+ }
278
+
279
+ module.exports = {
280
+ runCommand,
281
+ whichBinary,
282
+ isBinaryAvailable,
283
+ commonGlobalBinDirectories,
284
+ statBinaryInDirs,
285
+ resolveBinaryPath,
286
+ };
@@ -1,7 +1,6 @@
1
1
  const os = require("node:os");
2
2
  const path = require("node:path");
3
3
  const fs = require("node:fs");
4
- const https = require("node:https");
5
4
 
6
5
  const { readJson } = require("./fs");
7
6
  const { readSqliteFirstValue } = require("./sqlite-reader");
@@ -139,58 +138,73 @@ const CURSOR_CSV_URL = "https://cursor.com/api/dashboard/export-usage-events-csv
139
138
  const CURSOR_SUMMARY_URL = "https://cursor.com/api/usage-summary";
140
139
  const CURSOR_SOURCE_SCOPE = "account";
141
140
 
141
+ function isCursorFetchTimeout(err) {
142
+ return Boolean(err && (err.name === "TimeoutError" || err.name === "AbortError"));
143
+ }
144
+
145
+ function remapCursorFetchTimeout(err) {
146
+ if (isCursorFetchTimeout(err)) {
147
+ throw new Error("Cursor API request timed out");
148
+ }
149
+ throw err;
150
+ }
151
+
152
+ const CURSOR_REDIRECT_HOSTS = new Set(["cursor.com", "www.cursor.com"]);
153
+
154
+ /**
155
+ * Resolve a redirect `Location` before the session cookie is forwarded to it.
156
+ *
157
+ * The header is attacker-influenced in principle, and the next hop carries the
158
+ * Cursor session cookie, so an off-origin redirect would disclose it. Resolving
159
+ * against the request URL also handles a relative Location, which bare
160
+ * `fetch(location)` would reject.
161
+ */
162
+ function resolveCursorRedirect(location, baseUrl) {
163
+ let url;
164
+ try {
165
+ url = new URL(location, baseUrl);
166
+ } catch {
167
+ throw new Error("Cursor API redirect to an untrusted origin");
168
+ }
169
+ if (url.protocol !== "https:" || !CURSOR_REDIRECT_HOSTS.has(url.hostname)) {
170
+ throw new Error("Cursor API redirect to an untrusted origin");
171
+ }
172
+ return url.toString();
173
+ }
174
+
142
175
  /**
143
176
  * Fetch full usage CSV from Cursor API.
144
177
  * Returns raw CSV string or throws on error.
145
178
  */
146
- function fetchCursorUsageCsv({ cookie, timeoutMs = 30000 }) {
147
- return new Promise((resolve, reject) => {
148
- const url = new URL(CURSOR_CSV_URL);
149
- const req = https.request(
150
- {
151
- hostname: url.hostname,
152
- path: url.pathname + url.search,
153
- method: "GET",
154
- headers: {
155
- Accept: "*/*",
156
- Cookie: cookie,
157
- Referer: "https://www.cursor.com/settings",
158
- "User-Agent":
159
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
160
- },
161
- timeout: timeoutMs,
162
- },
163
- (res) => {
164
- if (res.statusCode === 401 || res.statusCode === 403) {
165
- res.resume();
166
- return reject(new Error("Cursor session expired re-login in Cursor to refresh"));
167
- }
168
- if (res.statusCode === 308 || res.statusCode === 301 || res.statusCode === 302) {
169
- // Follow redirect once
170
- const location = res.headers.location;
171
- res.resume();
172
- if (!location) return reject(new Error(`Cursor API redirect without Location header`));
173
- return fetchUrlRaw({ urlStr: location, cookie, timeoutMs }).then(resolve, reject);
174
- }
175
- if (res.statusCode !== 200) {
176
- res.resume();
177
- return reject(new Error(`Cursor API returned ${res.statusCode}`));
178
- }
179
- let data = "";
180
- res.on("data", (chunk) => {
181
- data += chunk;
182
- });
183
- res.on("end", () => resolve(data));
184
- res.on("error", reject);
185
- },
186
- );
187
- req.on("error", reject);
188
- req.on("timeout", () => {
189
- req.destroy();
190
- reject(new Error("Cursor API request timed out"));
191
- });
192
- req.end();
193
- });
179
+ function fetchCursorUsageCsv({ cookie, timeoutMs = 30000, fetchImpl = fetch }) {
180
+ return fetchImpl(CURSOR_CSV_URL, {
181
+ method: "GET",
182
+ headers: {
183
+ Accept: "*/*",
184
+ Cookie: cookie,
185
+ Referer: "https://www.cursor.com/settings",
186
+ "User-Agent":
187
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
188
+ },
189
+ redirect: "manual",
190
+ signal: AbortSignal.timeout(timeoutMs),
191
+ }).then(async (res) => {
192
+ if (res.status === 401 || res.status === 403) {
193
+ throw new Error("Cursor session expired — re-login in Cursor to refresh");
194
+ }
195
+ if (res.status === 308 || res.status === 301 || res.status === 302) {
196
+ // Follow redirect once. Keep this hop on redirect:"manual" so the
197
+ // Location + Cookie are forwarded explicitly (cursor.com www.cursor.com).
198
+ const location = res.headers.get("location");
199
+ if (!location) throw new Error("Cursor API redirect without Location header");
200
+ const target = resolveCursorRedirect(location, CURSOR_CSV_URL);
201
+ return fetchUrlRaw({ urlStr: target, cookie, timeoutMs, fetchImpl });
202
+ }
203
+ if (res.status !== 200) {
204
+ throw new Error(`Cursor API returned ${res.status}`);
205
+ }
206
+ return res.text();
207
+ }).catch(remapCursorFetchTimeout);
194
208
  }
195
209
 
196
210
  /**
@@ -220,43 +234,27 @@ function fetchCursorUsageSummary({ cookie, timeoutMs = 30000, fetchImpl = fetch
220
234
  });
221
235
  }
222
236
 
223
- function fetchUrlRaw({ urlStr, cookie, timeoutMs }) {
224
- return new Promise((resolve, reject) => {
225
- const url = new URL(urlStr);
226
- const req = https.request(
227
- {
228
- hostname: url.hostname,
229
- path: url.pathname + url.search,
230
- method: "GET",
231
- headers: {
232
- Accept: "*/*",
233
- Cookie: cookie,
234
- Referer: "https://www.cursor.com/settings",
235
- "User-Agent":
236
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
237
- },
238
- timeout: timeoutMs,
239
- },
240
- (res) => {
241
- if (res.statusCode !== 200) {
242
- res.resume();
243
- return reject(new Error(`Cursor API returned ${res.statusCode} from ${urlStr}`));
244
- }
245
- let data = "";
246
- res.on("data", (chunk) => {
247
- data += chunk;
248
- });
249
- res.on("end", () => resolve(data));
250
- res.on("error", reject);
251
- },
252
- );
253
- req.on("error", reject);
254
- req.on("timeout", () => {
255
- req.destroy();
256
- reject(new Error("Cursor API request timed out"));
257
- });
258
- req.end();
259
- });
237
+ function fetchUrlRaw({ urlStr, cookie, timeoutMs = 30000, fetchImpl = fetch }) {
238
+ return fetchImpl(urlStr, {
239
+ method: "GET",
240
+ headers: {
241
+ Accept: "*/*",
242
+ Cookie: cookie,
243
+ Referer: "https://www.cursor.com/settings",
244
+ "User-Agent":
245
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
246
+ },
247
+ // Keep manual: the previous hop did not follow a second redirect.
248
+ // redirect:"follow" would turn a second 301/302/308 into a silent
249
+ // success and may drop the explicit Cookie on a cross-host hop.
250
+ redirect: "manual",
251
+ signal: AbortSignal.timeout(timeoutMs),
252
+ }).then(async (res) => {
253
+ if (res.status !== 200) {
254
+ throw new Error(`Cursor API returned ${res.status} from ${urlStr}`);
255
+ }
256
+ return res.text();
257
+ }).catch(remapCursorFetchTimeout);
260
258
  }
261
259
 
262
260
  // ── CSV parsing ──
package/src/lib/fs.js CHANGED
@@ -10,8 +10,17 @@ async function writeFileAtomic(filePath, content) {
10
10
  const dir = path.dirname(filePath);
11
11
  await ensureDir(dir);
12
12
  const tmp = `${filePath}.tmp.${Date.now()}`;
13
- await fs.writeFile(tmp, content, { encoding: "utf8" });
14
- await fs.rename(tmp, filePath);
13
+ try {
14
+ await fs.writeFile(tmp, content, { encoding: "utf8" });
15
+ await fs.rename(tmp, filePath);
16
+ } catch (err) {
17
+ try {
18
+ await fs.unlink(tmp);
19
+ } catch {
20
+ /* tmp may not exist if writeFile failed before creating it */
21
+ }
22
+ throw err;
23
+ }
15
24
  }
16
25
 
17
26
  async function readJson(filePath) {
@@ -2621,6 +2621,143 @@ function createLocalApiHandler({ queuePath }) {
2621
2621
  return true;
2622
2622
  }
2623
2623
 
2624
+ // --- outbound proxy preference (manual / system / off) ---
2625
+ // Persisted on config.json next to the queue. GET is unauthenticated so the
2626
+ // settings page can probe availability; POST requires local-auth.
2627
+ if (p === "/functions/tokentracker-proxy-config") {
2628
+ const {
2629
+ normalizeProxyConfig,
2630
+ parseProxyPayload,
2631
+ } = require("./proxy-settings");
2632
+ const {
2633
+ applyUndiciProxyIfNeeded,
2634
+ getLastProxyApplyError,
2635
+ resolveEffectiveProxySource,
2636
+ invalidateSystemProxyCache,
2637
+ } = require("./proxy-env");
2638
+ const { writeFileAtomic, chmod600IfPossible } = require("./fs");
2639
+ const configPath = path.join(path.dirname(qp), "config.json");
2640
+ const readConfig = () => {
2641
+ try {
2642
+ const parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
2643
+ return parsed && typeof parsed === "object" ? parsed : {};
2644
+ } catch {
2645
+ return {};
2646
+ }
2647
+ };
2648
+ const writeConfig = async (next) => {
2649
+ await writeFileAtomic(configPath, JSON.stringify(next, null, 2));
2650
+ await chmod600IfPossible(configPath);
2651
+ };
2652
+ const toResponse = (configObj) => {
2653
+ const normalized = normalizeProxyConfig(configObj?.proxy);
2654
+ const effective = resolveEffectiveProxySource({
2655
+ env: process.env,
2656
+ proxyConfig: configObj?.proxy,
2657
+ });
2658
+ return {
2659
+ mode: normalized.mode,
2660
+ protocol: normalized.protocol,
2661
+ host: normalized.host,
2662
+ port: normalized.port,
2663
+ effective: effective.source,
2664
+ applyError: getLastProxyApplyError(),
2665
+ };
2666
+ };
2667
+ const method = String(req.method || "GET").toUpperCase();
2668
+ if (method === "GET") {
2669
+ json(res, toResponse(readConfig()));
2670
+ return true;
2671
+ }
2672
+ if (method === "POST" || method === "PUT") {
2673
+ if (!isAuthorizedLocalMutation(req)) {
2674
+ json(res, { ok: false, error: "Unauthorized" }, 401);
2675
+ return true;
2676
+ }
2677
+ let body = {};
2678
+ try {
2679
+ body = await readJsonBody(req);
2680
+ } catch {
2681
+ json(res, { ok: false, error: "invalid JSON" }, 400);
2682
+ return true;
2683
+ }
2684
+ const parsed = parseProxyPayload(body);
2685
+ if (!parsed.ok) {
2686
+ json(res, { ok: false, error: parsed.error }, 400);
2687
+ return true;
2688
+ }
2689
+ const current = readConfig();
2690
+ const prevProxy = current.proxy && typeof current.proxy === "object" && !Array.isArray(current.proxy)
2691
+ ? current.proxy
2692
+ : {};
2693
+ current.proxy = {
2694
+ ...prevProxy,
2695
+ mode: parsed.value.mode,
2696
+ protocol: parsed.value.protocol,
2697
+ host: parsed.value.host,
2698
+ port: parsed.value.port,
2699
+ };
2700
+ try {
2701
+ await writeConfig(current);
2702
+ } catch (error) {
2703
+ json(res, { ok: false, error: error?.message || "failed to save proxy config" }, 500);
2704
+ return true;
2705
+ }
2706
+ invalidateSystemProxyCache();
2707
+ const applyResult = applyUndiciProxyIfNeeded({ proxyConfig: current.proxy });
2708
+ json(res, {
2709
+ ok: applyResult?.ok !== false,
2710
+ unprotected: applyResult?.unprotected === true,
2711
+ ...toResponse(current),
2712
+ });
2713
+ return true;
2714
+ }
2715
+ json(res, { error: "Method Not Allowed" }, 405);
2716
+ return true;
2717
+ }
2718
+
2719
+ if (p === "/functions/tokentracker-proxy-test") {
2720
+ const { parseProxyPayload, buildProxyUrl } = require("./proxy-settings");
2721
+ const { runProxyConnectivityTest } = require("./proxy-env");
2722
+ if (String(req.method || "GET").toUpperCase() !== "POST") {
2723
+ json(res, { error: "Method Not Allowed" }, 405);
2724
+ return true;
2725
+ }
2726
+ if (!isAuthorizedLocalMutation(req)) {
2727
+ json(res, { ok: false, error: "Unauthorized" }, 401);
2728
+ return true;
2729
+ }
2730
+ let body = {};
2731
+ try {
2732
+ body = await readJsonBody(req);
2733
+ } catch {
2734
+ json(res, { ok: false, error: "invalid JSON" }, 400);
2735
+ return true;
2736
+ }
2737
+ const parsed = parseProxyPayload({ ...body, mode: "manual" });
2738
+ if (!parsed.ok) {
2739
+ json(res, { ok: false, error: parsed.error }, 400);
2740
+ return true;
2741
+ }
2742
+ const proxyUrl = buildProxyUrl({ ...parsed.value, mode: "manual" });
2743
+ const configPath = path.join(path.dirname(qp), "config.json");
2744
+ let config = {};
2745
+ try {
2746
+ config = JSON.parse(fs.readFileSync(configPath, "utf8")) || {};
2747
+ } catch {
2748
+ config = {};
2749
+ }
2750
+ const runtime = resolveRuntimeConfig({ config, env: process.env });
2751
+ const targetUrl = runtime.dashboardUrl || runtime.baseUrl;
2752
+ const result = await runProxyConnectivityTest({
2753
+ proxyUrl,
2754
+ targetUrl,
2755
+ timeoutMs: 5000,
2756
+ });
2757
+ json(res, result);
2758
+ return true;
2759
+ }
2760
+
2624
2761
  // --- telemetry preference (anonymous analytics opt-out mirror) ---
2625
2762
  // The dashboard's analytics init on localhost / native-app surfaces asks
2626
2763
  // this before sending anything, so TOKENTRACKER_NO_TELEMETRY /