codex-pet-share 0.3.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Codex Pet Share contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # codex-pet-share
2
+
3
+ Install shared Codex pets from codex-pet-share.
4
+
5
+ ```sh
6
+ npx codex-pet-share add pet-slug
7
+ npx codex-pet-share add-collection collection-slug
8
+ ```
9
+
10
+ Pets are installed into:
11
+
12
+ ```sh
13
+ $HOME/.codex/pets/{pet-id}
14
+ ```
15
+
16
+ ## Commands
17
+
18
+ ### `add {pet-id}`
19
+
20
+ Downloads the pet package by slug from codex-pet-share and writes `pet.json` and `spritesheet.webp` into the local Codex pets directory.
21
+
22
+ ```sh
23
+ npx codex-pet-share add tiny-dino
24
+ ```
25
+
26
+ ### `add-collection {collection-slug}`
27
+
28
+ Downloads every pet in a codex-pet-share collection and writes each package into the local Codex pets directory.
29
+
30
+ ```sh
31
+ npx codex-pet-share add-collection cats
32
+ ```
33
+
34
+ ## Configuration
35
+
36
+ The CLI defaults to the production API at `https://codex-pets.net`. To use a
37
+ different deployment, set:
38
+
39
+ ```sh
40
+ CODEX_PETS_API_BASE=https://your-deployment.example.com
41
+ ```
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runCli } from "../src/cli.js";
4
+
5
+ runCli(process.argv.slice(2)).catch((error) => {
6
+ const message = error instanceof Error ? error.message : String(error);
7
+ console.error(`codex-pet-share: ${message}`);
8
+ process.exitCode = 1;
9
+ });
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "codex-pet-share",
3
+ "version": "0.3.0",
4
+ "description": "Install shared Codex pets from codex-pet-share.",
5
+ "type": "module",
6
+ "bin": {
7
+ "codex-pet-share": "bin/codex-pets.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "test": "node --test",
17
+ "format": "prettier --write ."
18
+ },
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/portons/codex-pet-share.git",
26
+ "directory": "codex-pets-installer"
27
+ },
28
+ "homepage": "https://codex-pets.net",
29
+ "bugs": {
30
+ "url": "https://github.com/portons/codex-pet-share/issues"
31
+ },
32
+ "keywords": [
33
+ "codex",
34
+ "codex-pets",
35
+ "pets",
36
+ "cli",
37
+ "installer"
38
+ ],
39
+ "publishConfig": {
40
+ "access": "public",
41
+ "registry": "https://registry.npmjs.org/"
42
+ },
43
+ "devDependencies": {
44
+ "prettier": "^3.5.3"
45
+ }
46
+ }
package/src/cli.js ADDED
@@ -0,0 +1,68 @@
1
+ import { addPet, addPetCollection } from "./installer.js";
2
+
3
+ const helpText = `Usage:
4
+ codex-pet-share add {pet-id}
5
+ codex-pet-share add-collection {collection-slug}
6
+
7
+ Options:
8
+ -h, --help Show this help message
9
+
10
+ Environment:
11
+ CODEX_PETS_API_BASE Override the codex-pet-share API base URL`;
12
+
13
+ export async function runCli(args) {
14
+ const [command, value, ...extraArgs] = args;
15
+
16
+ if (!command || command === "-h" || command === "--help") {
17
+ console.log(helpText);
18
+ return;
19
+ }
20
+
21
+ if (command === "add") {
22
+ await runAddCommand(value, extraArgs);
23
+ return;
24
+ }
25
+
26
+ if (command === "add-collection") {
27
+ await runAddCollectionCommand(value, extraArgs);
28
+ return;
29
+ }
30
+
31
+ throw new Error(`unknown command "${command}"`);
32
+ }
33
+
34
+ async function runAddCommand(petId, extraArgs) {
35
+ if (!petId || extraArgs.length > 0) {
36
+ throw new Error("usage: codex-pet-share add {pet-id}");
37
+ }
38
+
39
+ const result = await addPet({ petId });
40
+ console.log(`Installed ${result.displayName} (${result.id})`);
41
+ console.log(result.installPath);
42
+ }
43
+
44
+ async function runAddCollectionCommand(collectionSlug, extraArgs) {
45
+ if (!collectionSlug || extraArgs.length > 0) {
46
+ throw new Error(
47
+ "usage: codex-pet-share add-collection {collection-slug}",
48
+ );
49
+ }
50
+
51
+ const result = await addPetCollection({
52
+ collectionSlug,
53
+ onPetInstalled: ({ pet, current, total }) => {
54
+ console.log(
55
+ `Installed ${current}/${total}: ${pet.displayName} (${pet.id})`,
56
+ );
57
+ },
58
+ });
59
+
60
+ if (result.total === 0) {
61
+ console.log(`No pets found in ${result.collection.displayName}`);
62
+ return;
63
+ }
64
+
65
+ console.log(
66
+ `Installed ${result.total} pets from ${result.collection.displayName}`,
67
+ );
68
+ }
@@ -0,0 +1,2 @@
1
+ export const defaultApiBase = "https://codex-pets.net";
2
+ export const petSlugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -0,0 +1,233 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ import { defaultApiBase, petSlugPattern } from "./constants.js";
5
+ import { extractZipEntries } from "./zip.js";
6
+
7
+ const requiredPetFiles = ["pet.json", "spritesheet.webp"];
8
+
9
+ export async function addPet({
10
+ petId,
11
+ apiBase = process.env.CODEX_PETS_API_BASE ||
12
+ process.env.PETSHARE_API_BASE ||
13
+ defaultApiBase,
14
+ codexDirectory = process.env.CODEX_HOME || path.join(homedir(), ".codex"),
15
+ fetchImpl = fetch,
16
+ }) {
17
+ const normalizedPetId = normalizePetId(petId);
18
+ const baseUrl = normalizeApiBase(apiBase);
19
+ const pet = await fetchPet(baseUrl, normalizedPetId, fetchImpl);
20
+ return installPetPackage({
21
+ baseUrl,
22
+ pet,
23
+ codexDirectory,
24
+ fetchImpl,
25
+ });
26
+ }
27
+
28
+ export async function addPetCollection({
29
+ collectionSlug,
30
+ apiBase = process.env.CODEX_PETS_API_BASE ||
31
+ process.env.PETSHARE_API_BASE ||
32
+ defaultApiBase,
33
+ codexDirectory = process.env.CODEX_HOME || path.join(homedir(), ".codex"),
34
+ fetchImpl = fetch,
35
+ onPetInstalled,
36
+ } = {}) {
37
+ const normalizedCollectionSlug = normalizeCollectionSlug(collectionSlug);
38
+ const baseUrl = normalizeApiBase(apiBase);
39
+ const collection = await fetchPetCollection(
40
+ baseUrl,
41
+ normalizedCollectionSlug,
42
+ fetchImpl,
43
+ );
44
+ const installedPets = [];
45
+ const totalPets = collection.pets.length;
46
+
47
+ for (const [index, pet] of collection.pets.entries()) {
48
+ const result = await installPetPackage({
49
+ baseUrl,
50
+ pet,
51
+ codexDirectory,
52
+ fetchImpl,
53
+ });
54
+ installedPets.push(result);
55
+ onPetInstalled?.({
56
+ pet: result,
57
+ current: index + 1,
58
+ total: totalPets,
59
+ });
60
+ }
61
+
62
+ return {
63
+ collection: collection.collection,
64
+ pets: installedPets,
65
+ total: installedPets.length,
66
+ };
67
+ }
68
+
69
+ async function installPetPackage({ baseUrl, pet, codexDirectory, fetchImpl }) {
70
+ const zipBytes = await fetchDownload(baseUrl, pet, fetchImpl);
71
+ const entries = extractZipEntries(Buffer.from(zipBytes));
72
+ const manifest = readManifest(entries, pet.id);
73
+ const installPath = path.join(codexDirectory, "pets", manifest.id);
74
+
75
+ await mkdir(installPath, { recursive: true });
76
+ for (const fileName of requiredPetFiles) {
77
+ const bytes = entries.get(fileName);
78
+ if (!bytes) {
79
+ throw new Error(`download did not include ${fileName}`);
80
+ }
81
+ await writeFile(path.join(installPath, fileName), bytes);
82
+ }
83
+
84
+ return {
85
+ id: manifest.id,
86
+ displayName: manifest.displayName,
87
+ installPath,
88
+ };
89
+ }
90
+
91
+ export function normalizePetId(petId) {
92
+ const normalizedPetId = String(petId || "").trim();
93
+ if (!petSlugPattern.test(normalizedPetId)) {
94
+ throw new Error("pet id must be a lowercase slug like tiny-dino");
95
+ }
96
+ return normalizedPetId;
97
+ }
98
+
99
+ export function normalizeCollectionSlug(collectionSlug) {
100
+ const normalizedCollectionSlug = String(collectionSlug || "").trim();
101
+ if (!petSlugPattern.test(normalizedCollectionSlug)) {
102
+ throw new Error("collection slug must be lowercase like cats");
103
+ }
104
+ return normalizedCollectionSlug;
105
+ }
106
+
107
+ export function normalizeApiBase(apiBase) {
108
+ const trimmedApiBase = String(apiBase || "")
109
+ .trim()
110
+ .replace(/\/+$/, "");
111
+ if (!trimmedApiBase) {
112
+ throw new Error("API base URL is required");
113
+ }
114
+
115
+ const url = new URL(trimmedApiBase);
116
+ if (!["http:", "https:"].includes(url.protocol)) {
117
+ throw new Error("API base URL must use http or https");
118
+ }
119
+ return url.toString().replace(/\/+$/, "");
120
+ }
121
+
122
+ async function fetchPet(apiBase, petId, fetchImpl) {
123
+ const response = await fetchImpl(`${apiBase}/api/pets/${petId}/share-data`);
124
+ const body = await readJson(response);
125
+ if (!response.ok) {
126
+ throw new Error(
127
+ body.error || `pet lookup failed with status ${response.status}`,
128
+ );
129
+ }
130
+ if (!body.pet?.id) {
131
+ throw new Error("pet lookup returned an invalid response");
132
+ }
133
+ return body.pet;
134
+ }
135
+
136
+ async function fetchPetCollection(apiBase, collectionSlug, fetchImpl) {
137
+ let collection;
138
+ const pets = [];
139
+ let page = 1;
140
+ let totalPages = 1;
141
+
142
+ do {
143
+ const response = await fetchImpl(
144
+ `${apiBase}/api/collections/${collectionSlug}?page=${page}&pageSize=60`,
145
+ );
146
+ const body = await readJson(response);
147
+ if (!response.ok) {
148
+ throw new Error(
149
+ body.error ||
150
+ `pet collection lookup failed with status ${response.status}`,
151
+ );
152
+ }
153
+ if (!body.collection?.slug || !Array.isArray(body.pets)) {
154
+ throw new Error("pet collection lookup returned an invalid response");
155
+ }
156
+
157
+ const normalizedSlug = normalizeCollectionSlug(body.collection.slug);
158
+ if (normalizedSlug !== collectionSlug) {
159
+ throw new Error("pet collection lookup returned a different collection");
160
+ }
161
+
162
+ collection ||= { ...body.collection, slug: normalizedSlug };
163
+ pets.push(...body.pets.map(normalizePetSummary));
164
+ totalPages = normalizeTotalPages(body.totalPages);
165
+ page += 1;
166
+ } while (page <= totalPages);
167
+
168
+ return { collection, pets };
169
+ }
170
+
171
+ async function fetchDownload(apiBase, pet, fetchImpl) {
172
+ const downloadUrl = absoluteUrl(
173
+ apiBase,
174
+ pet.downloadUrl || `/api/pets/${pet.id}/download`,
175
+ );
176
+ const response = await fetchImpl(downloadUrl);
177
+ if (!response.ok) {
178
+ const body = await readJson(response);
179
+ throw new Error(
180
+ body.error || `pet download failed with status ${response.status}`,
181
+ );
182
+ }
183
+ return response.arrayBuffer();
184
+ }
185
+
186
+ function absoluteUrl(apiBase, value) {
187
+ if (value.startsWith("/")) {
188
+ return `${apiBase}${value}`;
189
+ }
190
+ return new URL(value, `${apiBase}/`).toString();
191
+ }
192
+
193
+ async function readJson(response) {
194
+ return response.json().catch(() => ({}));
195
+ }
196
+
197
+ function readManifest(entries, expectedPetId) {
198
+ const manifestBytes = entries.get("pet.json");
199
+ if (!manifestBytes) {
200
+ throw new Error("download did not include pet.json");
201
+ }
202
+
203
+ const manifest = JSON.parse(new TextDecoder().decode(manifestBytes));
204
+ if (manifest.id !== expectedPetId) {
205
+ throw new Error("downloaded pet.json id did not match the requested pet");
206
+ }
207
+ if (manifest.spritesheetPath !== "spritesheet.webp") {
208
+ throw new Error("downloaded pet.json has an invalid spritesheetPath");
209
+ }
210
+ return manifest;
211
+ }
212
+
213
+ function normalizePetSummary(pet) {
214
+ if (!pet?.id) {
215
+ throw new Error("pet collection lookup returned a pet without an id");
216
+ }
217
+ return {
218
+ ...pet,
219
+ id: normalizePetId(pet.id),
220
+ };
221
+ }
222
+
223
+ function normalizeTotalPages(value) {
224
+ const totalPages = value === undefined ? 1 : Number(value);
225
+ if (
226
+ !Number.isSafeInteger(totalPages) ||
227
+ totalPages < 1 ||
228
+ totalPages > 1000
229
+ ) {
230
+ throw new Error("pet collection lookup returned invalid pagination");
231
+ }
232
+ return totalPages;
233
+ }
package/src/zip.js ADDED
@@ -0,0 +1,73 @@
1
+ import { inflateRawSync } from "node:zlib";
2
+
3
+ const localFileHeaderSignature = 0x04034b50;
4
+ const storeMethod = 0;
5
+ const deflateMethod = 8;
6
+
7
+ export function extractZipEntries(zipBytes) {
8
+ const entries = new Map();
9
+ let offset = 0;
10
+
11
+ while (
12
+ offset + 4 <= zipBytes.length &&
13
+ zipBytes.readUInt32LE(offset) === localFileHeaderSignature
14
+ ) {
15
+ const flags = zipBytes.readUInt16LE(offset + 6);
16
+ const compressionMethod = zipBytes.readUInt16LE(offset + 8);
17
+ const compressedSize = zipBytes.readUInt32LE(offset + 18);
18
+ const uncompressedSize = zipBytes.readUInt32LE(offset + 22);
19
+ const fileNameLength = zipBytes.readUInt16LE(offset + 26);
20
+ const extraFieldLength = zipBytes.readUInt16LE(offset + 28);
21
+ const fileNameStart = offset + 30;
22
+ const fileNameEnd = fileNameStart + fileNameLength;
23
+ const dataStart = fileNameEnd + extraFieldLength;
24
+ const dataEnd = dataStart + compressedSize;
25
+
26
+ if ((flags & 0x08) !== 0) {
27
+ throw new Error("zip files with data descriptors are not supported");
28
+ }
29
+ if (dataEnd > zipBytes.length) {
30
+ throw new Error("zip file is truncated");
31
+ }
32
+
33
+ const fileName = zipBytes.toString("utf8", fileNameStart, fileNameEnd);
34
+ if (isUnsafeFileName(fileName)) {
35
+ throw new Error(`zip entry has an unsafe path: ${fileName}`);
36
+ }
37
+
38
+ if (!fileName.endsWith("/")) {
39
+ const compressedData = zipBytes.subarray(dataStart, dataEnd);
40
+ const data = inflateEntry(compressedData, compressionMethod);
41
+ if (data.length !== uncompressedSize) {
42
+ throw new Error(`zip entry has an invalid size: ${fileName}`);
43
+ }
44
+ entries.set(fileName, data);
45
+ }
46
+
47
+ offset = dataEnd;
48
+ }
49
+
50
+ if (!entries.size) {
51
+ throw new Error("zip file did not contain any files");
52
+ }
53
+
54
+ return entries;
55
+ }
56
+
57
+ function inflateEntry(compressedData, compressionMethod) {
58
+ if (compressionMethod === storeMethod) {
59
+ return Buffer.from(compressedData);
60
+ }
61
+ if (compressionMethod === deflateMethod) {
62
+ return inflateRawSync(compressedData);
63
+ }
64
+ throw new Error(`unsupported zip compression method: ${compressionMethod}`);
65
+ }
66
+
67
+ function isUnsafeFileName(fileName) {
68
+ return (
69
+ fileName.startsWith("/") ||
70
+ fileName.includes("\\") ||
71
+ fileName.split("/").includes("..")
72
+ );
73
+ }