@staix/agent-console 0.0.0-stage → 0.1.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.
- package/CHANGELOG.md +49 -0
- package/LICENSE +21 -0
- package/README.md +354 -2
- package/bin/acon.js +8 -0
- package/package.json +38 -4
- package/shell/asset-viewer.css +5 -0
- package/shell/asset-viewer.html +11 -0
- package/shell/asset-viewer.js +23 -0
- package/shell/icon.svg +1 -0
- package/shell/index.html +53 -0
- package/shell/shell.css +534 -0
- package/shell/shell.js +2214 -0
- package/shell/space-view.js +164 -0
- package/shell/space.js +241 -0
- package/shell/space3d.js +364 -0
- package/shell/static.js +111 -0
- package/shell/vendor/BufferGeometryUtils.js +1523 -0
- package/shell/vendor/GLTFLoader.js +5006 -0
- package/shell/vendor/NOTICE +30 -0
- package/shell/vendor/SkeletonUtils.js +496 -0
- package/shell/vendor/manifest.json +42 -0
- package/shell/vendor/mermaid/NOTICE +25 -0
- package/shell/vendor/mermaid/manifest.json +19 -0
- package/shell/vendor/mermaid/mermaid.js +3580 -0
- package/shell/vendor/three.core.js +61162 -0
- package/shell/vendor/three.module.js +19537 -0
- package/shell/visual-frame.css +5 -0
- package/shell/visual-frame.html +14 -0
- package/shell/visual-frame.js +54 -0
- package/src/assets.ts +200 -0
- package/src/canonical.ts +16 -0
- package/src/cli.ts +246 -0
- package/src/conformance.ts +299 -0
- package/src/contract.ts +276 -0
- package/src/install.ts +248 -0
- package/src/pets.ts +63 -0
- package/src/plugins.ts +357 -0
- package/src/serve.ts +184 -0
- package/src/server.ts +462 -0
- package/src/setup.ts +74 -0
- package/src/space.ts +106 -0
- package/src/static.ts +102 -0
- package/test/fixtures/review/cases.json +726 -0
package/src/install.ts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { chmodSync, closeSync, constants, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, rmSync, writeSync } from "node:fs";
|
|
3
|
+
import { basename, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { validateAssetPack } from "./assets.ts";
|
|
5
|
+
import { validatePetPackage } from "./pets.ts";
|
|
6
|
+
|
|
7
|
+
/** acon install (#17): pet and asset packages downloaded separately from acon. */
|
|
8
|
+
export const DEFAULT_INDEX = "https://assets.entelecheia.ai/console/index.json";
|
|
9
|
+
|
|
10
|
+
export type PackageKind = "pet" | "pack";
|
|
11
|
+
export interface IndexPackage { kind: PackageKind; id: string; name: string; description?: string; manifest: string; size: number; files: Record<string, string> }
|
|
12
|
+
export interface AssetIndex { version: 1; packages: IndexPackage[] }
|
|
13
|
+
export interface InstallOptions { home: string; index?: string; force?: boolean; timeoutMs?: number }
|
|
14
|
+
export interface InstallResult { kind: PackageKind; id: string; dir: string; files: string[]; verified: boolean; replaced: boolean }
|
|
15
|
+
|
|
16
|
+
const ID = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
17
|
+
const FILE = /^[A-Za-z0-9._-]+$/;
|
|
18
|
+
const SHA = /^[0-9a-f]{64}$/;
|
|
19
|
+
const KB = 1024, MB = 1024 * KB;
|
|
20
|
+
export const LIMITS = { index: 1 * MB, manifest: 16 * KB, sheet: 8 * MB, image: 4 * MB, model: 4 * MB, package: 32 * MB };
|
|
21
|
+
const MANIFEST: Record<PackageKind, string> = { pet: "pet.json", pack: "pack.json" };
|
|
22
|
+
const LOOPBACK = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
|
|
23
|
+
|
|
24
|
+
export class InstallError extends Error {
|
|
25
|
+
constructor(message: string) { super(printable(message, 2000)); }
|
|
26
|
+
}
|
|
27
|
+
/** Drops C0/C1 control characters and DEL so index or manifest values cannot drive the terminal. */
|
|
28
|
+
export const printable = (text: string, max = 200): string => {
|
|
29
|
+
const clean = text.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
|
|
30
|
+
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
|
|
31
|
+
};
|
|
32
|
+
const fail = (message: string): never => { throw new InstallError(message); };
|
|
33
|
+
|
|
34
|
+
/** https anywhere; http only for loopback hosts (tests and local mirrors). */
|
|
35
|
+
export function allowedUrl(raw: string | URL): URL {
|
|
36
|
+
let url: URL;
|
|
37
|
+
try { url = new URL(raw); } catch { return fail(`not a valid URL: ${String(raw)}`); }
|
|
38
|
+
if (url.protocol === "https:") return url;
|
|
39
|
+
if (url.protocol === "http:" && LOOPBACK.has(url.hostname)) return url;
|
|
40
|
+
return fail(`refused ${url.href}: only https: (or http: on a loopback host) is allowed`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Downloads one file, following only https redirects, and stops reading as soon as the cap is passed. */
|
|
44
|
+
export async function fetchCapped(raw: string | URL, maxBytes: number, label: string, timeoutMs = 30_000): Promise<Buffer> {
|
|
45
|
+
let url = allowedUrl(raw);
|
|
46
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
47
|
+
for (let hop = 0; ; hop += 1) {
|
|
48
|
+
let res: Response;
|
|
49
|
+
try { res = await fetch(url, { redirect: "manual", signal }); }
|
|
50
|
+
catch (error) { return fail(`${label}: download failed (${(error as Error).name === "TimeoutError" ? `timed out after ${timeoutMs / 1000} s` : (error as Error).message})`); }
|
|
51
|
+
if (res.status >= 300 && res.status < 400) {
|
|
52
|
+
const location = res.headers.get("location");
|
|
53
|
+
await res.body?.cancel();
|
|
54
|
+
if (!location || hop >= 5) return fail(`${label}: too many or empty redirects`);
|
|
55
|
+
const next = new URL(location, url);
|
|
56
|
+
if (next.protocol !== "https:") return fail(`${label}: refused redirect to ${next.href}: redirects are followed only to https:`);
|
|
57
|
+
url = next;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (!res.ok || !res.body) { await res.body?.cancel(); return fail(`${label}: ${url.href} answered ${res.status}`); }
|
|
61
|
+
const declared = Number(res.headers.get("content-length"));
|
|
62
|
+
if (Number.isFinite(declared) && declared > maxBytes) { await res.body.cancel(); return fail(`${label} is larger than ${maxBytes} bytes`); }
|
|
63
|
+
const reader = res.body.getReader();
|
|
64
|
+
const parts: Uint8Array[] = [];
|
|
65
|
+
let total = 0;
|
|
66
|
+
try {
|
|
67
|
+
for (;;) {
|
|
68
|
+
const { value, done } = await reader.read();
|
|
69
|
+
if (done) break;
|
|
70
|
+
total += value.byteLength;
|
|
71
|
+
if (total > maxBytes) { await reader.cancel(); return fail(`${label} is larger than ${maxBytes} bytes`); }
|
|
72
|
+
parts.push(value);
|
|
73
|
+
}
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error instanceof InstallError) throw error;
|
|
76
|
+
return fail(`${label}: download failed (${(error as Error).name === "TimeoutError" ? `timed out after ${timeoutMs / 1000} s` : (error as Error).message})`);
|
|
77
|
+
}
|
|
78
|
+
return Buffer.concat(parts);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseIndex(bytes: Buffer): AssetIndex {
|
|
83
|
+
let value: unknown;
|
|
84
|
+
try { value = JSON.parse(bytes.toString("utf8")); } catch { return fail("index is not valid JSON"); }
|
|
85
|
+
const row = value as Record<string, unknown> | null;
|
|
86
|
+
if (!row || typeof row !== "object" || row.version !== 1 || !Array.isArray(row.packages)) return fail("index must be {version: 1, packages: [...]}");
|
|
87
|
+
const packages = row.packages.map((p, i): IndexPackage => {
|
|
88
|
+
const e = p as Record<string, unknown> | null;
|
|
89
|
+
const where = `index package ${i}`;
|
|
90
|
+
if (!e || typeof e !== "object") return fail(`${where} must be an object`);
|
|
91
|
+
if (e.kind !== "pet" && e.kind !== "pack") return fail(`${where}: kind must be pet or pack`);
|
|
92
|
+
if (typeof e.id !== "string" || !ID.test(e.id)) return fail(`${where}: id must match [a-z0-9][a-z0-9-]{0,31}`);
|
|
93
|
+
if (typeof e.name !== "string" || typeof e.manifest !== "string") return fail(`${where}: name and manifest must be strings`);
|
|
94
|
+
if (e.description !== undefined && typeof e.description !== "string") return fail(`${where}: description must be a string`);
|
|
95
|
+
if (!Number.isSafeInteger(e.size) || (e.size as number) < 0) return fail(`${where}: size must be a non-negative integer`);
|
|
96
|
+
if (!e.files || typeof e.files !== "object" || Array.isArray(e.files)) return fail(`${where}: files must be an object`);
|
|
97
|
+
const files = e.files as Record<string, unknown>;
|
|
98
|
+
for (const [name, sha] of Object.entries(files)) if (!FILE.test(name) || typeof sha !== "string" || !SHA.test(sha)) return fail(`${where}: file ${name} needs a plain name and a sha256`);
|
|
99
|
+
return { kind: e.kind, id: e.id, name: e.name, ...(typeof e.description === "string" ? { description: e.description } : {}), manifest: e.manifest, size: e.size as number, files: files as Record<string, string> };
|
|
100
|
+
});
|
|
101
|
+
return { version: 1, packages };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function fetchIndex(url: string, timeoutMs?: number): Promise<AssetIndex> {
|
|
105
|
+
return parseIndex(await fetchCapped(url, LIMITS.index, "index", timeoutMs));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface Planned { name: string; max: number; label: string }
|
|
109
|
+
|
|
110
|
+
/** Reads the manifest just enough to list the files it references; full validation runs on the staged folder. */
|
|
111
|
+
function plan(kind: PackageKind, bytes: Buffer): { id: string; files: Planned[] } {
|
|
112
|
+
const name = MANIFEST[kind];
|
|
113
|
+
let value: unknown;
|
|
114
|
+
try { value = JSON.parse(bytes.toString("utf8")); } catch { return fail(`${name} is not valid JSON`); }
|
|
115
|
+
const row = value as Record<string, unknown> | null;
|
|
116
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) return fail(`${name} must be an object`);
|
|
117
|
+
if (typeof row.id !== "string" || !ID.test(row.id)) return fail(`${name}: id must match [a-z0-9][a-z0-9-]{0,31}`);
|
|
118
|
+
const files: Planned[] = [];
|
|
119
|
+
const add = (file: unknown, max: number, label: string) => {
|
|
120
|
+
if (typeof file !== "string" || !FILE.test(file) || file === "." || file === "..") return fail(`${label}: file name ${JSON.stringify(file)} must be a plain name in the package`);
|
|
121
|
+
if (file === name) return fail(`${label}: may not reuse ${name}`);
|
|
122
|
+
if (!files.some((f) => f.name === file)) files.push({ name: file, max, label });
|
|
123
|
+
};
|
|
124
|
+
if (kind === "pet") add(row.spritesheetPath, LIMITS.sheet, "the sprite sheet");
|
|
125
|
+
else {
|
|
126
|
+
for (const field of ["images", "models"] as const) {
|
|
127
|
+
const map = row[field];
|
|
128
|
+
if (map === undefined && field === "models") continue;
|
|
129
|
+
if (!map || typeof map !== "object" || Array.isArray(map)) return fail(`pack.json: ${field} must be an object`);
|
|
130
|
+
for (const [logical, file] of Object.entries(map)) {
|
|
131
|
+
if (field === "models" && (typeof file !== "string" || !file.endsWith(".glb"))) return fail(`model ${logical}: file must be a .glb name`);
|
|
132
|
+
add(file, field === "images" ? LIMITS.image : LIMITS.model, `${field === "images" ? "image" : "model"} ${logical}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { id: row.id, files };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const sha256 = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
|
|
140
|
+
const exists = (path: string) => lstatSync(path, { throwIfNoEntry: false }) !== undefined;
|
|
141
|
+
const rootOf = (home: string, kind: PackageKind) => join(home, "console", kind === "pet" ? "pets" : "assets");
|
|
142
|
+
|
|
143
|
+
function writePrivate(file: string, bytes: Uint8Array): void {
|
|
144
|
+
const fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
145
|
+
try { let at = 0; while (at < bytes.byteLength) at += writeSync(fd, bytes, at); } finally { closeSync(fd); }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Runs the same validation the console runs at start; validateAssetPack also checks every model as a self-contained GLB 2. */
|
|
149
|
+
function validateStaged(kind: PackageKind, dir: string): string | undefined {
|
|
150
|
+
const result = kind === "pet" ? validatePetPackage(dir) : validateAssetPack(dir);
|
|
151
|
+
if (typeof result === "string") return result;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function kindOfManifest(name: string): PackageKind {
|
|
155
|
+
if (name === "pet.json") return "pet";
|
|
156
|
+
if (name === "pack.json") return "pack";
|
|
157
|
+
return fail(`expected a pet.json or pack.json, got ${name || "nothing"}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
interface Source { kind: PackageKind; id?: string; manifestUrl?: URL; dir?: string; hashes?: Record<string, string> }
|
|
161
|
+
|
|
162
|
+
async function resolveSource(source: string, o: InstallOptions): Promise<Source> {
|
|
163
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(source)) {
|
|
164
|
+
const url = allowedUrl(source);
|
|
165
|
+
return { kind: kindOfManifest(basename(url.pathname)), manifestUrl: url };
|
|
166
|
+
}
|
|
167
|
+
const local = resolve(source);
|
|
168
|
+
// A bare id always means the index package; a directory must be written as a path (D1/D2).
|
|
169
|
+
if (source.includes("/") || source.startsWith(".") || isAbsolute(source)) {
|
|
170
|
+
if (!lstatSync(local, { throwIfNoEntry: false })?.isDirectory()) return fail(`${source} is not a directory`);
|
|
171
|
+
const kind: PackageKind = exists(join(local, "pet.json")) ? "pet" : exists(join(local, "pack.json")) ? "pack" : fail(`${source} has no pet.json or pack.json`);
|
|
172
|
+
return { kind, dir: local };
|
|
173
|
+
}
|
|
174
|
+
if (!ID.test(source)) return fail(`${source} is not a package name, an https URL or a directory`);
|
|
175
|
+
const indexUrl = o.index ?? DEFAULT_INDEX;
|
|
176
|
+
const index = await fetchIndex(indexUrl, o.timeoutMs);
|
|
177
|
+
const entry = index.packages.find((p) => p.id === source);
|
|
178
|
+
if (!entry) return fail(`${source} is not in the index at ${indexUrl}; see acon install --list`);
|
|
179
|
+
const manifestUrl = allowedUrl(new URL(entry.manifest, indexUrl));
|
|
180
|
+
if (basename(manifestUrl.pathname) !== MANIFEST[entry.kind]) return fail(`index entry ${source}: manifest must be a ${MANIFEST[entry.kind]}`);
|
|
181
|
+
return { kind: entry.kind, id: entry.id, manifestUrl, hashes: entry.files };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Installs into $home/console/{pets,assets}/<id>: staging folder, full validation, then one rename. */
|
|
185
|
+
export async function installPackage(source: string, o: InstallOptions): Promise<InstallResult> {
|
|
186
|
+
const src = await resolveSource(source, o);
|
|
187
|
+
const root = rootOf(o.home, src.kind);
|
|
188
|
+
const refuseExisting = (id: string) => { if (!o.force && exists(join(root, id))) fail(`${src.kind} ${id} is already installed at ${join(root, id)}; use --force to replace it`); };
|
|
189
|
+
if (src.id) refuseExisting(src.id);
|
|
190
|
+
|
|
191
|
+
const manifestName = MANIFEST[src.kind];
|
|
192
|
+
const read = async (name: string, max: number, label: string): Promise<Buffer> => {
|
|
193
|
+
if (src.dir) {
|
|
194
|
+
const file = join(src.dir, name);
|
|
195
|
+
const stat = lstatSync(file, { throwIfNoEntry: false });
|
|
196
|
+
if (!stat?.isFile()) return fail(`${label} is missing or not a regular file`);
|
|
197
|
+
if (stat.size > max) return fail(`${label} is larger than ${max} bytes`);
|
|
198
|
+
return readFileSync(file);
|
|
199
|
+
}
|
|
200
|
+
// The manifest is fetched exactly as given (a signed URL keeps its query); only referenced files resolve against it.
|
|
201
|
+
const bytes = await fetchCapped(name === manifestName ? src.manifestUrl! : new URL(name, src.manifestUrl), max, label, o.timeoutMs);
|
|
202
|
+
if (src.hashes) {
|
|
203
|
+
const want = src.hashes[name];
|
|
204
|
+
if (!want) return fail(`${label} (${name}) has no sha256 in the index`);
|
|
205
|
+
if (sha256(bytes) !== want) return fail(`${name} does not match its sha256 in the index`);
|
|
206
|
+
}
|
|
207
|
+
return bytes;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const manifest = await read(manifestName, LIMITS.manifest, manifestName);
|
|
211
|
+
const { id, files } = plan(src.kind, manifest);
|
|
212
|
+
if (src.id && id !== src.id) fail(`${manifestName} id ${id} does not match the index id ${src.id}`);
|
|
213
|
+
refuseExisting(id);
|
|
214
|
+
|
|
215
|
+
const staging = join(o.home, "console", ".install");
|
|
216
|
+
mkdirSync(staging, { recursive: true, mode: 0o700 });
|
|
217
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
218
|
+
const stage = mkdtempSync(join(staging, `${id}-`));
|
|
219
|
+
chmodSync(stage, 0o700);
|
|
220
|
+
let old: string | undefined;
|
|
221
|
+
try {
|
|
222
|
+
writePrivate(join(stage, manifestName), manifest);
|
|
223
|
+
let total = manifest.byteLength;
|
|
224
|
+
for (const f of files) {
|
|
225
|
+
const bytes = await read(f.name, f.max, f.label);
|
|
226
|
+
total += bytes.byteLength;
|
|
227
|
+
if (total > LIMITS.package) fail(`package ${id} is larger than ${LIMITS.package} bytes`);
|
|
228
|
+
writePrivate(join(stage, f.name), bytes);
|
|
229
|
+
}
|
|
230
|
+
const problem = validateStaged(src.kind, stage);
|
|
231
|
+
if (problem) fail(`${src.kind} ${id}: ${problem}`);
|
|
232
|
+
const target = join(root, id);
|
|
233
|
+
if (exists(target)) {
|
|
234
|
+
if (!o.force) fail(`${src.kind} ${id} is already installed at ${target}; use --force to replace it`);
|
|
235
|
+
old = mkdtempSync(join(staging, `${id}-old-`));
|
|
236
|
+
renameSync(target, join(old, id));
|
|
237
|
+
}
|
|
238
|
+
renameSync(stage, target);
|
|
239
|
+
if (old) rmSync(old, { recursive: true, force: true });
|
|
240
|
+
return { kind: src.kind, id, dir: target, files: [manifestName, ...files.map((f) => f.name)], verified: Boolean(src.hashes), replaced: Boolean(old) };
|
|
241
|
+
} catch (error) {
|
|
242
|
+
rmSync(stage, { recursive: true, force: true });
|
|
243
|
+
// Put the previous package back if the final rename failed after moving it aside.
|
|
244
|
+
if (old && !exists(join(root, id))) try { renameSync(join(old, id), join(root, id)); } catch { /* reported below */ }
|
|
245
|
+
if (old) rmSync(old, { recursive: true, force: true });
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
}
|
package/src/pets.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { lstatSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { validateImage } from "./assets.ts";
|
|
5
|
+
|
|
6
|
+
/** A Codex v2 pet package (pet.json + an 8x11 sprite sheet of 192x208 cells), read from a local directory. */
|
|
7
|
+
export interface PetPackage { id: string; name: string; description?: string; file: string; type: "image/webp" | "image/png" }
|
|
8
|
+
|
|
9
|
+
const PET_ID = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
10
|
+
const SHEET = /^[A-Za-z0-9._-]+\.(webp|png)$/;
|
|
11
|
+
const MANIFEST_MAX = 16 * 1024;
|
|
12
|
+
const SHEET_MAX = 8 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
/** Console pets first, then Codex's ($CODEX_HOME, then ~/.codex, since account switchers move CODEX_HOME); the first id wins. */
|
|
15
|
+
export const petDirs = (home: string, env: Record<string, string | undefined> = process.env, user = homedir()): string[] =>
|
|
16
|
+
[...new Set([join(home, "console", "pets"), ...(env.CODEX_HOME ? [join(env.CODEX_HOME, "pets")] : []), join(user, ".codex", "pets")])];
|
|
17
|
+
|
|
18
|
+
/** Validates one pet package directory; shared with the installer. */
|
|
19
|
+
export function validatePetPackage(dir: string): PetPackage | string {
|
|
20
|
+
const manifest = join(dir, "pet.json");
|
|
21
|
+
const stat = lstatSync(manifest, { throwIfNoEntry: false });
|
|
22
|
+
if (!stat?.isFile()) return "pet.json is missing or not a regular file";
|
|
23
|
+
if (stat.size > MANIFEST_MAX) return "pet.json is larger than 16 KB";
|
|
24
|
+
let row: Record<string, unknown>;
|
|
25
|
+
try {
|
|
26
|
+
const value: unknown = JSON.parse(readFileSync(manifest, "utf8"));
|
|
27
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return "pet.json must be an object";
|
|
28
|
+
row = value as Record<string, unknown>;
|
|
29
|
+
} catch { return "pet.json is not valid JSON"; }
|
|
30
|
+
if (typeof row.id !== "string" || !PET_ID.test(row.id)) return "id must match [a-z0-9][a-z0-9-]{0,31}";
|
|
31
|
+
if (typeof row.displayName !== "string" || !row.displayName.trim() || row.displayName.length > 64) return "displayName must be a string of 1-64 characters";
|
|
32
|
+
if (row.description !== undefined && (typeof row.description !== "string" || row.description.length > 200)) return "description must be a string of at most 200 characters";
|
|
33
|
+
if (row.spriteVersionNumber !== 2) return "spriteVersionNumber must be 2";
|
|
34
|
+
if (typeof row.spritesheetPath !== "string" || !SHEET.test(row.spritesheetPath)) return "spritesheetPath must be a .webp or .png file name in the package";
|
|
35
|
+
const file = join(dir, row.spritesheetPath);
|
|
36
|
+
const image = validateImage(file, SHEET_MAX, "the sprite sheet");
|
|
37
|
+
if (typeof image === "string") return image;
|
|
38
|
+
return { id: row.id, name: row.displayName.trim(), ...(row.description ? { description: row.description } : {}), ...image };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Scans once; a missing directory is not a problem, an invalid package is skipped and named. */
|
|
42
|
+
export function loadPets(dirs: string[]): { pets: PetPackage[]; problems: string[] } {
|
|
43
|
+
const pets: PetPackage[] = [];
|
|
44
|
+
const problems: string[] = [];
|
|
45
|
+
for (const dir of dirs) {
|
|
46
|
+
let names: string[];
|
|
47
|
+
try { names = readdirSync(dir).sort(); } catch { continue; }
|
|
48
|
+
for (const name of names) {
|
|
49
|
+
const path = join(dir, name);
|
|
50
|
+
// One unreadable package (permissions, a vanished file) is skipped and named; it never stops the console.
|
|
51
|
+
let result: PetPackage | string;
|
|
52
|
+
try {
|
|
53
|
+
if (!lstatSync(path, { throwIfNoEntry: false })?.isDirectory()) continue;
|
|
54
|
+
result = validatePetPackage(path);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
result = `could not be read (${(error as NodeJS.ErrnoException).code ?? "error"})`;
|
|
57
|
+
}
|
|
58
|
+
if (typeof result === "string") problems.push(`${path}: ${result}`);
|
|
59
|
+
else if (!pets.some((p) => p.id === result.id)) pets.push(result);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { pets, problems };
|
|
63
|
+
}
|