@peekling/cli 0.1.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/dist/index.js ADDED
@@ -0,0 +1,569 @@
1
+ import { adaptCodexPetV2, } from "@peekling/adapter-codex-pet";
2
+ import { parseDataText, parseManifestText, validateNativePack, } from "@peekling/runtime/pack";
3
+ import { lstat, mkdir, open, realpath, readdir, rm, writeFile, } from "node:fs/promises";
4
+ import { createHash } from "node:crypto";
5
+ import path from "node:path";
6
+ import { PNG } from "pngjs";
7
+ import { atomicDirectory } from "./atomic-directory.js";
8
+ import { inspectImage } from "./image.js";
9
+ import { createFixtureAtlas } from "./png.js";
10
+ import { readZipEntries } from "./zip.js";
11
+ const EXECUTABLE = /\.(?:js|mjs|cjs|ts|tsx|jsx|html?|wasm|sh|bat|cmd|exe|dll|dylib|so|jar|py|rb|php)$/i;
12
+ function decodeJsonUtf8(bytes, label) {
13
+ try {
14
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
15
+ }
16
+ catch (cause) {
17
+ throw new Error(`${label} must be valid UTF-8`, { cause });
18
+ }
19
+ }
20
+ function starterManifest(name, sha256) {
21
+ const directions = ["N", "NE", "E", "SE", "W", "SW", "NW", "S"];
22
+ const states = {
23
+ idle: { frames: [0, 1], fps: 2, loop: true },
24
+ };
25
+ directions.forEach((direction, index) => {
26
+ states[`move:${direction}`] = {
27
+ frames: [16 + index * 2, 17 + index * 2],
28
+ fps: 6,
29
+ loop: true,
30
+ };
31
+ });
32
+ [
33
+ "click",
34
+ "double-click",
35
+ "context-click",
36
+ "scroll",
37
+ "happy",
38
+ "success",
39
+ "error",
40
+ "sleep",
41
+ ].forEach((state, index) => {
42
+ states[state] = {
43
+ frames: [32 + index * 2, 33 + index * 2],
44
+ fps: 4,
45
+ loop: state === "sleep",
46
+ };
47
+ });
48
+ return {
49
+ format: 1,
50
+ name,
51
+ version: "0.1.0",
52
+ license: "CC0-1.0",
53
+ metadata: {
54
+ title: `${name} development fixture`,
55
+ author: "Generated by the Peekling CLI",
56
+ description: "Synthetic geometric placeholder. Replace before release.",
57
+ tags: ["development-fixture"],
58
+ },
59
+ assets: {
60
+ atlas: {
61
+ src: "atlas.png",
62
+ sha256,
63
+ columns: 16,
64
+ rows: 3,
65
+ logicalCellSize: 32,
66
+ sourceCellSize: 32,
67
+ density: 1,
68
+ },
69
+ },
70
+ states,
71
+ capabilities: {
72
+ locomotion: {
73
+ directions: Object.fromEntries(directions.map((direction) => [direction, `move:${direction}`])),
74
+ },
75
+ },
76
+ defaults: { scale: 2 },
77
+ };
78
+ }
79
+ export async function packAuthoringSource(sourceDirectory, output) {
80
+ const sourceRoot = await realpath(path.resolve(sourceDirectory));
81
+ const sourceText = (await readBoundedFile(await confinedSourceFile(sourceRoot, "source.json", "source.json"), 64 * 1024, "source.json")).toString("utf8");
82
+ const raw = parseDataText(sourceText, "source.json");
83
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
84
+ throw new Error("source.json must be an object");
85
+ const author = raw;
86
+ const name = author.name;
87
+ const version = author.version;
88
+ const license = author.license;
89
+ const logicalCellSize = author.logicalCellSize;
90
+ if (typeof name !== "string" ||
91
+ !/^[a-z][a-z0-9-]{0,63}$/.test(name) ||
92
+ typeof version !== "string" ||
93
+ !/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(version) ||
94
+ typeof license !== "string" ||
95
+ ![32, 64].includes(logicalCellSize))
96
+ throw new Error("source.json identity or logicalCellSize is invalid");
97
+ if (!Array.isArray(author.sources) ||
98
+ author.sources.length < 1 ||
99
+ author.sources.length > 3)
100
+ throw new Error("source.json requires 1 through 3 density sources");
101
+ const sources = author.sources;
102
+ const seenDensity = new Set();
103
+ for (const source of sources) {
104
+ if (![1, 2, 4].includes(source.density) ||
105
+ seenDensity.has(source.density) ||
106
+ Boolean(source.sheet) === Boolean(source.directory))
107
+ throw new Error("Each source density must be unique and use sheet or directory");
108
+ seenDensity.add(source.density);
109
+ const sourcePath = source.sheet ?? source.directory;
110
+ validateSourcePath(sourcePath, `${source.density}x source`);
111
+ if (source.sheet &&
112
+ (!Number.isInteger(source.columns) ||
113
+ !Number.isInteger(source.rows) ||
114
+ source.columns < 1 ||
115
+ source.columns > 64 ||
116
+ source.rows < 1 ||
117
+ source.rows > 64)) {
118
+ throw new Error("Source sheet columns and rows must be integers 1-64");
119
+ }
120
+ }
121
+ if (!author.states ||
122
+ typeof author.states !== "object" ||
123
+ Array.isArray(author.states))
124
+ throw new Error("source.json states must be an object");
125
+ const stateEntries = Object.entries(author.states);
126
+ if (stateEntries.length < 1 || stateEntries.length > 64)
127
+ throw new Error("source.json states must contain 1 through 64 entries");
128
+ const names = [];
129
+ const packedIndex = new Map();
130
+ for (const [stateName, state] of stateEntries) {
131
+ if (!/^[a-z][a-z0-9-]*(?::(?:N|NE|E|SE|S|SW|W|NW|[a-z0-9-]+))?$/.test(stateName))
132
+ throw new Error(`Invalid source state name: ${stateName}`);
133
+ if (!Array.isArray(state.frames) ||
134
+ !state.frames.length ||
135
+ state.frames.length > 64 ||
136
+ state.frames.some((frame) => !/^[a-z][a-z0-9-]{0,63}$/.test(frame)))
137
+ throw new Error(`${stateName}.frames must contain safe stable names`);
138
+ if ((state.fps === undefined) === (state.durations === undefined))
139
+ throw new Error(`${stateName} must declare exactly one of fps or durations`);
140
+ for (const frame of state.frames)
141
+ if (!packedIndex.has(frame)) {
142
+ packedIndex.set(frame, names.length);
143
+ names.push(frame);
144
+ }
145
+ }
146
+ if (names.length > 64)
147
+ throw new Error("source.json exceeds 64 unique frames");
148
+ const rows = Math.max(2, Math.ceil(names.length / 16));
149
+ for (const source of sources) {
150
+ const cell = logicalCellSize * source.density;
151
+ if (16 * cell > 4_096 || rows * cell > 4_096)
152
+ throw new Error("Compiled atlas would exceed 4096x4096 pixels");
153
+ if (source.sheet &&
154
+ Number(source.columns) * cell * (Number(source.rows) * cell) * 4 >
155
+ 64 * 1024 * 1024) {
156
+ throw new Error("Decoded source allocation exceeds 64 MiB");
157
+ }
158
+ }
159
+ const compiledStates = Object.fromEntries(stateEntries.map(([stateName, state]) => [
160
+ stateName,
161
+ {
162
+ frames: state.frames.map((frame) => packedIndex.get(frame)),
163
+ loop: state.loop,
164
+ ...(state.fps === undefined ? {} : { fps: state.fps }),
165
+ ...(state.durations === undefined
166
+ ? {}
167
+ : { durations: state.durations }),
168
+ },
169
+ ]));
170
+ const frameLocations = new Map();
171
+ for (const [, state] of stateEntries) {
172
+ if (state.row === undefined)
173
+ continue;
174
+ if (!Number.isInteger(state.row) || state.row < 0)
175
+ throw new Error("Source rows must be non-negative integers");
176
+ const seenInState = new Set();
177
+ state.frames.forEach((frame, column) => {
178
+ if (seenInState.has(frame))
179
+ return;
180
+ seenInState.add(frame);
181
+ const existing = frameLocations.get(frame);
182
+ const next = { row: state.row, column };
183
+ if (existing &&
184
+ (existing.row !== next.row || existing.column !== next.column))
185
+ throw new Error(`Named frame ${frame} has conflicting source locations`);
186
+ frameLocations.set(frame, next);
187
+ });
188
+ }
189
+ for (const source of sources) {
190
+ if (!source.sheet)
191
+ continue;
192
+ for (const [frame, location] of frameLocations) {
193
+ if (location.row >= Number(source.rows) ||
194
+ location.column >= Number(source.columns)) {
195
+ throw new Error(`Named frame ${frame} source location is outside ${source.density}x sheet geometry`);
196
+ }
197
+ }
198
+ }
199
+ await atomicDirectory(output, async (temp) => {
200
+ const variants = [];
201
+ let compiledBytes = 0;
202
+ for (const source of [...sources].sort((a, b) => a.density - b.density)) {
203
+ const cell = logicalCellSize * source.density;
204
+ const outputWidth = 16 * cell;
205
+ const outputHeight = rows * cell;
206
+ if (outputWidth * outputHeight * 4 > 64 * 1024 * 1024)
207
+ throw new Error("Compiled atlas allocation exceeds 64 MiB");
208
+ const atlas = new PNG({ width: outputWidth, height: outputHeight });
209
+ let sheet;
210
+ if (source.sheet) {
211
+ const sourceFile = await confinedSourceFile(sourceRoot, source.sheet, `${source.density}x source sheet`);
212
+ const bytes = await readBoundedFile(sourceFile, 32 * 1024 * 1024, `${source.density}x source sheet`);
213
+ const header = inspectImage(bytes, source.sheet);
214
+ if (header.mimeType !== "image/png" ||
215
+ header.width !== Number(source.columns) * cell ||
216
+ header.height !== Number(source.rows) * cell)
217
+ throw new Error(`${source.density}x source sheet geometry is invalid`);
218
+ sheet = PNG.sync.read(bytes);
219
+ }
220
+ for (const [frameName, index] of packedIndex) {
221
+ let frame;
222
+ if (sheet) {
223
+ const location = frameLocations.get(frameName);
224
+ if (!location)
225
+ throw new Error(`Named frame ${frameName} lacks a source row`);
226
+ frame = cropPng(sheet, location.column * cell, location.row * cell, cell);
227
+ }
228
+ else {
229
+ const relative = path.posix.join(source.directory.replaceAll("\\", "/"), `${frameName}.png`);
230
+ const file = await confinedSourceFile(sourceRoot, relative, frameName);
231
+ const frameBytes = await readBoundedFile(file, 4 * 1024 * 1024, frameName);
232
+ const header = inspectImage(frameBytes, file);
233
+ if (header.mimeType !== "image/png" ||
234
+ header.width !== cell ||
235
+ header.height !== cell)
236
+ throw new Error(`${frameName} must be exactly ${cell}x${cell}`);
237
+ frame = PNG.sync.read(frameBytes);
238
+ }
239
+ blitPng(frame, atlas, (index % 16) * cell, Math.floor(index / 16) * cell);
240
+ }
241
+ const bytes = PNG.sync.write(atlas, {
242
+ colorType: 6,
243
+ deflateLevel: 9,
244
+ filterType: 4,
245
+ });
246
+ if (bytes.length > 4 * 1024 * 1024)
247
+ throw new Error(`${source.density}x compiled atlas exceeds 4 MiB`);
248
+ compiledBytes += bytes.length;
249
+ if (compiledBytes > 8 * 1024 * 1024)
250
+ throw new Error("Compiled atlas variants exceed 8 MiB total");
251
+ const fileName = `atlas-${source.density}x.png`;
252
+ await writeFile(path.join(temp, fileName), bytes);
253
+ variants.push({
254
+ src: fileName,
255
+ density: source.density,
256
+ sourceCellSize: cell,
257
+ sha256: createHash("sha256").update(bytes).digest("hex"),
258
+ });
259
+ }
260
+ const manifest = {
261
+ format: 1,
262
+ name,
263
+ version,
264
+ license,
265
+ ...(author.metadata ? { metadata: author.metadata } : {}),
266
+ assets: {
267
+ atlases: {
268
+ columns: 16,
269
+ rows,
270
+ logicalCellSize,
271
+ lineage: typeof author.lineage === "string"
272
+ ? author.lineage
273
+ : `${name}-compiled-v1`,
274
+ variants,
275
+ },
276
+ },
277
+ states: compiledStates,
278
+ ...(author.capabilities === undefined
279
+ ? {}
280
+ : { capabilities: author.capabilities }),
281
+ defaults: { scale: author.scale ?? (logicalCellSize === 64 ? 1 : 2) },
282
+ };
283
+ await writeFile(path.join(temp, "character.json"), `${JSON.stringify(manifest, null, 2)}\n`);
284
+ for (const required of ["LICENSE", "PROVENANCE.md"])
285
+ await writeFile(path.join(temp, required), await readBoundedFile(await confinedSourceFile(sourceRoot, required, required), 256 * 1024, required));
286
+ await validatePackDirectory(temp);
287
+ });
288
+ }
289
+ function validateSourcePath(relative, label) {
290
+ if (typeof relative !== "string" ||
291
+ relative.length < 1 ||
292
+ relative.length > 256 ||
293
+ path.isAbsolute(relative) ||
294
+ relative.includes("\\") ||
295
+ relative.split("/").some((part) => !part || part === "." || part === "..")) {
296
+ throw new Error(`${label} must be a confined relative path`);
297
+ }
298
+ }
299
+ async function confinedSourceFile(sourceRoot, relative, label) {
300
+ validateSourcePath(relative, label);
301
+ const target = await realpath(path.join(sourceRoot, relative)).catch(() => {
302
+ throw new Error(`Missing required ${label}`);
303
+ });
304
+ const relation = path.relative(sourceRoot, target);
305
+ if (relation.startsWith("..") || path.isAbsolute(relation))
306
+ throw new Error(`${label} escapes the source directory`);
307
+ return target;
308
+ }
309
+ function cropPng(input, x0, y0, size) {
310
+ const output = new PNG({ width: size, height: size });
311
+ for (let y = 0; y < size; y++) {
312
+ const source = ((y0 + y) * input.width + x0) * 4;
313
+ input.data.copy(output.data, y * size * 4, source, source + size * 4);
314
+ }
315
+ return output;
316
+ }
317
+ function blitPng(source, target, x0, y0) {
318
+ for (let y = 0; y < source.height; y++) {
319
+ const from = y * source.width * 4;
320
+ const to = ((y0 + y) * target.width + x0) * 4;
321
+ source.data.copy(target.data, to, from, from + source.width * 4);
322
+ }
323
+ }
324
+ export async function createPack(target, name) {
325
+ if (!/^[a-z][a-z0-9-]{0,63}$/.test(name)) {
326
+ throw new Error("Pack name must be lowercase letters, numbers, and hyphens");
327
+ }
328
+ await atomicDirectory(target, async (temp) => {
329
+ const atlas = createFixtureAtlas();
330
+ const manifest = starterManifest(name, createHash("sha256").update(atlas).digest("hex"));
331
+ await writeFile(path.join(temp, "character.json"), `${JSON.stringify(manifest, null, 2)}\n`);
332
+ await writeFile(path.join(temp, "atlas.png"), atlas);
333
+ await writeFile(path.join(temp, "LICENSE"), "CC0 1.0 Universal\n\nThe synthetic geometric fixture in atlas.png is dedicated to the public domain. Replace it and this license before distributing different art.\n");
334
+ await writeFile(path.join(temp, "PROVENANCE.md"), "# Development fixture provenance\n\n`atlas.png` was generated algorithmically by `@peekling/cli`. It contains no downloaded or third-party character art and is a development placeholder, not release character art.\n");
335
+ });
336
+ }
337
+ async function scanPack(root) {
338
+ const rootInfo = await lstat(root).catch(() => {
339
+ throw new Error(`Pack directory does not exist: ${root}`);
340
+ });
341
+ if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory())
342
+ throw new Error(`Pack path must be a real directory, not a link: ${root}`);
343
+ let files = 0;
344
+ let bytes = 0;
345
+ let directories = 0;
346
+ async function visit(directory, depth) {
347
+ if (depth > 8)
348
+ throw new Error("Pack exceeds directory-depth limit");
349
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
350
+ const absolute = path.join(directory, entry.name);
351
+ const relative = path.relative(root, absolute);
352
+ const info = await lstat(absolute);
353
+ if (info.isSymbolicLink())
354
+ throw new Error(`Pack must not contain symbolic links: ${relative}`);
355
+ if (info.isDirectory()) {
356
+ directories++;
357
+ if (directories > 64)
358
+ throw new Error("Pack exceeds directory-count limit");
359
+ await visit(absolute, depth + 1);
360
+ }
361
+ else if (info.isFile()) {
362
+ files++;
363
+ bytes += info.size;
364
+ if (files > 32 || bytes > 8 * 1024 * 1024)
365
+ throw new Error("Pack exceeds file-count or total-size limits");
366
+ if (EXECUTABLE.test(entry.name))
367
+ throw new Error(`Pack contains executable content: ${relative}`);
368
+ }
369
+ }
370
+ }
371
+ await visit(root, 0);
372
+ }
373
+ async function readBoundedFile(target, limit, label) {
374
+ const value = await readMaybeBoundedFile(target, limit, label, false);
375
+ if (!value)
376
+ throw new Error(`Missing required ${label}: ${target}`);
377
+ return value;
378
+ }
379
+ async function readOptionalBoundedFile(target, limit, label) {
380
+ return readMaybeBoundedFile(target, limit, label, true);
381
+ }
382
+ async function readMaybeBoundedFile(target, limit, label, optional) {
383
+ let initial;
384
+ try {
385
+ initial = await lstat(target);
386
+ }
387
+ catch (error) {
388
+ if (error.code === "ENOENT") {
389
+ if (optional)
390
+ return undefined;
391
+ throw new Error(`Missing required ${label}: ${target}`);
392
+ }
393
+ throw error;
394
+ }
395
+ if (initial.isSymbolicLink() || !initial.isFile()) {
396
+ throw new Error(`${label} must be a regular file, not a link: ${target}`);
397
+ }
398
+ if (initial.size > limit)
399
+ throw new Error(`${label} exceeds ${limit} bytes`);
400
+ const handle = await open(target, "r").catch((error) => {
401
+ if (error.code === "ENOENT") {
402
+ throw new Error(`${label} changed before it could be read`);
403
+ }
404
+ throw error;
405
+ });
406
+ try {
407
+ const opened = await handle.stat();
408
+ if (!opened.isFile() ||
409
+ opened.dev !== initial.dev ||
410
+ opened.ino !== initial.ino) {
411
+ throw new Error(`${label} changed before it could be read`);
412
+ }
413
+ if (opened.size > limit)
414
+ throw new Error(`${label} exceeds ${limit} bytes`);
415
+ const bytes = Buffer.alloc(Math.min(limit + 1, opened.size + 1));
416
+ let offset = 0;
417
+ while (offset < bytes.byteLength) {
418
+ const result = await handle.read(bytes, offset, bytes.byteLength - offset, offset);
419
+ if (result.bytesRead === 0)
420
+ break;
421
+ offset += result.bytesRead;
422
+ }
423
+ const after = await handle.stat();
424
+ if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs) {
425
+ throw new Error(`${label} changed while it was being read`);
426
+ }
427
+ if (offset > limit)
428
+ throw new Error(`${label} exceeds ${limit} bytes`);
429
+ return bytes.subarray(0, offset);
430
+ }
431
+ finally {
432
+ await handle.close();
433
+ }
434
+ }
435
+ export async function validatePackDirectory(target) {
436
+ const root = path.resolve(target);
437
+ await scanPack(root);
438
+ const manifestPath = path.join(root, "character.json");
439
+ const manifestText = (await readBoundedFile(manifestPath, 64 * 1024, "manifest")).toString("utf8");
440
+ const raw = parseManifestText(manifestText);
441
+ const preliminary = validateNativePack(raw);
442
+ const variants = preliminary.atlas.variants ?? [
443
+ {
444
+ src: preliminary.atlas.src,
445
+ density: preliminary.atlas.density ?? 1,
446
+ cellWidth: preliminary.atlas.cellWidth,
447
+ cellHeight: preliminary.atlas.cellHeight,
448
+ sha256: preliminary.atlas.sha256,
449
+ },
450
+ ];
451
+ let pack = preliminary;
452
+ let totalBytes = 0;
453
+ let largest = { width: 0, height: 0, bytes: 0 };
454
+ for (const variant of variants) {
455
+ const atlasPath = path.resolve(root, variant.src);
456
+ if (!atlasPath.startsWith(`${root}${path.sep}`))
457
+ throw new Error("Atlas path escapes the pack root");
458
+ const atlas = await readBoundedFile(atlasPath, 4 * 1024 * 1024, `declared ${variant.density}x atlas`);
459
+ const image = inspectImage(atlas, variant.src);
460
+ if (image.mimeType !== "image/png")
461
+ throw new Error("Native v0.1 atlas variants must be PNG");
462
+ if (!image.hasAlpha)
463
+ throw new Error(`Native ${variant.density}x atlas requires alpha`);
464
+ if (createHash("sha256").update(atlas).digest("hex") !== variant.sha256)
465
+ throw new Error(`Native ${variant.density}x atlas SHA-256 does not match`);
466
+ pack = validateNativePack(raw, {
467
+ width: image.width,
468
+ height: image.height,
469
+ byteLength: atlas.length,
470
+ }, variant.density);
471
+ totalBytes += atlas.length;
472
+ if (image.width * image.height > largest.width * largest.height)
473
+ largest = {
474
+ width: image.width,
475
+ height: image.height,
476
+ bytes: atlas.length,
477
+ };
478
+ }
479
+ if (totalBytes > 8 * 1024 * 1024)
480
+ throw new Error("Native atlas variants exceed the 8 MiB pack limit");
481
+ const license = (await readOptionalBoundedFile(path.join(root, "LICENSE"), 256 * 1024, "LICENSE"))?.toString("utf8");
482
+ if (!license?.trim())
483
+ throw new Error("Pack requires a non-empty LICENSE file");
484
+ return {
485
+ name: pack.name,
486
+ version: pack.version,
487
+ atlas: largest,
488
+ states: Object.keys(pack.states).length,
489
+ };
490
+ }
491
+ async function codexSource(input) {
492
+ const info = await lstat(input);
493
+ if (info.isDirectory()) {
494
+ const pet = await readBoundedFile(path.join(input, "pet.json"), 64 * 1024, "pet.json");
495
+ let atlasName = "spritesheet.webp";
496
+ let atlas = await readOptionalBoundedFile(path.join(input, atlasName), 32 * 1024 * 1024, atlasName);
497
+ if (!atlas) {
498
+ atlasName = "spritesheet.png";
499
+ atlas = await readBoundedFile(path.join(input, atlasName), 32 * 1024 * 1024, atlasName);
500
+ }
501
+ const license = await readOptionalBoundedFile(path.join(input, "LICENSE"), 256 * 1024, "LICENSE");
502
+ return { pet, atlas, atlasName, ...(license ? { license } : {}) };
503
+ }
504
+ if (!input.endsWith(".codex-pet"))
505
+ throw new Error("Codex input must be a directory or .codex-pet archive");
506
+ if (info.isSymbolicLink() || !info.isFile())
507
+ throw new Error("Codex input must be a regular directory or archive");
508
+ const archive = await readBoundedFile(input, 40 * 1024 * 1024, ".codex-pet archive");
509
+ const entries = readZipEntries(archive);
510
+ const pet = entries.get("pet.json");
511
+ const atlasName = entries.has("spritesheet.webp")
512
+ ? "spritesheet.webp"
513
+ : "spritesheet.png";
514
+ const atlas = entries.get(atlasName);
515
+ if (!pet || !atlas)
516
+ throw new Error(".codex-pet must contain root pet.json and spritesheet.webp");
517
+ const license = entries.get("LICENSE");
518
+ return { pet, atlas, atlasName, archive, ...(license ? { license } : {}) };
519
+ }
520
+ export async function importCodexPet(input, output, metadata) {
521
+ for (const [field, value] of Object.entries(metadata)) {
522
+ if (field !== "allowPng" &&
523
+ (typeof value !== "string" || value.trim() === "")) {
524
+ throw new Error(`Codex import requires --${field}`);
525
+ }
526
+ }
527
+ const source = await codexSource(path.resolve(input));
528
+ if (source.atlasName === "spritesheet.png" && !metadata.allowPng) {
529
+ throw new Error("PNG compatibility input requires --allow-png and must be self-hosted");
530
+ }
531
+ const pet = parseDataText(decodeJsonUtf8(source.pet, "pet.json"), "pet.json");
532
+ const image = inspectImage(source.atlas, source.atlasName);
533
+ const sidecar = {
534
+ format: 1,
535
+ adapter: "codex-pet-v2",
536
+ license: metadata.license,
537
+ provenance: {
538
+ author: metadata.author,
539
+ source: metadata.source,
540
+ rights: metadata.rights,
541
+ },
542
+ };
543
+ adaptCodexPetV2({
544
+ pet,
545
+ sidecar,
546
+ atlas: {
547
+ fileName: source.atlasName,
548
+ width: image.width,
549
+ height: image.height,
550
+ mimeType: image.mimeType,
551
+ hasAlpha: image.hasAlpha,
552
+ sha256: createHash("sha256").update(source.atlas).digest("hex"),
553
+ byteLength: source.atlas.length,
554
+ },
555
+ ...(metadata.allowPng ? { allowSelfHostedPng: true } : {}),
556
+ });
557
+ await atomicDirectory(output, async (temp) => {
558
+ await writeFile(path.join(temp, "pet.json"), source.pet);
559
+ await writeFile(path.join(temp, source.atlasName), source.atlas);
560
+ await writeFile(path.join(temp, "peekling.json"), `${JSON.stringify(sidecar, null, 2)}\n`);
561
+ if (source.license)
562
+ await writeFile(path.join(temp, "LICENSE"), source.license);
563
+ else
564
+ await writeFile(path.join(temp, "LICENSE"), `${metadata.license}\n`);
565
+ if (source.archive)
566
+ await writeFile(path.join(temp, "source.codex-pet"), source.archive);
567
+ });
568
+ }
569
+ export { createFixtureAtlas } from "./png.js";
package/dist/png.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ export declare function createFixtureAtlas(rows?: number): Buffer;
2
+ export declare function inspectPng(buffer: Buffer): {
3
+ width: number;
4
+ height: number;
5
+ hasAlpha: boolean;
6
+ };
7
+ //# sourceMappingURL=png.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"png.d.ts","sourceRoot":"","sources":["../src/png.ts"],"names":[],"mappings":"AA0CA,wBAAgB,kBAAkB,CAAC,IAAI,SAAI,GAAG,MAAM,CA8CnD;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG;IAC1C,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;CACnB,CA2DA"}