tokentracker-cli 0.76.2 → 0.77.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 (78) hide show
  1. package/README.de.md +10 -0
  2. package/README.ja.md +9 -0
  3. package/README.ko.md +9 -0
  4. package/README.md +10 -0
  5. package/README.zh-CN.md +9 -0
  6. package/dashboard/dist/assets/{ActivityHeatmap-CPGN-WN1.js → ActivityHeatmap-DTZh551Z.js} +1 -1
  7. package/dashboard/dist/assets/{Card-Bw438EXT.js → Card-DP2HT77t.js} +1 -1
  8. package/dashboard/dist/assets/ClawdAnimated-ScYRJSG0.js +1 -0
  9. package/dashboard/dist/assets/{CommunityStatsModal--AuOLfq1.js → CommunityStatsModal-CU46JMkg.js} +1 -1
  10. package/dashboard/dist/assets/{ConfirmModal-DXV9mzMw.js → ConfirmModal-BYvz0dzg.js} +1 -1
  11. package/dashboard/dist/assets/{Controls-D0h-lPHC.js → Controls-DPElSq0S.js} +1 -1
  12. package/dashboard/dist/assets/DashboardPage-CN0buIso.js +19 -0
  13. package/dashboard/dist/assets/{DevicePage-CMa1l2sC.js → DevicePage-BL9ew9-m.js} +1 -1
  14. package/dashboard/dist/assets/{DialogClose-DBKCvZPm.js → DialogClose-cqXrG903.js} +1 -1
  15. package/dashboard/dist/assets/DialogDescription-Bo0iWtYz.js +1 -0
  16. package/dashboard/dist/assets/{DialogTitle-CuhKU10w.js → DialogTitle-Oqnn_dIL.js} +1 -1
  17. package/dashboard/dist/assets/{FadeIn-BoYq0Lpr.js → FadeIn-CFLwnHqt.js} +1 -1
  18. package/dashboard/dist/assets/{HeaderGithubStar-DD58NKiQ.js → HeaderGithubStar-B397ELF4.js} +1 -1
  19. package/dashboard/dist/assets/{IpCheckPage-Pw-_Uvsa.js → IpCheckPage-CamsHe6o.js} +1 -1
  20. package/dashboard/dist/assets/{LandingPage-D2t79SLx.js → LandingPage-D4ygTHwO.js} +1 -1
  21. package/dashboard/dist/assets/{LeaderboardAvatar-D_fl_xuD.js → LeaderboardAvatar-B39rNGCV.js} +1 -1
  22. package/dashboard/dist/assets/LeaderboardPage-BFUOxX6P.js +2 -0
  23. package/dashboard/dist/assets/{LeaderboardProfileModal-DSMTxieN.js → LeaderboardProfileModal-BIe6gBNu.js} +3 -3
  24. package/dashboard/dist/assets/{LeaderboardProfilePage-neatevKp.js → LeaderboardProfilePage-DeVBEYtg.js} +2 -2
  25. package/dashboard/dist/assets/{LimitsPage-DqaIA0Es.js → LimitsPage-6I5EU7qe.js} +1 -1
  26. package/dashboard/dist/assets/{LocalOnlyNotice-Ds7yPxS1.js → LocalOnlyNotice-AyYKZloH.js} +1 -1
  27. package/dashboard/dist/assets/{LoginPage-7dsJbTEb.js → LoginPage-D8smF1gU.js} +1 -1
  28. package/dashboard/dist/assets/PetPage-BlMYvJn2.js +1 -0
  29. package/dashboard/dist/assets/PopoverPopup-DRWhz-bY.js +1 -0
  30. package/dashboard/dist/assets/{ResetPasswordPage-Czaz9zzN.js → ResetPasswordPage-CzSqJfuA.js} +1 -1
  31. package/dashboard/dist/assets/{Select-Dk9uTtUI.js → Select-Cb8n7kQO.js} +1 -1
  32. package/dashboard/dist/assets/{SelectItemText-Dontd_dr.js → SelectItemText-DEYadcIK.js} +1 -1
  33. package/dashboard/dist/assets/{SettingsPage-CunCphUc.js → SettingsPage-DlHXcFdJ.js} +1 -1
  34. package/dashboard/dist/assets/SkillsPage--Kcv1pxG.js +1 -0
  35. package/dashboard/dist/assets/{WidgetsPage-CpAPhR3_.js → WidgetsPage-DwtJ9CKU.js} +1 -1
  36. package/dashboard/dist/assets/{WrappedPage-DikP22Sw.js → WrappedPage-BuP9KXqF.js} +1 -1
  37. package/dashboard/dist/assets/{agent-logos-D8OYqlhF.js → agent-logos-DbwDXgg1.js} +1 -1
  38. package/dashboard/dist/assets/{arrow-up-right-DsLWfytD.js → arrow-up-right-cFMBPEVS.js} +1 -1
  39. package/dashboard/dist/assets/check-t_ltRp2A.js +1 -0
  40. package/dashboard/dist/assets/{copy-BxHuZgA1.js → copy-DYx8X3ZO.js} +1 -1
  41. package/dashboard/dist/assets/{download-tXehSjsQ.js → download-B-hXZWKj.js} +1 -1
  42. package/dashboard/dist/assets/{format-tokens-ab57-P2J.js → format-tokens-CngV51wN.js} +1 -1
  43. package/dashboard/dist/assets/{info-Xcqww53Y.js → info-qVF2hV50.js} +1 -1
  44. package/dashboard/dist/assets/link-2-DR-yGZFS.js +1 -0
  45. package/dashboard/dist/assets/loader-circle-0k9uueb5.js +1 -0
  46. package/dashboard/dist/assets/main-B8lT0e78.css +1 -0
  47. package/dashboard/dist/assets/{main-BQdH4mOs.js → main-CU04bNYH.js} +15 -4
  48. package/dashboard/dist/assets/trash-2-DpxBaV_1.js +1 -0
  49. package/dashboard/dist/assets/{use-community-stats-DFBJP4bf.js → use-community-stats-Cbij-V8j.js} +1 -1
  50. package/dashboard/dist/assets/{use-limits-display-prefs-gmXXzoWe.js → use-limits-display-prefs-q0TuryM0.js} +1 -1
  51. package/dashboard/dist/assets/{use-native-settings-BNv4MqR-.js → use-native-settings-BCzogs1L.js} +1 -1
  52. package/dashboard/dist/assets/{use-ordered-list-Dl82Utll.js → use-ordered-list-CH-J7c04.js} +1 -1
  53. package/dashboard/dist/assets/{use-quality-per-dollar-pref-XOF93N4q.js → use-quality-per-dollar-pref-DKLmkwxe.js} +1 -1
  54. package/dashboard/dist/assets/{use-usage-limits-YUhemFAp.js → use-usage-limits-DrmF6I_t.js} +1 -1
  55. package/dashboard/dist/assets/{useCurrency-B6KHWgVk.js → useCurrency-DwygZqrs.js} +1 -1
  56. package/dashboard/dist/assets/{useScrollLock-BEZTkDaR.js → useScrollLock-DVCKuErg.js} +1 -1
  57. package/dashboard/dist/index.html +2 -2
  58. package/dashboard/dist/pets/byte/spritesheet.webp +0 -0
  59. package/dashboard/dist/pets/ember/spritesheet.webp +0 -0
  60. package/dashboard/dist/pets/sprout/spritesheet.webp +0 -0
  61. package/dashboard/dist/share.html +2 -2
  62. package/package.json +3 -2
  63. package/src/commands/status.js +44 -7
  64. package/src/commands/sync.js +83 -108
  65. package/src/lib/install-resolver.js +1 -1
  66. package/src/lib/local-api.js +97 -0
  67. package/src/lib/pet-packages.js +373 -0
  68. package/src/lib/pricing/seed-snapshot.json +1 -1
  69. package/src/lib/rollout.js +42 -28
  70. package/dashboard/dist/assets/ClawdAnimated-DuxB7qei.js +0 -1
  71. package/dashboard/dist/assets/DashboardPage-_9IPwhlZ.js +0 -19
  72. package/dashboard/dist/assets/DialogDescription-Dj5LvyRi.js +0 -1
  73. package/dashboard/dist/assets/LeaderboardPage-0jDDohE4.js +0 -2
  74. package/dashboard/dist/assets/PetPage-UWW1N1PL.js +0 -1
  75. package/dashboard/dist/assets/PopoverPopup-Bg8XtAJ5.js +0 -1
  76. package/dashboard/dist/assets/SkillsPage-BElcW79T.js +0 -1
  77. package/dashboard/dist/assets/check-CPB_1BeX.js +0 -1
  78. package/dashboard/dist/assets/main-zch5Z6OA.css +0 -1
@@ -7,7 +7,7 @@ function resolveInstallPaths({ nativeValue, wslDir, wslValue } = {}, env = proce
7
7
  }
8
8
 
9
9
  const wslCandidate = wslValue !== undefined
10
- ? (wsl.shouldProbeWsl(env) ? wslValue : null)
10
+ ? (wsl.shouldProbeWsl(env) && pathExists(wslValue, deps.existsSync) ? wslValue : null)
11
11
  : (wslDir && wsl.shouldProbeWsl(env) ? wsl.discoverWslHome(wslDir, { ...deps, env }) : null);
12
12
  const nativeCandidate = wsl.shouldProbeNative(env) && nativeValue
13
13
  ? pathExists(nativeValue, deps.existsSync) : null;
@@ -525,6 +525,26 @@ function readJsonBody(req) {
525
525
  });
526
526
  }
527
527
 
528
+ function readBodyLimited(req, maxBytes) {
529
+ return new Promise((resolve, reject) => {
530
+ const chunks = [];
531
+ let total = 0;
532
+ let failed = false;
533
+ req.on("data", (chunk) => {
534
+ if (failed) return;
535
+ total += chunk.length;
536
+ if (total > maxBytes) {
537
+ failed = true;
538
+ reject(new Error(`Request body exceeds ${Math.ceil(maxBytes / 1024 / 1024)} MB`));
539
+ return;
540
+ }
541
+ chunks.push(chunk);
542
+ });
543
+ req.on("end", () => { if (!failed) resolve(Buffer.concat(chunks)); });
544
+ req.on("error", (error) => { if (!failed) reject(error); });
545
+ });
546
+ }
547
+
528
548
  function runSyncCommand(extraEnv = {}, opts = {}) {
529
549
  return new Promise((resolve, reject) => {
530
550
  const args = [TRACKER_BIN, "sync"];
@@ -1137,6 +1157,50 @@ function createLocalApiHandler({ queuePath }) {
1137
1157
  return true;
1138
1158
  }
1139
1159
 
1160
+ // --- local Codex-compatible pet assets and package import ---
1161
+ const localPetAssetMatch = p.match(/^\/api\/pets\/local\/([a-z0-9-]+)\/spritesheet\.webp$/);
1162
+ if (localPetAssetMatch) {
1163
+ const method = String(req.method || "GET").toUpperCase();
1164
+ if (method !== "GET" && method !== "HEAD") {
1165
+ json(res, { error: "Method Not Allowed" }, 405);
1166
+ return true;
1167
+ }
1168
+ const pet = require("./pet-packages").resolvePetAsset(localPetAssetMatch[1]);
1169
+ if (!pet) {
1170
+ json(res, { error: "Pet not found" }, 404);
1171
+ return true;
1172
+ }
1173
+ const stat = fs.statSync(pet.spritesheetPath);
1174
+ res.writeHead(200, {
1175
+ "Content-Type": "image/webp",
1176
+ "Content-Length": stat.size,
1177
+ "Cache-Control": "no-cache",
1178
+ "X-Content-Type-Options": "nosniff",
1179
+ });
1180
+ if (method === "HEAD") res.end();
1181
+ else fs.createReadStream(pet.spritesheetPath).pipe(res);
1182
+ return true;
1183
+ }
1184
+
1185
+ if (p === "/api/pets/import") {
1186
+ if (String(req.method || "GET").toUpperCase() !== "POST") {
1187
+ json(res, { ok: false, error: "Method Not Allowed" }, 405);
1188
+ return true;
1189
+ }
1190
+ if (!isAuthorizedLocalMutation(req)) {
1191
+ json(res, { ok: false, error: "Unauthorized" }, 401);
1192
+ return true;
1193
+ }
1194
+ const pets = require("./pet-packages");
1195
+ try {
1196
+ const body = await readBodyLimited(req, pets.MAX_PACKAGE_BYTES);
1197
+ json(res, { ok: true, pet: await pets.importPetZip(body) });
1198
+ } catch (error) {
1199
+ if (!res.headersSent) json(res, { ok: false, error: error?.message || "Pet import failed" }, 400);
1200
+ }
1201
+ return true;
1202
+ }
1203
+
1140
1204
  // --- Auth bridge: native OAuth flag (WebView ↔ system browser) ---
1141
1205
  if (p === "/api/auth-bridge/verifier") {
1142
1206
  const method = String(req.method || "GET").toUpperCase();
@@ -2044,6 +2108,39 @@ function createLocalApiHandler({ queuePath }) {
2044
2108
  return true;
2045
2109
  }
2046
2110
 
2111
+ // --- Codex-compatible pet manager ---
2112
+ if (p === "/functions/tokentracker-pets") {
2113
+ const method = String(req.method || "GET").toUpperCase();
2114
+ const pets = require("./pet-packages");
2115
+ try {
2116
+ if (method === "GET") {
2117
+ json(res, { pets: pets.listInstalledPets() });
2118
+ return true;
2119
+ }
2120
+ if (method === "POST") {
2121
+ if (!isAuthorizedLocalMutation(req)) {
2122
+ json(res, { ok: false, error: "Unauthorized" }, 401);
2123
+ return true;
2124
+ }
2125
+ const body = await readJsonBody(req);
2126
+ if (body?.action === "install_url") {
2127
+ json(res, { ok: true, pet: await pets.installFromCodexPets(body.url || body.id) });
2128
+ return true;
2129
+ }
2130
+ if (body?.action === "remove") {
2131
+ json(res, { ok: true, ...pets.removeInstalledPet(body.id) });
2132
+ return true;
2133
+ }
2134
+ json(res, { ok: false, error: "Unknown pets action" }, 400);
2135
+ return true;
2136
+ }
2137
+ json(res, { ok: false, error: "Method Not Allowed" }, 405);
2138
+ } catch (error) {
2139
+ json(res, { ok: false, error: error?.message || "Pet operation failed" }, 400);
2140
+ }
2141
+ return true;
2142
+ }
2143
+
2047
2144
  // --- skills manager ---
2048
2145
  if (p === "/functions/tokentracker-skills") {
2049
2146
  const method = String(req.method || "GET").toUpperCase();
@@ -0,0 +1,373 @@
1
+ const fs = require("node:fs");
2
+ const os = require("node:os");
3
+ const path = require("node:path");
4
+ const crypto = require("node:crypto");
5
+ const yauzl = require("yauzl");
6
+
7
+ const PET_MANIFEST = "pet.json";
8
+ const PET_SPRITESHEET = "spritesheet.webp";
9
+ const MAX_PACKAGE_BYTES = 12 * 1024 * 1024;
10
+ const MAX_MANIFEST_BYTES = 16 * 1024;
11
+ const MAX_SPRITESHEET_BYTES = 10 * 1024 * 1024;
12
+ const BUILTIN_IDS = new Set(["clawd", "sprout", "byte", "ember"]);
13
+ // codex-pets.net added `kind` to pet.json long after launch, and its enum may keep
14
+ // growing — packages published before that (and future kinds) must stay importable,
15
+ // so kind is optional metadata validated only as a short slug.
16
+ const KIND_RE = /^[a-z][a-z0-9-]{0,39}$/;
17
+ const PET_ID_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
18
+ const petDirectoryCache = new Map();
19
+
20
+ function resolvePetsDir() {
21
+ return path.resolve(
22
+ process.env.TOKENTRACKER_PETS_DIR || path.join(os.homedir(), ".codex", "pets"),
23
+ );
24
+ }
25
+
26
+ function readUInt24LE(buffer, offset) {
27
+ return buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16);
28
+ }
29
+
30
+ function readWebpDimensions(buffer) {
31
+ if (
32
+ !Buffer.isBuffer(buffer)
33
+ || buffer.length < 30
34
+ || buffer.subarray(0, 4).toString("ascii") !== "RIFF"
35
+ || buffer.subarray(8, 12).toString("ascii") !== "WEBP"
36
+ ) {
37
+ throw new Error("spritesheet.webp is not a valid WebP file");
38
+ }
39
+ if (buffer.readUInt32LE(4) + 8 !== buffer.length) {
40
+ throw new Error("spritesheet.webp has an invalid RIFF size");
41
+ }
42
+
43
+ let offset = 12;
44
+ while (offset + 8 <= buffer.length) {
45
+ const type = buffer.subarray(offset, offset + 4).toString("ascii");
46
+ const size = buffer.readUInt32LE(offset + 4);
47
+ const data = offset + 8;
48
+ if (data + size > buffer.length) throw new Error("spritesheet.webp is truncated");
49
+ if (type === "VP8X" && size >= 10) {
50
+ return {
51
+ width: readUInt24LE(buffer, data + 4) + 1,
52
+ height: readUInt24LE(buffer, data + 7) + 1,
53
+ };
54
+ }
55
+ if (type === "VP8 " && size >= 10) {
56
+ if (buffer[data + 3] !== 0x9d || buffer[data + 4] !== 0x01 || buffer[data + 5] !== 0x2a) {
57
+ throw new Error("spritesheet.webp has an invalid VP8 frame header");
58
+ }
59
+ return {
60
+ width: buffer.readUInt16LE(data + 6) & 0x3fff,
61
+ height: buffer.readUInt16LE(data + 8) & 0x3fff,
62
+ };
63
+ }
64
+ if (type === "VP8L" && size >= 5) {
65
+ if (buffer[data] !== 0x2f) throw new Error("spritesheet.webp has an invalid VP8L header");
66
+ const bits = buffer.readUInt32LE(data + 1);
67
+ return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 };
68
+ }
69
+ offset = data + size + (size % 2);
70
+ }
71
+ throw new Error("spritesheet.webp has no decodable image frame");
72
+ }
73
+
74
+ function normalizeManifest(value) {
75
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
76
+ throw new Error("pet.json must contain a JSON object");
77
+ }
78
+ const id = String(value.id || "").trim().toLowerCase();
79
+ const displayName = String(value.displayName || "").trim();
80
+ const description = String(value.description || "").trim();
81
+ const spritesheetPath = String(value.spritesheetPath || "").trim();
82
+ const kind = String(value.kind || "").trim().toLowerCase();
83
+ const versionProvided = value.spriteVersionNumber !== undefined;
84
+ const spriteVersionNumber = versionProvided ? value.spriteVersionNumber : 1;
85
+
86
+ if (!PET_ID_RE.test(id)) throw new Error("pet.json id must be a lowercase URL-safe slug");
87
+ if (BUILTIN_IDS.has(id)) throw new Error(`${id} is reserved by a built-in TokenTracker pet`);
88
+ if (!displayName || displayName.length > 80) throw new Error("pet.json displayName is required and must be at most 80 characters");
89
+ if (!description || description.length > 280) throw new Error("pet.json description is required and must be at most 280 characters");
90
+ if (spritesheetPath !== PET_SPRITESHEET) throw new Error("pet.json spritesheetPath must be spritesheet.webp");
91
+ if (kind && !KIND_RE.test(kind)) throw new Error("pet.json kind must be a short lowercase label when provided");
92
+ if (versionProvided && spriteVersionNumber !== 2) {
93
+ throw new Error("pet.json spriteVersionNumber must be 2 when provided");
94
+ }
95
+
96
+ return {
97
+ id,
98
+ displayName,
99
+ description,
100
+ spritesheetPath: PET_SPRITESHEET,
101
+ spriteVersionNumber,
102
+ ...(kind ? { kind } : {}),
103
+ };
104
+ }
105
+
106
+ function validatePackageFiles(manifestBuffer, spritesheetBuffer) {
107
+ if (!Buffer.isBuffer(manifestBuffer) || manifestBuffer.length === 0 || manifestBuffer.length > MAX_MANIFEST_BYTES) {
108
+ throw new Error("pet.json is missing or too large");
109
+ }
110
+ if (!Buffer.isBuffer(spritesheetBuffer) || spritesheetBuffer.length === 0 || spritesheetBuffer.length > MAX_SPRITESHEET_BYTES) {
111
+ throw new Error("spritesheet.webp is missing or too large");
112
+ }
113
+
114
+ let parsed;
115
+ try {
116
+ parsed = JSON.parse(manifestBuffer.toString("utf8"));
117
+ } catch {
118
+ throw new Error("pet.json is not valid JSON");
119
+ }
120
+ const manifest = normalizeManifest(parsed);
121
+ const dimensions = readWebpDimensions(spritesheetBuffer);
122
+ const expectedHeight = manifest.spriteVersionNumber === 2 ? 2288 : 1872;
123
+ if (dimensions.width !== 1536 || dimensions.height !== expectedHeight) {
124
+ throw new Error(
125
+ `spritesheet.webp must be 1536x${expectedHeight} for a v${manifest.spriteVersionNumber} pet`,
126
+ );
127
+ }
128
+ return { manifest, dimensions };
129
+ }
130
+
131
+ function readZipEntries(zipBuffer) {
132
+ if (!Buffer.isBuffer(zipBuffer) || zipBuffer.length === 0 || zipBuffer.length > MAX_PACKAGE_BYTES) {
133
+ return Promise.reject(new Error("Pet package is empty or exceeds 12 MB"));
134
+ }
135
+ return new Promise((resolve, reject) => {
136
+ yauzl.fromBuffer(zipBuffer, { lazyEntries: true, validateEntrySizes: true }, (openError, zipfile) => {
137
+ if (openError) return reject(new Error(`Invalid pet package: ${openError.message}`));
138
+ const entries = new Map();
139
+ let settled = false;
140
+ const fail = (error) => {
141
+ if (settled) return;
142
+ settled = true;
143
+ try { zipfile.close(); } catch {}
144
+ reject(error);
145
+ };
146
+ zipfile.on("error", (error) => fail(new Error(`Invalid pet package: ${error.message}`)));
147
+ zipfile.on("entry", (entry) => {
148
+ const name = String(entry.fileName || "");
149
+ const unixMode = (entry.externalFileAttributes >>> 16) & 0xffff;
150
+ if ((unixMode & 0xf000) === 0xa000) {
151
+ fail(new Error("Pet package entries must not be symbolic links"));
152
+ return;
153
+ }
154
+ if (name !== PET_MANIFEST && name !== PET_SPRITESHEET) {
155
+ fail(new Error("A standard pet package must contain only pet.json and spritesheet.webp at its root"));
156
+ return;
157
+ }
158
+ if (entries.has(name)) {
159
+ fail(new Error(`Pet package contains duplicate ${name}`));
160
+ return;
161
+ }
162
+ const limit = name === PET_MANIFEST ? MAX_MANIFEST_BYTES : MAX_SPRITESHEET_BYTES;
163
+ if (entry.uncompressedSize <= 0 || entry.uncompressedSize > limit) {
164
+ fail(new Error(`${name} is empty or too large`));
165
+ return;
166
+ }
167
+ zipfile.openReadStream(entry, (streamError, stream) => {
168
+ if (streamError) return fail(new Error(`Could not read ${name}: ${streamError.message}`));
169
+ const chunks = [];
170
+ let total = 0;
171
+ stream.on("data", (chunk) => {
172
+ total += chunk.length;
173
+ if (total > limit) {
174
+ stream.destroy(new Error(`${name} is too large`));
175
+ return;
176
+ }
177
+ chunks.push(chunk);
178
+ });
179
+ stream.on("error", (error) => fail(error));
180
+ stream.on("end", () => {
181
+ if (settled) return;
182
+ entries.set(name, Buffer.concat(chunks));
183
+ zipfile.readEntry();
184
+ });
185
+ });
186
+ });
187
+ zipfile.on("end", () => {
188
+ if (settled) return;
189
+ settled = true;
190
+ if (entries.size !== 2 || !entries.has(PET_MANIFEST) || !entries.has(PET_SPRITESHEET)) {
191
+ reject(new Error("Pet package must contain pet.json and spritesheet.webp"));
192
+ return;
193
+ }
194
+ resolve(entries);
195
+ });
196
+ zipfile.readEntry();
197
+ });
198
+ });
199
+ }
200
+
201
+ function publicPet(manifest, extra = {}) {
202
+ const assetVersion = extra.assetVersion || "";
203
+ return {
204
+ ...manifest,
205
+ custom: true,
206
+ assetUrl: `/api/pets/local/${encodeURIComponent(manifest.id)}/${PET_SPRITESHEET}${assetVersion ? `?v=${encodeURIComponent(assetVersion)}` : ""}`,
207
+ ...extra,
208
+ };
209
+ }
210
+
211
+ function loadPetDirectory(directory, expectedId = null) {
212
+ const stat = fs.lstatSync(directory);
213
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Pet path is not a directory");
214
+ const manifestPath = path.join(directory, PET_MANIFEST);
215
+ const spritesheetPath = path.join(directory, PET_SPRITESHEET);
216
+ const manifestStat = fs.lstatSync(manifestPath);
217
+ const spriteStat = fs.lstatSync(spritesheetPath);
218
+ if (!manifestStat.isFile() || manifestStat.isSymbolicLink() || !spriteStat.isFile() || spriteStat.isSymbolicLink()) {
219
+ throw new Error("Pet assets must be regular files");
220
+ }
221
+ const cacheKey = [
222
+ manifestStat.mtimeMs,
223
+ manifestStat.size,
224
+ spriteStat.mtimeMs,
225
+ spriteStat.size,
226
+ ].join(":");
227
+ const cacheId = directory;
228
+ const cached = petDirectoryCache.get(cacheId);
229
+ if (cached?.cacheKey === cacheKey) return cached.value;
230
+ const result = validatePackageFiles(fs.readFileSync(manifestPath), fs.readFileSync(spritesheetPath));
231
+ if (expectedId && result.manifest.id !== expectedId) throw new Error("Pet directory and manifest id do not match");
232
+ const value = {
233
+ ...result,
234
+ directory,
235
+ spritesheetPath,
236
+ assetVersion: `${Math.trunc(spriteStat.mtimeMs)}-${spriteStat.size}`,
237
+ };
238
+ petDirectoryCache.set(cacheId, { cacheKey, value });
239
+ return value;
240
+ }
241
+
242
+ function listInstalledPets() {
243
+ const root = resolvePetsDir();
244
+ let names = [];
245
+ try { names = fs.readdirSync(root); } catch (error) {
246
+ if (error?.code === "ENOENT") return [];
247
+ throw error;
248
+ }
249
+ const pets = [];
250
+ for (const name of names) {
251
+ if (!PET_ID_RE.test(name) || BUILTIN_IDS.has(name)) continue;
252
+ try {
253
+ const loaded = loadPetDirectory(path.join(root, name), name);
254
+ pets.push(publicPet(loaded.manifest, { assetVersion: loaded.assetVersion }));
255
+ } catch {
256
+ // Invalid or partially copied directories are ignored until they become valid.
257
+ }
258
+ }
259
+ return pets.sort((a, b) => a.displayName.localeCompare(b.displayName));
260
+ }
261
+
262
+ function installValidatedPackage(manifestBuffer, spritesheetBuffer) {
263
+ const { manifest } = validatePackageFiles(manifestBuffer, spritesheetBuffer);
264
+ const root = resolvePetsDir();
265
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
266
+ const stage = path.join(root, `.tokentracker-pet-${process.pid}-${crypto.randomBytes(6).toString("hex")}`);
267
+ const destination = path.join(root, manifest.id);
268
+ const backup = `${stage}-backup`;
269
+ fs.mkdirSync(stage, { mode: 0o700 });
270
+ try {
271
+ fs.writeFileSync(path.join(stage, PET_MANIFEST), `${JSON.stringify({
272
+ id: manifest.id,
273
+ displayName: manifest.displayName,
274
+ description: manifest.description,
275
+ spritesheetPath: PET_SPRITESHEET,
276
+ ...(manifest.spriteVersionNumber === 2 ? { spriteVersionNumber: 2 } : {}),
277
+ kind: manifest.kind,
278
+ }, null, 2)}\n`, { mode: 0o600 });
279
+ fs.writeFileSync(path.join(stage, PET_SPRITESHEET), spritesheetBuffer, { mode: 0o600 });
280
+ loadPetDirectory(stage, manifest.id);
281
+ petDirectoryCache.delete(destination);
282
+ if (fs.existsSync(destination)) fs.renameSync(destination, backup);
283
+ fs.renameSync(stage, destination);
284
+ petDirectoryCache.delete(stage);
285
+ } catch (error) {
286
+ petDirectoryCache.delete(stage);
287
+ fs.rmSync(stage, { recursive: true, force: true });
288
+ if (!fs.existsSync(destination) && fs.existsSync(backup)) fs.renameSync(backup, destination);
289
+ throw error;
290
+ }
291
+ try { fs.rmSync(backup, { recursive: true, force: true }); } catch {}
292
+ const installed = loadPetDirectory(destination, manifest.id);
293
+ return publicPet(manifest, { assetVersion: installed.assetVersion });
294
+ }
295
+
296
+ async function importPetZip(zipBuffer) {
297
+ const entries = await readZipEntries(zipBuffer);
298
+ return installValidatedPackage(entries.get(PET_MANIFEST), entries.get(PET_SPRITESHEET));
299
+ }
300
+
301
+ function petIdFromCodexPetsUrl(input) {
302
+ const raw = String(input || "").trim();
303
+ if (PET_ID_RE.test(raw)) return raw;
304
+ let parsed;
305
+ try { parsed = new URL(raw); } catch { throw new Error("Enter a Codex Pets URL or pet id"); }
306
+ if (parsed.protocol !== "https:" || parsed.hostname !== "codex-pets.net") {
307
+ throw new Error("Only https://codex-pets.net pet URLs are supported");
308
+ }
309
+ const hashMatch = parsed.hash.match(/^#\/pets\/([a-z0-9-]+)\/?$/i);
310
+ const pathMatch = parsed.pathname.match(/^\/api\/pets\/([a-z0-9-]+)(?:\/download)?\/?$/i);
311
+ const id = String(hashMatch?.[1] || pathMatch?.[1] || "").toLowerCase();
312
+ if (!PET_ID_RE.test(id)) throw new Error("The Codex Pets URL does not contain a valid pet id");
313
+ return id;
314
+ }
315
+
316
+ async function installFromCodexPets(input, fetchImpl = fetch) {
317
+ const id = petIdFromCodexPetsUrl(input);
318
+ const controller = new AbortController();
319
+ const timeout = setTimeout(() => controller.abort(), 30_000);
320
+ timeout.unref?.();
321
+ try {
322
+ const response = await fetchImpl(`https://codex-pets.net/api/pets/${encodeURIComponent(id)}/download`, {
323
+ redirect: "follow",
324
+ signal: controller.signal,
325
+ headers: { accept: "application/zip", "user-agent": "TokenTracker Pet Importer" },
326
+ });
327
+ if (!response.ok) throw new Error(`Codex Pets download failed with HTTP ${response.status}`);
328
+ const length = Number(response.headers.get("content-length") || 0);
329
+ if (length > MAX_PACKAGE_BYTES) throw new Error("Pet package exceeds 12 MB");
330
+ const chunks = [];
331
+ let total = 0;
332
+ for await (const chunk of response.body || []) {
333
+ total += chunk.length;
334
+ if (total > MAX_PACKAGE_BYTES) throw new Error("Pet package exceeds 12 MB");
335
+ chunks.push(Buffer.from(chunk));
336
+ }
337
+ const body = Buffer.concat(chunks);
338
+ return await importPetZip(body);
339
+ } finally {
340
+ clearTimeout(timeout);
341
+ }
342
+ }
343
+
344
+ function removeInstalledPet(id) {
345
+ const normalized = String(id || "").trim().toLowerCase();
346
+ if (!PET_ID_RE.test(normalized) || BUILTIN_IDS.has(normalized)) throw new Error("Invalid custom pet id");
347
+ const root = resolvePetsDir();
348
+ const destination = path.join(root, normalized);
349
+ if (!destination.startsWith(`${root}${path.sep}`)) throw new Error("Invalid custom pet path");
350
+ fs.rmSync(destination, { recursive: true, force: true });
351
+ petDirectoryCache.delete(destination);
352
+ return { id: normalized };
353
+ }
354
+
355
+ function resolvePetAsset(id) {
356
+ const normalized = String(id || "").trim().toLowerCase();
357
+ if (!PET_ID_RE.test(normalized) || BUILTIN_IDS.has(normalized)) return null;
358
+ try { return loadPetDirectory(path.join(resolvePetsDir(), normalized), normalized); } catch { return null; }
359
+ }
360
+
361
+ module.exports = {
362
+ MAX_PACKAGE_BYTES,
363
+ importPetZip,
364
+ installFromCodexPets,
365
+ listInstalledPets,
366
+ normalizeManifest,
367
+ petIdFromCodexPetsUrl,
368
+ readWebpDimensions,
369
+ removeInstalledPet,
370
+ resolvePetAsset,
371
+ resolvePetsDir,
372
+ validatePackageFiles,
373
+ };