tokentracker-cli 0.82.1 → 0.82.2
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/dashboard/dist/assets/{AchievementBadge-CkmyCQlm.js → AchievementBadge-Dz8bedOl.js} +1 -1
- package/dashboard/dist/assets/{AchievementsPage-B4WDkj4Q.js → AchievementsPage-DYeOB_b5.js} +2 -2
- package/dashboard/dist/assets/{AchievementsSection-CLA812Vd.js → AchievementsSection-ByvPCFbJ.js} +1 -1
- package/dashboard/dist/assets/{BadgeDetailModal-rPrkKVBY.js → BadgeDetailModal-CyfdVn-N.js} +1 -1
- package/dashboard/dist/assets/{Card-D4wKdGxL.js → Card-C-9AAzrt.js} +1 -1
- package/dashboard/dist/assets/{ClawdAnimated-BzfSSyf4.js → ClawdAnimated-85mtsgNG.js} +1 -1
- package/dashboard/dist/assets/CommunityStatsModal-BcY_bUKs.js +1 -0
- package/dashboard/dist/assets/{ConfirmModal-CjKotIxk.js → ConfirmModal-1F-oKSQV.js} +1 -1
- package/dashboard/dist/assets/DashboardPage-ClasFW2f.js +1 -0
- package/dashboard/dist/assets/{DevicePage-Bwrgs9bf.js → DevicePage-Dz6nuSzK.js} +1 -1
- package/dashboard/dist/assets/DialogClose-DoAFJvy1.js +1 -0
- package/dashboard/dist/assets/DialogDescription-DHznQh6m.js +1 -0
- package/dashboard/dist/assets/DialogTitle-DXvcoQzl.js +1 -0
- package/dashboard/dist/assets/{FadeIn-D_skMXdS.js → FadeIn-C8JjNrTO.js} +1 -1
- package/dashboard/dist/assets/{HeaderGithubStar-Er3o6s3b.js → HeaderGithubStar-xAchQaWn.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-CVelrxeM.js → IpCheckPage-BMAw8ydi.js} +1 -1
- package/dashboard/dist/assets/{LandingPage-0YKN8fUt.js → LandingPage-DHQemdKQ.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardAvatar-BTIhp_Eo.js → LeaderboardAvatar-BJULUEGJ.js} +1 -1
- package/dashboard/dist/assets/LeaderboardPage-CwIJH5cz.js +2 -0
- package/dashboard/dist/assets/{LeaderboardProfileModal-HATS5y64.js → LeaderboardProfileModal-B2gmyvsz.js} +4 -4
- package/dashboard/dist/assets/{LeaderboardProfilePage-mnQke6q-.js → LeaderboardProfilePage-CcPzqTDF.js} +3 -3
- package/dashboard/dist/assets/{LimitsPage-OyBAsn4S.js → LimitsPage-BahJx6CU.js} +4 -4
- package/dashboard/dist/assets/{LocalOnlyNotice-Dc1CtgPN.js → LocalOnlyNotice-tbkd3Hic.js} +1 -1
- package/dashboard/dist/assets/{LoginPage-BXJQ9slu.js → LoginPage-DH94HwWF.js} +1 -1
- package/dashboard/dist/assets/PetPage-Bgha7egO.js +1 -0
- package/dashboard/dist/assets/PopoverPopup-kRGuXnS1.js +1 -0
- package/dashboard/dist/assets/{ResetPasswordPage-Bc15F6XB.js → ResetPasswordPage-DDJBMz-A.js} +1 -1
- package/dashboard/dist/assets/{Select-DE6DMSrS.js → Select-DbPDNra5.js} +1 -1
- package/dashboard/dist/assets/SelectItemText-D5FrEa78.js +1 -0
- package/dashboard/dist/assets/{SettingsPage-C_c9z42f.js → SettingsPage-DC3zR4gl.js} +1 -1
- package/dashboard/dist/assets/SkillsPage-CnmtS3f-.js +1 -0
- package/dashboard/dist/assets/TrendMonitor-DixWn0Ji.js +19 -0
- package/dashboard/dist/assets/WidgetsPage-9TJfxcmr.js +1 -0
- package/dashboard/dist/assets/{WrappedPage-wnw_Eknp.js → WrappedPage-DiEg9GtQ.js} +1 -1
- package/dashboard/dist/assets/{__vite-browser-external-CHbEWSwX.js → __vite-browser-external-BQjn-jcg.js} +22 -10
- package/dashboard/dist/assets/__vite-browser-external-DWSIu6d0.css +1 -0
- package/dashboard/dist/assets/{agent-logos-DF4o0Ebm.js → agent-logos-9DPVn5O-.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-B1yj6nxk.js → arrow-up-right-DRXKp-g8.js} +1 -1
- package/dashboard/dist/assets/{check-Dvngccc0.js → check-Bp-cxir1.js} +1 -1
- package/dashboard/dist/assets/{copy-D592E6jX.js → copy-eRWKRZgV.js} +1 -1
- package/dashboard/dist/assets/{download-HHcFs0Ff.js → download-BYBgztAF.js} +1 -1
- package/dashboard/dist/assets/{flame-DCV1yyCQ.js → flame-Dn3di8Oc.js} +1 -1
- package/dashboard/dist/assets/{format-tokens-BN3gD2gw.js → format-tokens-CUBOjHb3.js} +1 -1
- package/dashboard/dist/assets/{icons-DhvcZGBX.js → icons-DGmKoYrQ.js} +1 -1
- package/dashboard/dist/assets/{info-DoeeLajS.js → info-hNxx3heq.js} +1 -1
- package/dashboard/dist/assets/{limits-providers-Db3iK5TG.js → limits-providers-DbDbUJlS.js} +1 -1
- package/dashboard/dist/assets/{link-2-C4RnhbaI.js → link-2-DLpasKnO.js} +1 -1
- package/dashboard/dist/assets/{loader-circle-Cz7QNoOj.js → loader-circle-Dr8XBEwL.js} +1 -1
- package/dashboard/dist/assets/provider-display-BaIvawBI.js +42 -0
- package/dashboard/dist/assets/{terminal-Bwl-eNY6.js → terminal-XHI11uAB.js} +1 -1
- package/dashboard/dist/assets/{trash-2-Ceor8aI2.js → trash-2-B_iuyhQd.js} +1 -1
- package/dashboard/dist/assets/{use-community-stats-C0cLdfhQ.js → use-community-stats-DXyHOYbX.js} +1 -1
- package/dashboard/dist/assets/{use-limits-display-prefs-B-zzSXHf.js → use-limits-display-prefs-BdFQf_ea.js} +1 -1
- package/dashboard/dist/assets/{use-native-settings-CIHg16YZ.js → use-native-settings-Gia9MO-Y.js} +1 -1
- package/dashboard/dist/assets/{use-ordered-list-XWE9dLyL.js → use-ordered-list-m5UXCO_e.js} +1 -1
- package/dashboard/dist/assets/{use-session-efficiency-pref-DUe7bWPZ.js → use-session-efficiency-pref-wCgbduxT.js} +1 -1
- package/dashboard/dist/assets/use-transform-CqiRAEK5.js +1 -0
- package/dashboard/dist/assets/use-usage-limits-B-zrEGe6.js +1 -0
- package/dashboard/dist/assets/{useCurrency-QqSMR7Ba.js → useCurrency-ko1kI_y1.js} +1 -1
- package/dashboard/dist/assets/useScrollLock-C24aFA2G.js +1 -0
- package/dashboard/dist/assets/{useTokenFormat-BLfmNxg5.js → useTokenFormat-CNxtNtIi.js} +1 -1
- package/dashboard/dist/assets/{zap-DjGiVr-_.js → zap-Dqpw2hFM.js} +1 -1
- package/dashboard/dist/index.html +2 -2
- package/dashboard/dist/share.html +2 -2
- package/package.json +1 -1
- package/src/lib/grok-limits.js +203 -25
- package/src/lib/local-api.js +34 -0
- package/src/lib/pet-packages.js +268 -5
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/usage-limits.js +9 -1
- package/dashboard/dist/assets/CommunityStatsModal-BDgJ6Rdv.js +0 -1
- package/dashboard/dist/assets/DashboardPage-C61HPDUg.js +0 -1
- package/dashboard/dist/assets/DialogDescription-D0X0v38O.js +0 -1
- package/dashboard/dist/assets/DialogTitle-qsUpNlJa.js +0 -1
- package/dashboard/dist/assets/LeaderboardPage-bGkIx65M.js +0 -2
- package/dashboard/dist/assets/PetPage-DsjNXLBM.js +0 -1
- package/dashboard/dist/assets/PopoverPopup-BuSDh9-C.js +0 -1
- package/dashboard/dist/assets/SelectItemText-Ca7gf5A0.js +0 -1
- package/dashboard/dist/assets/SkillsPage-iR9Slxl2.js +0 -1
- package/dashboard/dist/assets/TrendMonitor-C-acyVP7.js +0 -19
- package/dashboard/dist/assets/WidgetsPage-DmiqtvrV.js +0 -1
- package/dashboard/dist/assets/__vite-browser-external-Cd947_ni.css +0 -1
- package/dashboard/dist/assets/provider-display-BvvK0p2x.js +0 -42
- package/dashboard/dist/assets/use-transform-DgEhM1tE.js +0 -1
- package/dashboard/dist/assets/use-usage-limits-B_NbJCPh.js +0 -1
- package/dashboard/dist/assets/useScrollLock-DdUQVKDA.js +0 -1
package/src/lib/grok-limits.js
CHANGED
|
@@ -3,6 +3,56 @@ const os = require("node:os");
|
|
|
3
3
|
const path = require("node:path");
|
|
4
4
|
|
|
5
5
|
const DEFAULT_BILLING_BASE_URL = "https://cli-chat-proxy.grok.com";
|
|
6
|
+
const DEFAULT_BILLING_TIMEOUT_MS = 15_000;
|
|
7
|
+
|
|
8
|
+
function grokAuthError() {
|
|
9
|
+
const error = new Error("Not logged in to Grok Build. Run `grok login` in Terminal to authenticate.");
|
|
10
|
+
error.code = "GROK_AUTH_REQUIRED";
|
|
11
|
+
return error;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function grokBillingTimeoutError() {
|
|
15
|
+
const error = new Error("Grok billing request timed out.");
|
|
16
|
+
error.code = "GROK_BILLING_TIMEOUT";
|
|
17
|
+
return error;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function runBeforeBillingDeadline(operation, deadlineMs) {
|
|
21
|
+
const remainingMs = Math.ceil(deadlineMs - Date.now());
|
|
22
|
+
if (remainingMs <= 0) throw grokBillingTimeoutError();
|
|
23
|
+
|
|
24
|
+
const controller = new AbortController();
|
|
25
|
+
let timer;
|
|
26
|
+
const timeout = new Promise((_, reject) => {
|
|
27
|
+
timer = setTimeout(() => {
|
|
28
|
+
const error = grokBillingTimeoutError();
|
|
29
|
+
controller.abort(error);
|
|
30
|
+
reject(error);
|
|
31
|
+
}, remainingMs);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
return await Promise.race([operation(controller.signal), timeout]);
|
|
36
|
+
} finally {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function fetchGrokBillingAttempt(fetchImpl, url, headers, deadlineMs) {
|
|
42
|
+
return runBeforeBillingDeadline(async (signal) => {
|
|
43
|
+
const response = await fetchImpl(url, { method: "GET", headers, signal });
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
try {
|
|
46
|
+
await response.body?.cancel?.();
|
|
47
|
+
} catch (_error) {
|
|
48
|
+
// The status remains authoritative even if discarding the body fails.
|
|
49
|
+
}
|
|
50
|
+
if (response.status === 401 || response.status === 403) throw grokAuthError();
|
|
51
|
+
return { ok: false, status: response.status };
|
|
52
|
+
}
|
|
53
|
+
return { ok: true, body: await response.json() };
|
|
54
|
+
}, deadlineMs);
|
|
55
|
+
}
|
|
6
56
|
|
|
7
57
|
function resolveGrokHome({ home, env = process.env } = {}) {
|
|
8
58
|
if (typeof env.TOKENTRACKER_GROK_HOME === "string" && env.TOKENTRACKER_GROK_HOME.trim()) {
|
|
@@ -58,6 +108,62 @@ function buildWindow({ usedPercent, resetAt }) {
|
|
|
58
108
|
};
|
|
59
109
|
}
|
|
60
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Map Grok's USAGE_PERIOD_TYPE_* enum (or a bare "weekly"/"monthly" string)
|
|
113
|
+
* into a short period key the UI can switch labels on.
|
|
114
|
+
*
|
|
115
|
+
* Only daily / weekly / monthly are recognized end-to-end (dashboard + macOS).
|
|
116
|
+
* Hourly (and other unknown) types return null so UIs fall back to the generic
|
|
117
|
+
* Month label rather than inventing an unsupported period key.
|
|
118
|
+
*/
|
|
119
|
+
function normalizeGrokPeriodType(value) {
|
|
120
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
121
|
+
const upper = value.trim().toUpperCase();
|
|
122
|
+
if (upper.includes("WEEK")) return "weekly";
|
|
123
|
+
if (upper.includes("MONTH")) return "monthly";
|
|
124
|
+
// Prefer explicit DAILY / DAY over accidental "HOUR_OF_DAY" style matches.
|
|
125
|
+
if (upper.includes("DAILY") || /(^|_)DAY($|_)/.test(upper) || upper === "DAY") return "daily";
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Infer daily / weekly / monthly from period length when the API omits
|
|
131
|
+
* `currentPeriod.type` (legacy payloads only expose start/end dates).
|
|
132
|
+
*
|
|
133
|
+
* Windows used by Grok today: ~1d, ~7d, ~calendar month. Boundaries leave
|
|
134
|
+
* gaps for odd lengths (e.g. 12d, 2h) so we do not mislabel them.
|
|
135
|
+
*/
|
|
136
|
+
function inferGrokPeriodTypeFromDates(startIso, endIso) {
|
|
137
|
+
if (!startIso || !endIso) return null;
|
|
138
|
+
const startMs = Date.parse(startIso);
|
|
139
|
+
const endMs = Date.parse(endIso);
|
|
140
|
+
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return null;
|
|
141
|
+
const days = (endMs - startMs) / 86_400_000;
|
|
142
|
+
if (days > 0.5 && days <= 1.5) return "daily";
|
|
143
|
+
if (days > 1.5 && days <= 8) return "weekly";
|
|
144
|
+
if (days >= 25 && days <= 35) return "monthly";
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Sum per-product attribution percentages into the shared pool total.
|
|
150
|
+
* productUsage breaks down the same cap (GrokBuild + GrokChat + …), not
|
|
151
|
+
* independent quotas — a single product entry undercounts the pool.
|
|
152
|
+
*/
|
|
153
|
+
function sumProductUsagePercent(productUsage) {
|
|
154
|
+
if (!Array.isArray(productUsage)) return null;
|
|
155
|
+
let sum = 0;
|
|
156
|
+
let sawAny = false;
|
|
157
|
+
for (const entry of productUsage) {
|
|
158
|
+
if (!entry || typeof entry !== "object") continue;
|
|
159
|
+
const pct = clampPercent(entry.usagePercent);
|
|
160
|
+
if (pct === null) continue;
|
|
161
|
+
sawAny = true;
|
|
162
|
+
sum += pct;
|
|
163
|
+
}
|
|
164
|
+
return sawAny ? clampPercent(sum) : null;
|
|
165
|
+
}
|
|
166
|
+
|
|
61
167
|
function isGrokInstalled({ home, env } = {}) {
|
|
62
168
|
const grokHome = resolveGrokHome({ home, env });
|
|
63
169
|
const authPath = path.join(grokHome, "auth.json");
|
|
@@ -88,25 +194,50 @@ function readGrokAccessToken({ home, env } = {}) {
|
|
|
88
194
|
return key || null;
|
|
89
195
|
}
|
|
90
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Parse either:
|
|
199
|
+
* - Unified billing (`?format=credits`): weekly/monthly period + creditUsagePercent
|
|
200
|
+
* - Legacy monthly credits: monthlyLimit / used + calendar-month billingPeriod*
|
|
201
|
+
*
|
|
202
|
+
* Prefer the unified shape; legacy remains as a fallback for older accounts.
|
|
203
|
+
*/
|
|
91
204
|
function normalizeGrokBillingResponse(body) {
|
|
92
205
|
const config = body?.config;
|
|
93
206
|
if (!config || typeof config !== "object") {
|
|
94
207
|
throw new Error("Could not parse Grok billing: missing config");
|
|
95
208
|
}
|
|
96
209
|
|
|
210
|
+
const currentPeriod =
|
|
211
|
+
config.currentPeriod && typeof config.currentPeriod === "object" ? config.currentPeriod : null;
|
|
212
|
+
|
|
213
|
+
const periodStart =
|
|
214
|
+
grokIsoReset(currentPeriod?.start) || grokIsoReset(config.billingPeriodStart);
|
|
215
|
+
const resetAt = grokIsoReset(currentPeriod?.end) || grokIsoReset(config.billingPeriodEnd);
|
|
216
|
+
|
|
217
|
+
let periodType = normalizeGrokPeriodType(currentPeriod?.type);
|
|
218
|
+
if (!periodType) {
|
|
219
|
+
periodType = inferGrokPeriodTypeFromDates(periodStart, resetAt);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Unified billing: overall pool percent is what gates "You hit your weekly limit".
|
|
223
|
+
// productUsage is attribution across products that share the same pool — sum it.
|
|
224
|
+
let usedPercent = clampPercent(config.creditUsagePercent);
|
|
225
|
+
if (usedPercent === null) {
|
|
226
|
+
usedPercent = sumProductUsagePercent(config.productUsage);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Legacy monthly credit counters (pre-unified / non-format=credits responses).
|
|
97
230
|
const monthlyLimit = grokValNumber(config.monthlyLimit);
|
|
98
231
|
const used = grokValNumber(config.used);
|
|
232
|
+
if (usedPercent === null && Number.isFinite(monthlyLimit) && monthlyLimit > 0 && Number.isFinite(used)) {
|
|
233
|
+
usedPercent = (used / monthlyLimit) * 100;
|
|
234
|
+
if (!periodType) periodType = "monthly";
|
|
235
|
+
}
|
|
236
|
+
|
|
99
237
|
const onDemandCap = grokValNumber(config.onDemandCap);
|
|
100
238
|
const onDemandUsed = grokValNumber(config.onDemandUsed);
|
|
101
|
-
const resetAt = grokIsoReset(config.billingPeriodEnd);
|
|
102
239
|
|
|
103
|
-
|
|
104
|
-
if (Number.isFinite(monthlyLimit) && monthlyLimit > 0 && Number.isFinite(used)) {
|
|
105
|
-
primaryWindow = buildWindow({
|
|
106
|
-
usedPercent: (used / monthlyLimit) * 100,
|
|
107
|
-
resetAt,
|
|
108
|
-
});
|
|
109
|
-
}
|
|
240
|
+
const primaryWindow = buildWindow({ usedPercent, resetAt });
|
|
110
241
|
|
|
111
242
|
let secondaryWindow = null;
|
|
112
243
|
if (Number.isFinite(onDemandCap) && onDemandCap > 0 && Number.isFinite(onDemandUsed)) {
|
|
@@ -121,35 +252,79 @@ function normalizeGrokBillingResponse(body) {
|
|
|
121
252
|
}
|
|
122
253
|
|
|
123
254
|
return {
|
|
255
|
+
period_type: periodType,
|
|
124
256
|
monthly_credits_limit: monthlyLimit,
|
|
125
257
|
monthly_credits_used: used,
|
|
258
|
+
// Effective percent used for the primary bar (API creditUsagePercent, or
|
|
259
|
+
// productUsage / legacy monthly counters when the raw field is absent).
|
|
260
|
+
credit_usage_percent: usedPercent == null ? null : clampPercent(usedPercent),
|
|
126
261
|
on_demand_cap: onDemandCap,
|
|
127
262
|
on_demand_used: onDemandUsed,
|
|
128
|
-
billing_period_start:
|
|
263
|
+
billing_period_start: periodStart,
|
|
129
264
|
primary_window: primaryWindow,
|
|
130
265
|
secondary_window: secondaryWindow,
|
|
131
266
|
};
|
|
132
267
|
}
|
|
133
268
|
|
|
134
|
-
|
|
269
|
+
/**
|
|
270
|
+
* Fetch Grok billing. Prefer `?format=credits` (unified weekly/monthly pool
|
|
271
|
+
* used by the Grok Build TUI). Fall back to the bare `/v1/billing` payload for
|
|
272
|
+
* older accounts that only expose monthlyLimit/used.
|
|
273
|
+
*/
|
|
274
|
+
async function fetchGrokBilling(
|
|
275
|
+
accessToken,
|
|
276
|
+
{ fetchImpl = fetch, baseUrl, env, timeoutMs = DEFAULT_BILLING_TIMEOUT_MS } = {},
|
|
277
|
+
) {
|
|
135
278
|
const root = (baseUrl || resolveGrokBillingBaseUrl(env)).replace(/\/$/, "");
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
279
|
+
const headers = {
|
|
280
|
+
Authorization: `Bearer ${accessToken}`,
|
|
281
|
+
Accept: "application/json",
|
|
282
|
+
};
|
|
283
|
+
const normalizedTimeoutMs =
|
|
284
|
+
Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_BILLING_TIMEOUT_MS;
|
|
285
|
+
const deadlineMs = Date.now() + normalizedTimeoutMs;
|
|
286
|
+
|
|
287
|
+
const creditsUrl = `${root}/v1/billing?format=credits`;
|
|
288
|
+
let creditsFailure = "request failed";
|
|
289
|
+
try {
|
|
290
|
+
const creditsResult = await fetchGrokBillingAttempt(
|
|
291
|
+
fetchImpl,
|
|
292
|
+
creditsUrl,
|
|
293
|
+
headers,
|
|
294
|
+
deadlineMs,
|
|
295
|
+
);
|
|
296
|
+
if (creditsResult.ok) return creditsResult.body;
|
|
297
|
+
creditsFailure = `HTTP ${creditsResult.status}`;
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (error?.code === "GROK_AUTH_REQUIRED") throw error;
|
|
300
|
+
if (error?.code === "GROK_BILLING_TIMEOUT") throw error;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Non-auth HTTP, network, or response-decoding failure → try the legacy
|
|
304
|
+
// shape once, while sharing the original request deadline.
|
|
305
|
+
let legacyResult;
|
|
306
|
+
try {
|
|
307
|
+
legacyResult = await fetchGrokBillingAttempt(
|
|
308
|
+
fetchImpl,
|
|
309
|
+
`${root}/v1/billing`,
|
|
310
|
+
headers,
|
|
311
|
+
deadlineMs,
|
|
312
|
+
);
|
|
313
|
+
} catch (error) {
|
|
314
|
+
if (error?.code === "GROK_AUTH_REQUIRED" || error?.code === "GROK_BILLING_TIMEOUT") throw error;
|
|
315
|
+
throw new Error(`Grok billing request failed (format=credits: ${creditsFailure})`, {
|
|
316
|
+
cause: error,
|
|
317
|
+
});
|
|
145
318
|
}
|
|
146
|
-
if (!
|
|
147
|
-
throw new Error(
|
|
319
|
+
if (!legacyResult.ok) {
|
|
320
|
+
throw new Error(
|
|
321
|
+
`Grok billing API returned ${legacyResult.status} (format=credits: ${creditsFailure})`,
|
|
322
|
+
);
|
|
148
323
|
}
|
|
149
|
-
return
|
|
324
|
+
return legacyResult.body;
|
|
150
325
|
}
|
|
151
326
|
|
|
152
|
-
async function fetchGrokLimits({ home, env, fetchImpl = fetch } = {}) {
|
|
327
|
+
async function fetchGrokLimits({ home, env, fetchImpl = fetch, timeoutMs } = {}) {
|
|
153
328
|
if (!isGrokInstalled({ home, env })) {
|
|
154
329
|
return { configured: false };
|
|
155
330
|
}
|
|
@@ -158,7 +333,7 @@ async function fetchGrokLimits({ home, env, fetchImpl = fetch } = {}) {
|
|
|
158
333
|
return { configured: false };
|
|
159
334
|
}
|
|
160
335
|
try {
|
|
161
|
-
const body = await fetchGrokBilling(accessToken, { fetchImpl, env });
|
|
336
|
+
const body = await fetchGrokBilling(accessToken, { fetchImpl, env, timeoutMs });
|
|
162
337
|
return {
|
|
163
338
|
configured: true,
|
|
164
339
|
error: null,
|
|
@@ -178,7 +353,10 @@ module.exports = {
|
|
|
178
353
|
isGrokInstalled,
|
|
179
354
|
loadGrokAuthEntry,
|
|
180
355
|
readGrokAccessToken,
|
|
356
|
+
normalizeGrokPeriodType,
|
|
357
|
+
inferGrokPeriodTypeFromDates,
|
|
358
|
+
sumProductUsagePercent,
|
|
181
359
|
normalizeGrokBillingResponse,
|
|
182
360
|
fetchGrokBilling,
|
|
183
361
|
fetchGrokLimits,
|
|
184
|
-
};
|
|
362
|
+
};
|
package/src/lib/local-api.js
CHANGED
|
@@ -1400,6 +1400,31 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
1400
1400
|
return true;
|
|
1401
1401
|
}
|
|
1402
1402
|
|
|
1403
|
+
// Preview asset for a Codex pet that hasn't been imported yet (served from
|
|
1404
|
+
// ~/.codex/pets or straight out of the Codex.app bundle).
|
|
1405
|
+
const codexPetAssetMatch = p.match(/^\/api\/pets\/codex\/([a-z0-9-]+)\/spritesheet\.webp$/);
|
|
1406
|
+
if (codexPetAssetMatch) {
|
|
1407
|
+
const method = String(req.method || "GET").toUpperCase();
|
|
1408
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
1409
|
+
json(res, { error: "Method Not Allowed" }, 405);
|
|
1410
|
+
return true;
|
|
1411
|
+
}
|
|
1412
|
+
const asset = require("./pet-packages").readCodexImportableAsset(codexPetAssetMatch[1]);
|
|
1413
|
+
if (!asset) {
|
|
1414
|
+
json(res, { error: "Pet not found" }, 404);
|
|
1415
|
+
return true;
|
|
1416
|
+
}
|
|
1417
|
+
res.writeHead(200, {
|
|
1418
|
+
"Content-Type": "image/webp",
|
|
1419
|
+
"Content-Length": asset.buffer.length,
|
|
1420
|
+
"Cache-Control": "no-cache",
|
|
1421
|
+
"X-Content-Type-Options": "nosniff",
|
|
1422
|
+
});
|
|
1423
|
+
if (method === "HEAD") res.end();
|
|
1424
|
+
else res.end(asset.buffer);
|
|
1425
|
+
return true;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1403
1428
|
if (p === "/api/pets/import") {
|
|
1404
1429
|
if (String(req.method || "GET").toUpperCase() !== "POST") {
|
|
1405
1430
|
json(res, { ok: false, error: "Method Not Allowed" }, 405);
|
|
@@ -2498,6 +2523,11 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
2498
2523
|
const pets = require("./pet-packages");
|
|
2499
2524
|
try {
|
|
2500
2525
|
if (method === "GET") {
|
|
2526
|
+
if (url.searchParams.get("scope") === "codex") {
|
|
2527
|
+
const importable = pets.listCodexImportablePets();
|
|
2528
|
+
json(res, { importable, codexDetected: importable.length > 0 });
|
|
2529
|
+
return true;
|
|
2530
|
+
}
|
|
2501
2531
|
json(res, { pets: pets.listInstalledPets() });
|
|
2502
2532
|
return true;
|
|
2503
2533
|
}
|
|
@@ -2515,6 +2545,10 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
2515
2545
|
json(res, { ok: true, ...pets.removeInstalledPet(body.id) });
|
|
2516
2546
|
return true;
|
|
2517
2547
|
}
|
|
2548
|
+
if (body?.action === "import_codex") {
|
|
2549
|
+
json(res, { ok: true, ...pets.importFromCodex(body.ids) });
|
|
2550
|
+
return true;
|
|
2551
|
+
}
|
|
2518
2552
|
json(res, { ok: false, error: "Unknown pets action" }, 400);
|
|
2519
2553
|
return true;
|
|
2520
2554
|
}
|
package/src/lib/pet-packages.js
CHANGED
|
@@ -16,10 +16,22 @@ const BUILTIN_IDS = new Set(["clawd", "sprout", "byte", "ember"]);
|
|
|
16
16
|
const KIND_RE = /^[a-z][a-z0-9-]{0,39}$/;
|
|
17
17
|
const PET_ID_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
18
18
|
const petDirectoryCache = new Map();
|
|
19
|
+
let codexAsarCache = null;
|
|
20
|
+
|
|
21
|
+
// TokenTracker keeps its own pets under ~/.tokentracker/pets and never writes to the
|
|
22
|
+
// Codex app: URL/zip imports stay here, and Codex is only ever read from (reverse
|
|
23
|
+
// import). `~/.codex/pets` is reachable purely as an import source.
|
|
24
|
+
const MIGRATION_MARKER = ".migrated-v1";
|
|
19
25
|
|
|
20
26
|
function resolvePetsDir() {
|
|
21
27
|
return path.resolve(
|
|
22
|
-
process.env.TOKENTRACKER_PETS_DIR || path.join(os.homedir(), ".
|
|
28
|
+
process.env.TOKENTRACKER_PETS_DIR || path.join(os.homedir(), ".tokentracker", "pets"),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function resolveCodexPetsDir() {
|
|
33
|
+
return path.resolve(
|
|
34
|
+
process.env.TOKENTRACKER_CODEX_PETS_DIR || path.join(os.homedir(), ".codex", "pets"),
|
|
23
35
|
);
|
|
24
36
|
}
|
|
25
37
|
|
|
@@ -218,6 +230,12 @@ function loadPetDirectory(directory, expectedId = null) {
|
|
|
218
230
|
if (!manifestStat.isFile() || manifestStat.isSymbolicLink() || !spriteStat.isFile() || spriteStat.isSymbolicLink()) {
|
|
219
231
|
throw new Error("Pet assets must be regular files");
|
|
220
232
|
}
|
|
233
|
+
if (manifestStat.size <= 0 || manifestStat.size > MAX_MANIFEST_BYTES) {
|
|
234
|
+
throw new Error("pet.json is missing or too large");
|
|
235
|
+
}
|
|
236
|
+
if (spriteStat.size <= 0 || spriteStat.size > MAX_SPRITESHEET_BYTES) {
|
|
237
|
+
throw new Error("spritesheet.webp is missing or too large");
|
|
238
|
+
}
|
|
221
239
|
const cacheKey = [
|
|
222
240
|
manifestStat.mtimeMs,
|
|
223
241
|
manifestStat.size,
|
|
@@ -240,6 +258,7 @@ function loadPetDirectory(directory, expectedId = null) {
|
|
|
240
258
|
}
|
|
241
259
|
|
|
242
260
|
function listInstalledPets() {
|
|
261
|
+
migrateLegacyCodexPets();
|
|
243
262
|
const root = resolvePetsDir();
|
|
244
263
|
let names = [];
|
|
245
264
|
try { names = fs.readdirSync(root); } catch (error) {
|
|
@@ -259,9 +278,9 @@ function listInstalledPets() {
|
|
|
259
278
|
return pets.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
260
279
|
}
|
|
261
280
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
281
|
+
// Atomically writes one validated pet into `root` (staged dir → rename), optionally
|
|
282
|
+
// dropping `markerName` inside the pet directory. Returns the loaded asset info.
|
|
283
|
+
function writePetIntoDir(root, manifest, spritesheetBuffer, markerName = null) {
|
|
265
284
|
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
266
285
|
const stage = path.join(root, `.tokentracker-pet-${process.pid}-${crypto.randomBytes(6).toString("hex")}`);
|
|
267
286
|
const destination = path.join(root, manifest.id);
|
|
@@ -277,6 +296,7 @@ function installValidatedPackage(manifestBuffer, spritesheetBuffer) {
|
|
|
277
296
|
kind: manifest.kind,
|
|
278
297
|
}, null, 2)}\n`, { mode: 0o600 });
|
|
279
298
|
fs.writeFileSync(path.join(stage, PET_SPRITESHEET), spritesheetBuffer, { mode: 0o600 });
|
|
299
|
+
if (markerName) fs.writeFileSync(path.join(stage, markerName), "", { mode: 0o600 });
|
|
280
300
|
loadPetDirectory(stage, manifest.id);
|
|
281
301
|
petDirectoryCache.delete(destination);
|
|
282
302
|
if (fs.existsSync(destination)) fs.renameSync(destination, backup);
|
|
@@ -289,7 +309,12 @@ function installValidatedPackage(manifestBuffer, spritesheetBuffer) {
|
|
|
289
309
|
throw error;
|
|
290
310
|
}
|
|
291
311
|
try { fs.rmSync(backup, { recursive: true, force: true }); } catch {}
|
|
292
|
-
|
|
312
|
+
return loadPetDirectory(destination, manifest.id);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function installValidatedPackage(manifestBuffer, spritesheetBuffer) {
|
|
316
|
+
const { manifest } = validatePackageFiles(manifestBuffer, spritesheetBuffer);
|
|
317
|
+
const installed = writePetIntoDir(resolvePetsDir(), manifest, spritesheetBuffer);
|
|
293
318
|
return publicPet(manifest, { assetVersion: installed.assetVersion });
|
|
294
319
|
}
|
|
295
320
|
|
|
@@ -353,20 +378,258 @@ function removeInstalledPet(id) {
|
|
|
353
378
|
}
|
|
354
379
|
|
|
355
380
|
function resolvePetAsset(id) {
|
|
381
|
+
// Native hosts can ask for the persisted custom pet before the dashboard has
|
|
382
|
+
// listed the catalog. Migrate first so an upgrade does not turn that first
|
|
383
|
+
// request into a 404 and reset the user's selection.
|
|
384
|
+
migrateLegacyCodexPets();
|
|
356
385
|
const normalized = String(id || "").trim().toLowerCase();
|
|
357
386
|
if (!PET_ID_RE.test(normalized) || BUILTIN_IDS.has(normalized)) return null;
|
|
358
387
|
try { return loadPetDirectory(path.join(resolvePetsDir(), normalized), normalized); } catch { return null; }
|
|
359
388
|
}
|
|
360
389
|
|
|
390
|
+
// --- One-time migration: copy legacy pets out of the shared Codex dir into our own ---
|
|
391
|
+
|
|
392
|
+
function migrateLegacyCodexPets() {
|
|
393
|
+
const root = resolvePetsDir();
|
|
394
|
+
const marker = path.join(root, MIGRATION_MARKER);
|
|
395
|
+
try { if (fs.existsSync(marker)) return { migrated: 0 }; } catch { return { migrated: 0 }; }
|
|
396
|
+
try { fs.mkdirSync(root, { recursive: true, mode: 0o700 }); } catch { return { migrated: 0 }; }
|
|
397
|
+
let migrated = 0;
|
|
398
|
+
let names = [];
|
|
399
|
+
let scanComplete = false;
|
|
400
|
+
try {
|
|
401
|
+
names = fs.readdirSync(resolveCodexPetsDir());
|
|
402
|
+
scanComplete = true;
|
|
403
|
+
} catch (error) {
|
|
404
|
+
// A missing legacy directory is a completed no-op. Permission and I/O
|
|
405
|
+
// failures are not: leave the marker absent so the next launch can retry.
|
|
406
|
+
if (error?.code === "ENOENT") scanComplete = true;
|
|
407
|
+
}
|
|
408
|
+
let copyFailed = false;
|
|
409
|
+
for (const name of names) {
|
|
410
|
+
if (!PET_ID_RE.test(name) || BUILTIN_IDS.has(name)) continue;
|
|
411
|
+
if (fs.existsSync(path.join(root, name))) continue; // never clobber an existing local pet
|
|
412
|
+
let loaded;
|
|
413
|
+
try {
|
|
414
|
+
loaded = loadPetDirectory(path.join(resolveCodexPetsDir(), name), name);
|
|
415
|
+
} catch {
|
|
416
|
+
// Invalid legacy packages are ignored just as they were by the old catalog.
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
writePetIntoDir(root, loaded.manifest, fs.readFileSync(loaded.spritesheetPath));
|
|
421
|
+
migrated += 1;
|
|
422
|
+
} catch {
|
|
423
|
+
copyFailed = true;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (scanComplete && !copyFailed) {
|
|
427
|
+
try { fs.writeFileSync(marker, `${new Date().toISOString()}\n`, { mode: 0o600 }); } catch {}
|
|
428
|
+
}
|
|
429
|
+
return { migrated };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// --- Reverse import: pull Codex's own pets into TokenTracker ---
|
|
433
|
+
|
|
434
|
+
// Resolved per call (not frozen at load) so an env override applies at runtime.
|
|
435
|
+
function resolveCodexAsarPath() {
|
|
436
|
+
return process.env.TOKENTRACKER_CODEX_ASAR
|
|
437
|
+
|| "/Applications/Codex.app/Contents/Resources/app.asar";
|
|
438
|
+
}
|
|
439
|
+
// Codex ships these native companions bundled inside its app; the display names are
|
|
440
|
+
// stable brand labels. Anything not listed falls back to a capitalized id.
|
|
441
|
+
const CODEX_BUILTIN_NAMES = {
|
|
442
|
+
rocky: "Rocky", seedy: "Seedy", hoots: "Hoots", dewey: "Dewey", fireball: "Fireball",
|
|
443
|
+
stacky: "Stacky", codex: "Codex", bsod: "BSOD", "null-signal": "Null Signal",
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
// Parses the asar header (Chromium Pickle) and returns the file tree plus the byte
|
|
447
|
+
// offset where file contents begin. Pure Node, no dependency — asar's layout is stable.
|
|
448
|
+
function readAsarHeader(fd) {
|
|
449
|
+
const head = Buffer.alloc(16);
|
|
450
|
+
if (fs.readSync(fd, head, 0, 16, 0) < 16) throw new Error("asar too small");
|
|
451
|
+
const headerPayloadSize = head.readUInt32LE(4);
|
|
452
|
+
const jsonStrLen = head.readUInt32LE(12);
|
|
453
|
+
if (jsonStrLen <= 0 || jsonStrLen > 64 * 1024 * 1024) throw new Error("asar header size out of range");
|
|
454
|
+
const jsonBuf = Buffer.alloc(jsonStrLen);
|
|
455
|
+
fs.readSync(fd, jsonBuf, 0, jsonStrLen, 16);
|
|
456
|
+
const header = JSON.parse(jsonBuf.toString("utf8"));
|
|
457
|
+
const base = 8 + headerPayloadSize;
|
|
458
|
+
return { header, baseOffset: base % 4 ? base + 4 - (base % 4) : base };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Reads Codex's bundled companion spritesheets out of the app's asar. Returns
|
|
462
|
+
// [{ id, displayName, description, spriteVersionNumber, buffer }]. Degrades to [] if
|
|
463
|
+
// Codex isn't installed or the bundle can't be read.
|
|
464
|
+
function readCodexBuiltinPets(asarPath = resolveCodexAsarPath()) {
|
|
465
|
+
const resolvedAsarPath = path.resolve(asarPath);
|
|
466
|
+
let asarStat;
|
|
467
|
+
try { asarStat = fs.statSync(resolvedAsarPath); } catch { return []; }
|
|
468
|
+
const cacheKey = `${resolvedAsarPath}:${asarStat.mtimeMs}:${asarStat.size}`;
|
|
469
|
+
if (codexAsarCache?.key === cacheKey) return codexAsarCache.pets;
|
|
470
|
+
|
|
471
|
+
let fd;
|
|
472
|
+
try { fd = fs.openSync(resolvedAsarPath, "r"); } catch { return []; }
|
|
473
|
+
try {
|
|
474
|
+
const { header, baseOffset } = readAsarHeader(fd);
|
|
475
|
+
const entries = [];
|
|
476
|
+
(function walk(node) {
|
|
477
|
+
for (const [name, value] of Object.entries(node?.files || {})) {
|
|
478
|
+
if (value?.files) walk(value);
|
|
479
|
+
else if (/-spritesheet-.*\.webp$/i.test(name) && value?.offset != null) entries.push([name, value]);
|
|
480
|
+
}
|
|
481
|
+
})(header);
|
|
482
|
+
const byId = new Map();
|
|
483
|
+
for (const [name, entry] of entries) {
|
|
484
|
+
const id = name.replace(/-spritesheet-.*$/i, "").toLowerCase();
|
|
485
|
+
if (!PET_ID_RE.test(id) || BUILTIN_IDS.has(id) || byId.has(id)) continue;
|
|
486
|
+
const size = Number(entry.size);
|
|
487
|
+
if (!(size > 0) || size > MAX_SPRITESHEET_BYTES) continue;
|
|
488
|
+
const buffer = Buffer.alloc(size);
|
|
489
|
+
fs.readSync(fd, buffer, 0, size, baseOffset + Number(entry.offset));
|
|
490
|
+
let dimensions;
|
|
491
|
+
try { dimensions = readWebpDimensions(buffer); } catch { continue; }
|
|
492
|
+
const spriteVersionNumber = dimensions.width === 1536 && dimensions.height === 2288 ? 2
|
|
493
|
+
: dimensions.width === 1536 && dimensions.height === 1872 ? 1 : 0;
|
|
494
|
+
if (!spriteVersionNumber) continue;
|
|
495
|
+
byId.set(id, {
|
|
496
|
+
id,
|
|
497
|
+
displayName: CODEX_BUILTIN_NAMES[id] || (id.charAt(0).toUpperCase() + id.slice(1)),
|
|
498
|
+
description: "Built-in Codex companion.",
|
|
499
|
+
spriteVersionNumber,
|
|
500
|
+
buffer,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
const pets = [...byId.values()];
|
|
504
|
+
codexAsarCache = { key: cacheKey, pets };
|
|
505
|
+
return pets;
|
|
506
|
+
} catch {
|
|
507
|
+
codexAsarCache = { key: cacheKey, pets: [] };
|
|
508
|
+
return [];
|
|
509
|
+
}
|
|
510
|
+
finally { try { fs.closeSync(fd); } catch {} }
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Preview URL for an importable Codex pet (served before it lands in our own dir).
|
|
514
|
+
function codexAssetUrl(id, version) {
|
|
515
|
+
return `/api/pets/codex/${encodeURIComponent(id)}/${PET_SPRITESHEET}${version ? `?v=${encodeURIComponent(version)}` : ""}`;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Lists Codex pets that could be imported but aren't in TokenTracker yet: valid
|
|
519
|
+
// packages in ~/.codex/pets plus Codex's app-bundled companions. Each carries an
|
|
520
|
+
// assetUrl so the picker can preview sprites.
|
|
521
|
+
function listCodexImportablePets() {
|
|
522
|
+
const alreadyHere = new Set(listInstalledPets().map((pet) => pet.id));
|
|
523
|
+
const result = new Map();
|
|
524
|
+
const codexRoot = resolveCodexPetsDir();
|
|
525
|
+
let names = [];
|
|
526
|
+
try { names = fs.readdirSync(codexRoot); } catch { names = []; }
|
|
527
|
+
for (const name of names) {
|
|
528
|
+
if (!PET_ID_RE.test(name) || BUILTIN_IDS.has(name) || alreadyHere.has(name)) continue;
|
|
529
|
+
try {
|
|
530
|
+
const loaded = loadPetDirectory(path.join(codexRoot, name), name);
|
|
531
|
+
result.set(name, {
|
|
532
|
+
...loaded.manifest,
|
|
533
|
+
custom: true,
|
|
534
|
+
assetUrl: codexAssetUrl(name, loaded.assetVersion),
|
|
535
|
+
assetVersion: loaded.assetVersion,
|
|
536
|
+
source: "codex-dir",
|
|
537
|
+
});
|
|
538
|
+
} catch {}
|
|
539
|
+
}
|
|
540
|
+
for (const pet of readCodexBuiltinPets()) {
|
|
541
|
+
if (alreadyHere.has(pet.id) || result.has(pet.id)) continue;
|
|
542
|
+
result.set(pet.id, {
|
|
543
|
+
id: pet.id,
|
|
544
|
+
displayName: pet.displayName,
|
|
545
|
+
description: pet.description,
|
|
546
|
+
spritesheetPath: PET_SPRITESHEET,
|
|
547
|
+
spriteVersionNumber: pet.spriteVersionNumber,
|
|
548
|
+
custom: true,
|
|
549
|
+
assetUrl: codexAssetUrl(pet.id, `app${pet.spriteVersionNumber}`),
|
|
550
|
+
source: "codex-app",
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
return [...result.values()].sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Returns { buffer, contentLength } for an importable Codex pet's spritesheet — from
|
|
557
|
+
// ~/.codex/pets first, then the app bundle. Powers the picker preview route.
|
|
558
|
+
function readCodexImportableAsset(id) {
|
|
559
|
+
const normalized = String(id || "").trim().toLowerCase();
|
|
560
|
+
if (!PET_ID_RE.test(normalized) || BUILTIN_IDS.has(normalized)) return null;
|
|
561
|
+
const codexDir = path.join(resolveCodexPetsDir(), normalized);
|
|
562
|
+
try {
|
|
563
|
+
const loaded = loadPetDirectory(codexDir, normalized);
|
|
564
|
+
return { buffer: fs.readFileSync(loaded.spritesheetPath) };
|
|
565
|
+
} catch {}
|
|
566
|
+
for (const pet of readCodexBuiltinPets()) {
|
|
567
|
+
if (pet.id === normalized) return { buffer: pet.buffer };
|
|
568
|
+
}
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function importFromCodex(ids) {
|
|
573
|
+
const requested = new Set(
|
|
574
|
+
(Array.isArray(ids) ? ids : [])
|
|
575
|
+
.map((id) => String(id || "").trim().toLowerCase())
|
|
576
|
+
.filter((id) => PET_ID_RE.test(id) && !BUILTIN_IDS.has(id)),
|
|
577
|
+
);
|
|
578
|
+
if (requested.size === 0) throw new Error("No valid Codex pet ids to import");
|
|
579
|
+
const root = resolvePetsDir();
|
|
580
|
+
// The listing excludes already-installed ids, but enforce the same rule at
|
|
581
|
+
// the write boundary so a handcrafted local API request cannot overwrite a
|
|
582
|
+
// TokenTracker-owned pet with Codex's copy.
|
|
583
|
+
for (const id of [...requested]) {
|
|
584
|
+
if (fs.existsSync(path.join(root, id))) requested.delete(id);
|
|
585
|
+
}
|
|
586
|
+
if (requested.size === 0) throw new Error("Requested Codex pets are already installed");
|
|
587
|
+
const codexRoot = resolveCodexPetsDir();
|
|
588
|
+
const imported = [];
|
|
589
|
+
for (const id of [...requested]) {
|
|
590
|
+
try {
|
|
591
|
+
const loaded = loadPetDirectory(path.join(codexRoot, id), id);
|
|
592
|
+
const installed = writePetIntoDir(root, loaded.manifest, fs.readFileSync(loaded.spritesheetPath));
|
|
593
|
+
imported.push(publicPet(loaded.manifest, { assetVersion: installed.assetVersion }));
|
|
594
|
+
requested.delete(id);
|
|
595
|
+
} catch {}
|
|
596
|
+
}
|
|
597
|
+
if (requested.size > 0) {
|
|
598
|
+
for (const pet of readCodexBuiltinPets()) {
|
|
599
|
+
if (!requested.has(pet.id)) continue;
|
|
600
|
+
const manifest = normalizeManifest({
|
|
601
|
+
id: pet.id,
|
|
602
|
+
displayName: pet.displayName,
|
|
603
|
+
description: pet.description,
|
|
604
|
+
spritesheetPath: PET_SPRITESHEET,
|
|
605
|
+
...(pet.spriteVersionNumber === 2 ? { spriteVersionNumber: 2 } : {}),
|
|
606
|
+
});
|
|
607
|
+
try {
|
|
608
|
+
const installed = writePetIntoDir(root, manifest, pet.buffer);
|
|
609
|
+
imported.push(publicPet(manifest, { assetVersion: installed.assetVersion }));
|
|
610
|
+
requested.delete(pet.id);
|
|
611
|
+
} catch {}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
if (imported.length === 0) throw new Error("None of the requested Codex pets could be imported");
|
|
615
|
+
return { imported };
|
|
616
|
+
}
|
|
617
|
+
|
|
361
618
|
module.exports = {
|
|
362
619
|
MAX_PACKAGE_BYTES,
|
|
620
|
+
importFromCodex,
|
|
363
621
|
importPetZip,
|
|
364
622
|
installFromCodexPets,
|
|
623
|
+
listCodexImportablePets,
|
|
365
624
|
listInstalledPets,
|
|
625
|
+
migrateLegacyCodexPets,
|
|
366
626
|
normalizeManifest,
|
|
367
627
|
petIdFromCodexPetsUrl,
|
|
628
|
+
readCodexBuiltinPets,
|
|
629
|
+
readCodexImportableAsset,
|
|
368
630
|
readWebpDimensions,
|
|
369
631
|
removeInstalledPet,
|
|
632
|
+
resolveCodexPetsDir,
|
|
370
633
|
resolvePetAsset,
|
|
371
634
|
resolvePetsDir,
|
|
372
635
|
validatePackageFiles,
|