260 lines
8.5 KiB
JavaScript
260 lines
8.5 KiB
JavaScript
#!/usr/bin/env node
|
|
import { readFile, stat, writeFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import process from 'node:process';
|
|
|
|
const USAGE = `Usage:
|
|
node scripts/package-pet.mjs <pet-folder> [--out <file.zip>] [--flat] [--dry-run]
|
|
|
|
Build an app-importable Hermes-Relay pet ZIP from a folder containing pet.json.
|
|
Only pet.json and assets referenced by the manifest are included.
|
|
`;
|
|
|
|
function parseArgs(argv) {
|
|
const args = { input: '', out: '', flat: false, dryRun: false };
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const arg = argv[i];
|
|
if (arg === '--help' || arg === '-h') {
|
|
console.log(USAGE);
|
|
process.exit(0);
|
|
}
|
|
if (arg === '--out' || arg === '-o') {
|
|
args.out = argv[++i] || '';
|
|
continue;
|
|
}
|
|
if (arg === '--flat') {
|
|
args.flat = true;
|
|
continue;
|
|
}
|
|
if (arg === '--dry-run') {
|
|
args.dryRun = true;
|
|
continue;
|
|
}
|
|
if (!args.input) {
|
|
args.input = arg;
|
|
continue;
|
|
}
|
|
throw new Error(`Unexpected argument: ${arg}`);
|
|
}
|
|
if (!args.input) throw new Error('Missing pet folder.');
|
|
return args;
|
|
}
|
|
|
|
function sanitizeId(value) {
|
|
return String(value || 'pet')
|
|
.trim()
|
|
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
.replace(/^[.-]+|[.-]+$/g, '') || 'pet';
|
|
}
|
|
|
|
function normalizeRef(raw, context) {
|
|
if (typeof raw !== 'string' || raw.trim() === '') {
|
|
throw new Error(`${context} references a blank path.`);
|
|
}
|
|
const ref = raw.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
const parts = ref.split('/').filter(Boolean);
|
|
if (path.isAbsolute(raw) || /^[A-Za-z]:/.test(raw) || parts.some((part) => part === '..')) {
|
|
throw new Error(`${context} references an unsafe path: ${raw}`);
|
|
}
|
|
return parts.join('/');
|
|
}
|
|
|
|
function isPositiveNumber(value) {
|
|
return Number.isFinite(Number(value)) && Number(value) > 0;
|
|
}
|
|
|
|
function collectClipAssets(clip, name, assetRefs, warnings) {
|
|
if (!clip || typeof clip !== 'object' || Array.isArray(clip)) {
|
|
throw new Error(`${name} must be an object clip.`);
|
|
}
|
|
|
|
const hasFrames = Array.isArray(clip.frames) && clip.frames.length > 0;
|
|
const hasSheet = typeof clip.sheet === 'string' && clip.sheet.trim() !== '';
|
|
|
|
if (!hasFrames && !hasSheet) {
|
|
throw new Error(`${name} needs either a non-empty frames list or a sheet.`);
|
|
}
|
|
if (hasFrames && hasSheet) {
|
|
warnings.push(`${name} declares both frames and sheet; the app uses frames first.`);
|
|
}
|
|
|
|
if (hasFrames) {
|
|
clip.frames.forEach((frame, index) => {
|
|
assetRefs.add(normalizeRef(frame, `${name}.frames[${index}]`));
|
|
});
|
|
} else {
|
|
assetRefs.add(normalizeRef(clip.sheet, `${name}.sheet`));
|
|
for (const field of ['frameWidth', 'frameHeight', 'frameCount']) {
|
|
if (!isPositiveNumber(clip[field])) throw new Error(`${name}.${field} must be positive for a sheet clip.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function existsFile(file) {
|
|
try {
|
|
return (await stat(file)).isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function validatePack(packDir) {
|
|
const manifestPath = path.join(packDir, 'pet.json');
|
|
if (!(await existsFile(manifestPath))) throw new Error(`No pet.json found at ${manifestPath}`);
|
|
|
|
const manifestText = await readFile(manifestPath, 'utf8');
|
|
let spec;
|
|
try {
|
|
spec = JSON.parse(manifestText);
|
|
} catch (error) {
|
|
throw new Error(`pet.json is not valid JSON: ${error.message}`);
|
|
}
|
|
|
|
if (!spec.states || typeof spec.states !== 'object' || Array.isArray(spec.states)) {
|
|
throw new Error('pet.json needs a states object.');
|
|
}
|
|
if (!spec.states.idle) throw new Error("pet.json needs a usable 'idle' clip.");
|
|
|
|
const warnings = [];
|
|
const assetRefs = new Set();
|
|
for (const [name, clip] of Object.entries(spec.states)) {
|
|
collectClipAssets(clip, `states.${name}`, assetRefs, warnings);
|
|
}
|
|
if (spec.defaults) collectClipAssets(spec.defaults, 'defaults', assetRefs, warnings);
|
|
|
|
const assets = [];
|
|
for (const ref of [...assetRefs].sort()) {
|
|
const resolved = path.resolve(packDir, ref);
|
|
const base = path.resolve(packDir) + path.sep;
|
|
if (!resolved.startsWith(base)) throw new Error(`Asset escapes the pack folder: ${ref}`);
|
|
if (!(await existsFile(resolved))) throw new Error(`Referenced asset is missing: ${ref}`);
|
|
assets.push({ ref, file: resolved });
|
|
}
|
|
|
|
const id = sanitizeId(spec.id || path.basename(packDir));
|
|
const label = typeof spec.label === 'string' && spec.label.trim() ? spec.label.trim() : id;
|
|
return { id, label, manifestText, assets, warnings };
|
|
}
|
|
|
|
const crcTable = new Uint32Array(256);
|
|
for (let n = 0; n < 256; n += 1) {
|
|
let c = n;
|
|
for (let k = 0; k < 8; k += 1) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
|
crcTable[n] = c >>> 0;
|
|
}
|
|
|
|
function crc32(buffer) {
|
|
let c = 0xffffffff;
|
|
for (const byte of buffer) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8);
|
|
return (c ^ 0xffffffff) >>> 0;
|
|
}
|
|
|
|
function dosDateTime(date = new Date()) {
|
|
const year = Math.max(1980, date.getFullYear());
|
|
const dosTime = (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2);
|
|
const dosDate = ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate();
|
|
return { dosTime, dosDate };
|
|
}
|
|
|
|
function buildZip(entries) {
|
|
const fileParts = [];
|
|
const centralParts = [];
|
|
let offset = 0;
|
|
const { dosTime, dosDate } = dosDateTime();
|
|
|
|
for (const entry of entries) {
|
|
const name = Buffer.from(entry.name, 'utf8');
|
|
const data = Buffer.from(entry.data);
|
|
const crc = crc32(data);
|
|
|
|
const local = Buffer.alloc(30 + name.length);
|
|
let p = 0;
|
|
local.writeUInt32LE(0x04034b50, p); p += 4;
|
|
local.writeUInt16LE(20, p); p += 2;
|
|
local.writeUInt16LE(0x0800, p); p += 2;
|
|
local.writeUInt16LE(0, p); p += 2;
|
|
local.writeUInt16LE(dosTime, p); p += 2;
|
|
local.writeUInt16LE(dosDate, p); p += 2;
|
|
local.writeUInt32LE(crc, p); p += 4;
|
|
local.writeUInt32LE(data.length, p); p += 4;
|
|
local.writeUInt32LE(data.length, p); p += 4;
|
|
local.writeUInt16LE(name.length, p); p += 2;
|
|
local.writeUInt16LE(0, p); p += 2;
|
|
name.copy(local, p);
|
|
|
|
const central = Buffer.alloc(46 + name.length);
|
|
p = 0;
|
|
central.writeUInt32LE(0x02014b50, p); p += 4;
|
|
central.writeUInt16LE(20, p); p += 2;
|
|
central.writeUInt16LE(20, p); p += 2;
|
|
central.writeUInt16LE(0x0800, p); p += 2;
|
|
central.writeUInt16LE(0, p); p += 2;
|
|
central.writeUInt16LE(dosTime, p); p += 2;
|
|
central.writeUInt16LE(dosDate, p); p += 2;
|
|
central.writeUInt32LE(crc, p); p += 4;
|
|
central.writeUInt32LE(data.length, p); p += 4;
|
|
central.writeUInt32LE(data.length, p); p += 4;
|
|
central.writeUInt16LE(name.length, p); p += 2;
|
|
central.writeUInt16LE(0, p); p += 2;
|
|
central.writeUInt16LE(0, p); p += 2;
|
|
central.writeUInt16LE(0, p); p += 2;
|
|
central.writeUInt16LE(0, p); p += 2;
|
|
central.writeUInt32LE(0, p); p += 4;
|
|
central.writeUInt32LE(offset, p); p += 4;
|
|
name.copy(central, p);
|
|
|
|
fileParts.push(local, data);
|
|
centralParts.push(central);
|
|
offset += local.length + data.length;
|
|
}
|
|
|
|
const centralOffset = offset;
|
|
const centralSize = centralParts.reduce((sum, part) => sum + part.length, 0);
|
|
const end = Buffer.alloc(22);
|
|
let p = 0;
|
|
end.writeUInt32LE(0x06054b50, p); p += 4;
|
|
end.writeUInt16LE(0, p); p += 2;
|
|
end.writeUInt16LE(0, p); p += 2;
|
|
end.writeUInt16LE(entries.length, p); p += 2;
|
|
end.writeUInt16LE(entries.length, p); p += 2;
|
|
end.writeUInt32LE(centralSize, p); p += 4;
|
|
end.writeUInt32LE(centralOffset, p); p += 4;
|
|
end.writeUInt16LE(0, p);
|
|
|
|
return Buffer.concat([...fileParts, ...centralParts, end]);
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const packDir = path.resolve(args.input);
|
|
const pack = await validatePack(packDir);
|
|
const top = args.flat ? '' : `${pack.id}/`;
|
|
const entries = [
|
|
{ name: `${top}pet.json`, data: Buffer.from(pack.manifestText, 'utf8') },
|
|
];
|
|
for (const asset of pack.assets) {
|
|
entries.push({ name: `${top}${asset.ref}`, data: await readFile(asset.file) });
|
|
}
|
|
|
|
const out = path.resolve(args.out || path.join(path.dirname(packDir), `${pack.id}.zip`));
|
|
const zip = buildZip(entries);
|
|
|
|
console.log(`Pet: ${pack.label} (${pack.id})`);
|
|
console.log(`Source: ${packDir}`);
|
|
console.log(`Entries: ${entries.length} (${entries.map((entry) => entry.name).join(', ')})`);
|
|
for (const warning of pack.warnings) console.warn(`Warning: ${warning}`);
|
|
|
|
if (args.dryRun) {
|
|
console.log(`Dry run: would write ${out} (${zip.length} bytes)`);
|
|
return;
|
|
}
|
|
|
|
await writeFile(out, zip);
|
|
console.log(`Wrote ${out} (${zip.length} bytes)`);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(`Error: ${error.message}`);
|
|
process.exit(1);
|
|
});
|