gm-pantry 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dapper Mink
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 CHANGED
@@ -1,3 +1,91 @@
1
- # Temporary Holding Version
1
+ # GM-Pantry
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A format for game repositories: a static directory served over HTTPS that lists game archives, with
4
+ a TypeScript library, a validator and a CLI. The specification is [SPEC.md](SPEC.md), at revision 1.
5
+
6
+ ## The format at a glance
7
+
8
+ A repo is a directory. Its entry point is `repo.json`, and the URL of that file is the repo's identity.
9
+
10
+ ```json
11
+ {
12
+ "schema": 1,
13
+ "name": "Example Pantry",
14
+ "updated": "2026-11-15",
15
+ "games": [
16
+ {
17
+ "id": "my-fangame",
18
+ "title": "My Fangame",
19
+ "work": { "delfruit": 1234 },
20
+ "versions": [
21
+ { "version": "1.0", "file": { "url": "My_Fangame.zip", "sha256": "<64 hex digits>", "size": 1048576 } }
22
+ ]
23
+ }
24
+ ]
25
+ }
26
+ ```
27
+
28
+ - Archives are stored exactly as distributed and are never modified. Their sha256 is checked after
29
+ download; a mismatch discards the file.
30
+ - File urls may be relative, so a mirror of the directory is a working repo.
31
+ - Unknown fields are ignored; a malformed game or version is skipped and the rest of the repo still loads.
32
+ - Nothing lists or indexes repos. A private repo is an unguessable URL.
33
+
34
+ ## CLI
35
+
36
+ ```
37
+ npx gm-pantry init <dir> [--name NAME]
38
+ npx gm-pantry build <dir>
39
+ npx gm-pantry add <dir> <archive> [--id ID] [--title TITLE] [--version LABEL]
40
+ npx gm-pantry validate <url|path> [--hosting]
41
+ ```
42
+
43
+ - `build` hashes every `.zip`, `.7z` and `.rar` at the top of the folder (streaming), derives each
44
+ game's id and title from the file name, and writes `repo.json`. An existing `repo.json` is kept:
45
+ hand-edited metadata, other versions and unknown fields survive, and a changed archive only has its
46
+ hash and size refreshed. It never deletes an entry. `updated` moves only when something changed, so
47
+ a rebuild of an unchanged folder is byte-identical.
48
+ - `add` does the same for one archive, which must be inside the directory, as a new newest version.
49
+ - `validate` parses a `repo.json` (a URL, a folder or a file) and reports every skipped entry; for a
50
+ folder it also checks each archive's hash. `--hosting` (URL only) fetches `repo.json` and a sample
51
+ archive and checks CORS, Range, content types and validators. Exit code 1 on any problem.
52
+
53
+ ## Library
54
+
55
+ ```ts
56
+ import { parseRepo, resolveUrl, verifyArchive, validateHosting } from "gm-pantry";
57
+
58
+ const { repo, warnings } = parseRepo(await (await fetch(url)).json(), url); // throws RepoError if unusable
59
+ const result = await verifyArchive(bytes, repo.games[0].versions[0].file); // { ok: true } or { ok: false, ... }
60
+ ```
61
+
62
+ The library uses Web Crypto and `fetch` only, so it runs in browsers and Node. Only the CLI uses Node
63
+ APIs.
64
+
65
+ ## Hosting
66
+
67
+ The host must serve HTTPS and send `Access-Control-Allow-Origin: *` on `repo.json` and the archives.
68
+ `Range` on archives and `ETag`/`Last-Modified` on `repo.json` are recommended. Check with
69
+ `gm-pantry validate <url> --hosting`.
70
+
71
+ - **GitHub Pages** serves `Access-Control-Allow-Origin: *` already. Keep archives under its size limits.
72
+ - **Amazon S3** needs a CORS rule on the bucket:
73
+
74
+ ```json
75
+ [{ "AllowedOrigins": ["*"], "AllowedMethods": ["GET", "HEAD"], "AllowedHeaders": ["Range", "If-None-Match", "If-Modified-Since"], "ExposeHeaders": ["Content-Length", "Content-Range", "ETag"], "MaxAgeSeconds": 86400 }]
76
+ ```
77
+
78
+ - **Cloudflare R2** takes the same rule in the bucket's CORS policy (dashboard, Settings, CORS policy).
79
+ - **Backblaze B2** needs CORS rules on the bucket allowing `GET`/`HEAD` from any origin (its S3
80
+ compatible API accepts the S3 rule above).
81
+
82
+ ## Development
83
+
84
+ ```
85
+ pnpm install
86
+ pnpm run typecheck && pnpm test && pnpm run build
87
+ ```
88
+
89
+ `conformance/` holds `repo.json` inputs with their expected result; a reader in any language can use them.
90
+
91
+ MIT licensed.
@@ -0,0 +1,29 @@
1
+ type Json = Record<string, any>;
2
+ export declare const ARCHIVE_EXTENSIONS: string[];
3
+ export declare function hashFile(path: string): Promise<{
4
+ sha256: string;
5
+ size: number;
6
+ }>;
7
+ export declare const slug: (s: string) => string;
8
+ export declare function readRepoJson(dir: string): Promise<Json | undefined>;
9
+ export declare function emptyRepo(name: string): Json;
10
+ /** Adds an archive, or refreshes sha256 and size of the version that already points at it. */
11
+ export declare function upsertArchive(repo: Json, url: string, hashed: {
12
+ sha256: string;
13
+ size: number;
14
+ }, opts?: {
15
+ id?: string;
16
+ title?: string;
17
+ version?: string;
18
+ }): void;
19
+ /** Serialises repo.json; `updated` moves only when something else changed, so a rebuild is byte-identical. */
20
+ export declare function writeRepoJson(dir: string, repo: Json, now?: string): Promise<boolean>;
21
+ /** Every archive in `dir` (top level only), by name, as repo-relative urls. */
22
+ export declare function listArchives(dir: string): Promise<string[]>;
23
+ export declare function buildRepo(dir: string, name?: string, now?: string): Promise<{
24
+ changed: boolean;
25
+ count: number;
26
+ }>;
27
+ /** The repo-relative url of an archive that must live inside the repo directory. */
28
+ export declare function relativeUrl(dir: string, archive: string): Promise<string>;
29
+ export {};
package/dist/build.js ADDED
@@ -0,0 +1,93 @@
1
+ // Writing a repo.json (SPEC.md §7): node-only, kept out of the library entry point.
2
+ // Works on the raw JSON, never on a parsed Repo, so hand-edited fields and unknown fields survive.
3
+ import { createHash } from "node:crypto";
4
+ import { createReadStream } from "node:fs";
5
+ import { readFile, readdir, stat, writeFile } from "node:fs/promises";
6
+ import { basename, extname, join, relative, sep } from "node:path";
7
+ export const ARCHIVE_EXTENSIONS = [".zip", ".7z", ".rar"];
8
+ export async function hashFile(path) {
9
+ const hash = createHash("sha256");
10
+ let size = 0;
11
+ for await (const chunk of createReadStream(path)) {
12
+ hash.update(chunk);
13
+ size += chunk.length;
14
+ }
15
+ return { sha256: hash.digest("hex"), size };
16
+ }
17
+ export const slug = (s) => s.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
18
+ /** "My_Game-v2.zip" -> "My Game-v2" */
19
+ const titleOf = (file) => basename(file, extname(file)).replace(/_+/g, " ").trim();
20
+ export async function readRepoJson(dir) {
21
+ try {
22
+ return JSON.parse(await readFile(join(dir, "repo.json"), "utf8"));
23
+ }
24
+ catch (e) {
25
+ if (e.code === "ENOENT")
26
+ return undefined;
27
+ throw new Error(`repo.json in ${dir} is unreadable: ${e.message}`);
28
+ }
29
+ }
30
+ export function emptyRepo(name) {
31
+ return { schema: 1, name, updated: new Date().toISOString(), games: [] };
32
+ }
33
+ /** Adds an archive, or refreshes sha256 and size of the version that already points at it. */
34
+ export function upsertArchive(repo, url, hashed, opts = {}) {
35
+ const file = { url, ...hashed };
36
+ const games = (repo.games ??= []);
37
+ for (const game of games) {
38
+ for (const v of game.versions ?? []) {
39
+ if (v.file?.url === url) {
40
+ v.file = { ...v.file, ...hashed };
41
+ return;
42
+ }
43
+ }
44
+ }
45
+ const id = opts.id ?? slug(titleOf(decodeURIComponent(url)));
46
+ if (id === "")
47
+ throw new Error(`cannot derive an id from ${url}; pass --id`);
48
+ let game = games.find((g) => g.id === id);
49
+ if (!game) {
50
+ game = { id, title: opts.title ?? titleOf(decodeURIComponent(url)), versions: [] };
51
+ games.push(game);
52
+ games.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
53
+ }
54
+ game.versions.unshift({ version: opts.version ?? String(game.versions.length + 1), file });
55
+ }
56
+ /** Serialises repo.json; `updated` moves only when something else changed, so a rebuild is byte-identical. */
57
+ export async function writeRepoJson(dir, repo, now = new Date().toISOString()) {
58
+ const before = await readFile(join(dir, "repo.json"), "utf8").catch(() => undefined);
59
+ const stable = (r) => JSON.stringify({ ...r, updated: undefined });
60
+ let old;
61
+ try {
62
+ old = before ? JSON.parse(before) : undefined;
63
+ }
64
+ catch { /* rewritten below */ }
65
+ const next = { ...repo, updated: old && stable(old) === stable(repo) ? old.updated : now };
66
+ const out = JSON.stringify(next, null, 2) + "\n";
67
+ if (out === before)
68
+ return false;
69
+ await writeFile(join(dir, "repo.json"), out);
70
+ return true;
71
+ }
72
+ /** Every archive in `dir` (top level only), by name, as repo-relative urls. */
73
+ export async function listArchives(dir) {
74
+ return (await readdir(dir, { withFileTypes: true }))
75
+ .filter((e) => e.isFile() && ARCHIVE_EXTENSIONS.includes(extname(e.name).toLowerCase()))
76
+ .map((e) => e.name)
77
+ .sort();
78
+ }
79
+ export async function buildRepo(dir, name, now) {
80
+ const repo = (await readRepoJson(dir)) ?? emptyRepo(name ?? basename(dir));
81
+ const files = await listArchives(dir);
82
+ for (const f of files)
83
+ upsertArchive(repo, encodeURIComponent(f), await hashFile(join(dir, f)));
84
+ return { changed: await writeRepoJson(dir, repo, now), count: files.length };
85
+ }
86
+ /** The repo-relative url of an archive that must live inside the repo directory. */
87
+ export async function relativeUrl(dir, archive) {
88
+ await stat(archive);
89
+ const rel = relative(dir, archive);
90
+ if (rel.startsWith("..") || rel.startsWith(sep))
91
+ throw new Error(`${archive} is outside the repo directory ${dir}`);
92
+ return rel.split(sep).map(encodeURIComponent).join("/");
93
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
+ import { parseArgs } from "node:util";
5
+ import { buildRepo, emptyRepo, hashFile, readRepoJson, relativeUrl, upsertArchive, writeRepoJson } from "./build.js";
6
+ import { validateHosting } from "./hosting.js";
7
+ import { parseRepo } from "./parse.js";
8
+ import { verifyArchive } from "./verify.js";
9
+ const USAGE = `gm-pantry init <dir> [--name NAME]
10
+ gm-pantry build <dir>
11
+ gm-pantry add <dir> <archive> [--id ID] [--title TITLE] [--version LABEL]
12
+ gm-pantry validate <url|path> [--hosting]`;
13
+ async function main(argv) {
14
+ const { positionals: [cmd, target, archive], values: o } = parseArgs({
15
+ args: argv,
16
+ allowPositionals: true,
17
+ options: { name: { type: "string" }, id: { type: "string" }, title: { type: "string" }, version: { type: "string" }, hosting: { type: "boolean" } },
18
+ });
19
+ if (!cmd || !target)
20
+ return console.error(USAGE), 2;
21
+ const dir = resolve(target);
22
+ switch (cmd) {
23
+ case "init": {
24
+ if (await readRepoJson(dir))
25
+ return console.error("repo.json already exists"), 1;
26
+ await writeRepoJson(dir, emptyRepo(o.name ?? dir.split("/").pop()));
27
+ return console.log(`wrote ${join(dir, "repo.json")}`), 0;
28
+ }
29
+ case "build": {
30
+ const { changed, count } = await buildRepo(dir, o.name);
31
+ return console.log(`${count} archive(s); repo.json ${changed ? "written" : "unchanged"}`), 0;
32
+ }
33
+ case "add": {
34
+ if (!archive)
35
+ return console.error(USAGE), 2;
36
+ const repo = (await readRepoJson(dir)) ?? emptyRepo(o.name ?? dir.split("/").pop());
37
+ upsertArchive(repo, await relativeUrl(dir, resolve(archive)), await hashFile(resolve(archive)), o);
38
+ await writeRepoJson(dir, repo);
39
+ return console.log("repo.json updated"), 0;
40
+ }
41
+ case "validate":
42
+ return validate(target, o.hosting === true);
43
+ default:
44
+ return console.error(USAGE), 2;
45
+ }
46
+ }
47
+ async function validate(target, hosting) {
48
+ let failed = false;
49
+ const isUrl = /^https?:\/\//.test(target);
50
+ let json;
51
+ let base;
52
+ let dir;
53
+ if (isUrl) {
54
+ base = target;
55
+ const res = await fetch(target);
56
+ if (!res.ok)
57
+ return console.error(`HTTP ${res.status}`), 1;
58
+ json = await res.json();
59
+ }
60
+ else {
61
+ dir = (await stat(target)).isDirectory() ? resolve(target) : resolve(target, "..");
62
+ // Relative urls are checked against the folder itself below; resolution only needs a valid https base.
63
+ base = "https://repo.invalid/";
64
+ json = JSON.parse(await readFile((await stat(target)).isDirectory() ? join(target, "repo.json") : target, "utf8"));
65
+ }
66
+ const { repo, warnings } = parseRepo(json, base);
67
+ console.log(`${repo.name}: ${repo.games.length} game(s)`);
68
+ for (const w of warnings)
69
+ console.log(`warning ${w.path}: ${w.message}`), (failed = true);
70
+ if (dir) {
71
+ for (const g of repo.games)
72
+ for (const v of g.versions) {
73
+ if (!v.file.url.startsWith(base))
74
+ continue;
75
+ const path = join(dir, decodeURIComponent(v.file.url.slice(base.length)));
76
+ try {
77
+ const r = await verifyArchive(await readFile(path), v.file);
78
+ if (!r.ok)
79
+ console.log(`error ${g.id} ${v.version}: ${r.reason} is ${r.actual}, repo.json says ${r.expected}`), (failed = true);
80
+ }
81
+ catch {
82
+ console.log(`error ${g.id} ${v.version}: ${path} is missing`), (failed = true);
83
+ }
84
+ }
85
+ }
86
+ if (hosting) {
87
+ if (!isUrl)
88
+ return console.error("--hosting needs a url"), 2;
89
+ const report = await validateHosting(target);
90
+ for (const c of report.checks)
91
+ console.log(`${c.status.padEnd(4)} ${c.name}: ${c.message}`);
92
+ if (!report.ok)
93
+ failed = true;
94
+ }
95
+ return failed ? 1 : 0;
96
+ }
97
+ main(process.argv.slice(2)).then((code) => process.exit(code), (e) => (console.error(e.message), process.exit(1)));
@@ -0,0 +1,11 @@
1
+ export interface Check {
2
+ name: string;
3
+ status: "pass" | "warn" | "fail";
4
+ message: string;
5
+ }
6
+ export interface HostingReport {
7
+ ok: boolean;
8
+ checks: Check[];
9
+ }
10
+ /** Fetches repo.json and the newest archive of the first game; checks CORS, Range, content types and validators. */
11
+ export declare function validateHosting(url: string, fetchImpl?: typeof fetch): Promise<HostingReport>;
@@ -0,0 +1,55 @@
1
+ // SPEC.md §2: does a URL actually serve a repo the way a browser consumer needs?
2
+ import { parseRepo } from "./parse.js";
3
+ /** Fetches repo.json and the newest archive of the first game; checks CORS, Range, content types and validators. */
4
+ export async function validateHosting(url, fetchImpl = fetch) {
5
+ const checks = [];
6
+ const add = (name, status, message) => checks.push({ name, status, message });
7
+ const report = () => ({ ok: !checks.some((c) => c.status === "fail"), checks });
8
+ let res;
9
+ try {
10
+ res = await fetchImpl(url);
11
+ }
12
+ catch (e) {
13
+ add("repo.json", "fail", `fetch failed (no CORS or unreachable): ${e.message}`);
14
+ return report();
15
+ }
16
+ add("repo.json", res.ok ? "pass" : "fail", `HTTP ${res.status}`);
17
+ if (!res.ok)
18
+ return report();
19
+ cors(res, "repo.json cors", add);
20
+ const type = res.headers.get("content-type") ?? "";
21
+ add("repo.json content-type", /json|text\/plain/.test(type) ? "pass" : "warn", type || "missing");
22
+ const hasValidator = res.headers.has("etag") || res.headers.has("last-modified");
23
+ add("repo.json validators", hasValidator ? "pass" : "warn", hasValidator ? "ETag or Last-Modified present" : "no ETag or Last-Modified");
24
+ let archive;
25
+ try {
26
+ const { repo } = parseRepo(await res.json(), res.url || url);
27
+ archive = repo.games[0]?.versions[0]?.file.url;
28
+ add("repo.json parses", "pass", `${repo.games.length} game(s)`);
29
+ }
30
+ catch (e) {
31
+ add("repo.json parses", "fail", e.message);
32
+ return report();
33
+ }
34
+ if (!archive)
35
+ return add("archive", "warn", "no game to sample"), report();
36
+ try {
37
+ const a = await fetchImpl(archive, { headers: { Range: "bytes=0-0" } });
38
+ await a.body?.cancel();
39
+ add("archive", a.ok ? "pass" : "fail", `HTTP ${a.status}`);
40
+ if (!a.ok)
41
+ return report();
42
+ cors(a, "archive cors", add);
43
+ add("archive range", a.status === 206 ? "pass" : "warn", a.status === 206 ? "206 Partial Content" : `Range ignored (HTTP ${a.status}); optional`);
44
+ const t = a.headers.get("content-type") ?? "";
45
+ add("archive content-type", /text\/html/.test(t) ? "fail" : "pass", t || "missing");
46
+ }
47
+ catch (e) {
48
+ add("archive", "fail", `fetch failed (no CORS or unreachable): ${e.message}`);
49
+ }
50
+ return report();
51
+ }
52
+ function cors(res, name, add) {
53
+ const v = res.headers.get("access-control-allow-origin");
54
+ add(name, v === "*" ? "pass" : "fail", `Access-Control-Allow-Origin: ${v ?? "missing"}`);
55
+ }
@@ -0,0 +1,4 @@
1
+ export type { ArchiveFile, Format, Game, Meta, Repo, Version, Warning } from "./types.ts";
2
+ export { LIMITS, RepoError, isSecureUrl, parseRepo, resolveUrl } from "./parse.ts";
3
+ export { verifyArchive, type VerifyResult } from "./verify.ts";
4
+ export { validateHosting, type Check, type HostingReport } from "./hosting.ts";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { LIMITS, RepoError, isSecureUrl, parseRepo, resolveUrl } from "./parse.js";
2
+ export { verifyArchive } from "./verify.js";
3
+ export { validateHosting } from "./hosting.js";
@@ -0,0 +1,34 @@
1
+ import type { Repo, Warning } from "./types.ts";
2
+ /** The caps of SPEC.md §4.4. */
3
+ export declare const LIMITS: {
4
+ readonly name: 80;
5
+ readonly repoDescription: 280;
6
+ readonly games: 10000;
7
+ readonly id: 64;
8
+ readonly title: 200;
9
+ readonly versions: 200;
10
+ readonly versionLabel: 64;
11
+ readonly url: 2048;
12
+ readonly metaText: 2000;
13
+ readonly author: 120;
14
+ readonly notes: 2000;
15
+ readonly tags: 20;
16
+ readonly tag: 40;
17
+ readonly screenshots: 20;
18
+ readonly links: 20;
19
+ readonly linkName: 80;
20
+ readonly work: 8;
21
+ readonly workKey: 32;
22
+ readonly workText: 128;
23
+ };
24
+ export declare class RepoError extends Error {
25
+ }
26
+ /** Resolves a possibly relative url against the url of repo.json. Returns undefined if it is not a url. */
27
+ export declare function resolveUrl(url: string, baseUrl: string): string | undefined;
28
+ /** https, or http on a loopback host (so a repo can be tried locally). */
29
+ export declare function isSecureUrl(url: unknown): url is string;
30
+ /** Parses a repo.json already decoded from JSON. `baseUrl` is where it was fetched from. Throws RepoError. */
31
+ export declare function parseRepo(json: unknown, baseUrl: string): {
32
+ repo: Repo;
33
+ warnings: Warning[];
34
+ };
package/dist/parse.js ADDED
@@ -0,0 +1,218 @@
1
+ // SPEC.md §4 and §5: reading a repo.json. Entries that are malformed are skipped and reported;
2
+ // advisory fields that are malformed are dropped and reported. Only a broken envelope throws.
3
+ /** The caps of SPEC.md §4.4. */
4
+ export const LIMITS = {
5
+ name: 80,
6
+ repoDescription: 280,
7
+ games: 10_000,
8
+ id: 64,
9
+ title: 200,
10
+ versions: 200,
11
+ versionLabel: 64,
12
+ url: 2048,
13
+ metaText: 2000,
14
+ author: 120,
15
+ notes: 2000,
16
+ tags: 20,
17
+ tag: 40,
18
+ screenshots: 20,
19
+ links: 20,
20
+ linkName: 80,
21
+ work: 8,
22
+ workKey: 32,
23
+ workText: 128,
24
+ };
25
+ export class RepoError extends Error {
26
+ }
27
+ const ID = /^[a-z0-9][a-z0-9._-]*$/;
28
+ const SHA256 = /^[0-9a-f]{64}$/;
29
+ const WORK_KEY = /^[a-z][a-z0-9_-]*$/;
30
+ const DATE = /^\d{4}-\d{2}-\d{2}(T[\d:.]+(Z|[+-]\d{2}:\d{2}))?$/;
31
+ const FORMATS = ["zip", "7z", "rar"];
32
+ /** Resolves a possibly relative url against the url of repo.json. Returns undefined if it is not a url. */
33
+ export function resolveUrl(url, baseUrl) {
34
+ try {
35
+ return new URL(url, baseUrl).href;
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ }
41
+ /** https, or http on a loopback host (so a repo can be tried locally). */
42
+ export function isSecureUrl(url) {
43
+ if (typeof url !== "string" || url.length > LIMITS.url)
44
+ return false;
45
+ try {
46
+ const u = new URL(url);
47
+ return u.protocol === "https:" ||
48
+ (u.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname));
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
55
+ const text = (v, max) => typeof v === "string" && v.trim() !== "" && v.trim().length <= max ? v.trim() : undefined;
56
+ const validDate = (v) => typeof v === "string" && DATE.test(v) && !isNaN(Date.parse(v));
57
+ /** Parses a repo.json already decoded from JSON. `baseUrl` is where it was fetched from. Throws RepoError. */
58
+ export function parseRepo(json, baseUrl) {
59
+ const warnings = [];
60
+ const warn = (path, message) => warnings.push({ path, message });
61
+ if (!isObject(json))
62
+ throw new RepoError("repo.json is not an object");
63
+ if (json.schema !== 1)
64
+ throw new RepoError(`unsupported schema ${JSON.stringify(json.schema)}; this reader implements 1`);
65
+ const name = text(json.name, LIMITS.name);
66
+ if (name === undefined)
67
+ throw new RepoError(`"name" must be a non-empty string of at most ${LIMITS.name} characters`);
68
+ if (!validDate(json.updated))
69
+ throw new RepoError(`"updated" must be an ISO-8601 date or timestamp`);
70
+ if (!Array.isArray(json.games))
71
+ throw new RepoError(`"games" must be an array`);
72
+ const repo = { schema: 1, name, updated: json.updated, games: [] };
73
+ const description = text(json.description, LIMITS.repoDescription);
74
+ if (description)
75
+ repo.description = description;
76
+ else if (json.description !== undefined)
77
+ warn("description", "dropped: not a string of 1-280 characters");
78
+ if (isSecureUrl(json.homepage))
79
+ repo.homepage = json.homepage;
80
+ else if (json.homepage !== undefined)
81
+ warn("homepage", "dropped: not an https url");
82
+ const seen = new Set();
83
+ json.games.slice(0, LIMITS.games).forEach((entry, i) => {
84
+ const path = `games[${i}]`;
85
+ const game = parseGame(entry, path, baseUrl, warn);
86
+ if (!game)
87
+ return;
88
+ if (seen.has(game.id))
89
+ return warn(path, `skipped: duplicate id "${game.id}"`);
90
+ seen.add(game.id);
91
+ repo.games.push(game);
92
+ });
93
+ if (json.games.length > LIMITS.games)
94
+ warn("games", `only the first ${LIMITS.games} entries are read`);
95
+ return { repo, warnings };
96
+ }
97
+ function parseGame(entry, path, baseUrl, warn) {
98
+ if (!isObject(entry))
99
+ return void warn(path, "skipped: not an object");
100
+ if (typeof entry.id !== "string" || entry.id.length > LIMITS.id || !ID.test(entry.id)) {
101
+ return void warn(path, `skipped: "id" must match ${ID} and be at most ${LIMITS.id} characters`);
102
+ }
103
+ const title = text(entry.title, LIMITS.title);
104
+ if (title === undefined)
105
+ return void warn(path, `skipped: "title" must be a non-empty string of at most ${LIMITS.title} characters`);
106
+ if (!Array.isArray(entry.versions))
107
+ return void warn(path, `skipped: "versions" must be an array`);
108
+ const versions = [];
109
+ entry.versions.slice(0, LIMITS.versions).forEach((v, i) => {
110
+ const version = parseVersion(v, `${path}.versions[${i}]`, baseUrl, warn);
111
+ if (version)
112
+ versions.push(version);
113
+ });
114
+ if (versions.length === 0)
115
+ return void warn(path, "skipped: no valid version");
116
+ const game = { id: entry.id, title, versions };
117
+ const work = parseWork(entry.work);
118
+ if (work)
119
+ game.work = work;
120
+ else if (entry.work !== undefined)
121
+ warn(`${path}.work`, "dropped: not an object of short string or integer values");
122
+ if (entry.meta !== undefined)
123
+ game.meta = parseMeta(entry.meta, `${path}.meta`, baseUrl, warn);
124
+ return game;
125
+ }
126
+ function parseVersion(v, path, baseUrl, warn) {
127
+ if (!isObject(v))
128
+ return void warn(path, "skipped: not an object");
129
+ const label = text(v.version, LIMITS.versionLabel);
130
+ if (label === undefined)
131
+ return void warn(path, `skipped: "version" must be a non-empty string of at most ${LIMITS.versionLabel} characters`);
132
+ const f = v.file;
133
+ if (!isObject(f))
134
+ return void warn(path, `skipped: "file" must be an object`);
135
+ if (typeof f.url !== "string" || f.url.length > LIMITS.url)
136
+ return void warn(path, `skipped: "file.url" must be a url`);
137
+ const url = resolveUrl(f.url, baseUrl);
138
+ if (url === undefined || !isSecureUrl(url))
139
+ return void warn(path, `skipped: "file.url" must resolve to an https url`);
140
+ if (typeof f.sha256 !== "string" || !SHA256.test(f.sha256))
141
+ return void warn(path, `skipped: "file.sha256" must be 64 lowercase hex digits`);
142
+ if (!Number.isSafeInteger(f.size) || f.size < 0)
143
+ return void warn(path, `skipped: "file.size" must be a non-negative integer`);
144
+ const file = { url, sha256: f.sha256, size: f.size };
145
+ const version = { version: label, file };
146
+ if (typeof v.format === "string" && FORMATS.includes(v.format))
147
+ version.format = v.format;
148
+ else if (v.format !== undefined)
149
+ warn(`${path}.format`, "dropped: not zip, 7z or rar");
150
+ if (typeof v.build === "string" && SHA256.test(v.build))
151
+ version.build = v.build;
152
+ else if (v.build !== undefined)
153
+ warn(`${path}.build`, "dropped: not 64 lowercase hex digits");
154
+ const notes = text(v.notes, LIMITS.notes);
155
+ if (notes)
156
+ version.notes = notes;
157
+ else if (v.notes !== undefined)
158
+ warn(`${path}.notes`, "dropped");
159
+ if (validDate(v.released))
160
+ version.released = v.released;
161
+ else if (v.released !== undefined)
162
+ warn(`${path}.released`, "dropped: not an ISO-8601 date");
163
+ return version;
164
+ }
165
+ function parseWork(w) {
166
+ if (!isObject(w))
167
+ return undefined;
168
+ const entries = Object.entries(w);
169
+ if (entries.length === 0 || entries.length > LIMITS.work)
170
+ return undefined;
171
+ const out = {};
172
+ for (const [k, val] of entries) {
173
+ if (k.length > LIMITS.workKey || !WORK_KEY.test(k))
174
+ return undefined;
175
+ if (typeof val === "number" ? !Number.isSafeInteger(val) : text(val, LIMITS.workText) === undefined)
176
+ return undefined;
177
+ out[k] = typeof val === "string" ? val.trim() : val;
178
+ }
179
+ return out;
180
+ }
181
+ function parseMeta(m, path, baseUrl, warn) {
182
+ if (!isObject(m))
183
+ return void warn(path, "dropped: not an object");
184
+ const meta = {};
185
+ const url = (v) => {
186
+ const resolved = typeof v === "string" ? resolveUrl(v, baseUrl) : undefined;
187
+ return resolved !== undefined && isSecureUrl(resolved) ? resolved : undefined;
188
+ };
189
+ const author = text(m.author, LIMITS.author);
190
+ if (author)
191
+ meta.author = author;
192
+ const description = text(m.description, LIMITS.metaText);
193
+ if (description)
194
+ meta.description = description;
195
+ if (Array.isArray(m.tags)) {
196
+ const tags = m.tags.map((t) => text(t, LIMITS.tag)).filter((t) => t !== undefined).slice(0, LIMITS.tags);
197
+ if (tags.length)
198
+ meta.tags = tags;
199
+ }
200
+ const cover = url(m.cover);
201
+ if (cover)
202
+ meta.cover = cover;
203
+ if (Array.isArray(m.screenshots)) {
204
+ const shots = m.screenshots.map(url).filter((u) => u !== undefined).slice(0, LIMITS.screenshots);
205
+ if (shots.length)
206
+ meta.screenshots = shots;
207
+ }
208
+ if (Array.isArray(m.links)) {
209
+ const links = m.links.flatMap((l) => {
210
+ const name = isObject(l) ? text(l.name, LIMITS.linkName) : undefined;
211
+ const href = isObject(l) ? url(l.url) : undefined;
212
+ return name && href ? [{ name, url: href }] : [];
213
+ }).slice(0, LIMITS.links);
214
+ if (links.length)
215
+ meta.links = links;
216
+ }
217
+ return Object.keys(meta).length ? meta : undefined;
218
+ }
@@ -0,0 +1,50 @@
1
+ export type Format = "zip" | "7z" | "rar";
2
+ export interface ArchiveFile {
3
+ url: string;
4
+ /** Lowercase hex SHA-256 of the archive, exactly as stored. */
5
+ sha256: string;
6
+ /** Bytes. */
7
+ size: number;
8
+ }
9
+ export interface Version {
10
+ version: string;
11
+ file: ArchiveFile;
12
+ format?: Format;
13
+ /** Claimed hash of the normalised game payload; a hint, consumers recompute. */
14
+ build?: string;
15
+ notes?: string;
16
+ /** YYYY-MM-DD, or a full ISO-8601 timestamp. */
17
+ released?: string;
18
+ }
19
+ export interface Meta {
20
+ author?: string;
21
+ description?: string;
22
+ tags?: string[];
23
+ cover?: string;
24
+ screenshots?: string[];
25
+ links?: {
26
+ name: string;
27
+ url: string;
28
+ }[];
29
+ }
30
+ export interface Game {
31
+ id: string;
32
+ title: string;
33
+ work?: Record<string, string | number>;
34
+ meta?: Meta;
35
+ /** Non-empty, newest first. */
36
+ versions: Version[];
37
+ }
38
+ export interface Repo {
39
+ schema: 1;
40
+ name: string;
41
+ description?: string;
42
+ homepage?: string;
43
+ updated: string;
44
+ games: Game[];
45
+ }
46
+ /** Something skipped or dropped while parsing; `path` is a JSON path such as `games[3].versions[0]`. */
47
+ export interface Warning {
48
+ path: string;
49
+ message: string;
50
+ }
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ // The types of SPEC.md §3. A parsed Repo has every url resolved to an absolute one.
2
+ export {};
@@ -0,0 +1,14 @@
1
+ import type { ArchiveFile } from "./types.ts";
2
+ export type VerifyResult = {
3
+ ok: true;
4
+ } | {
5
+ ok: false;
6
+ reason: "size" | "sha256";
7
+ expected: string | number;
8
+ actual: string | number;
9
+ };
10
+ /**
11
+ * Checks bytes against a `file` entry: size first, then sha256. A consumer discards the archive on `ok: false`.
12
+ * shortcut: a stream is buffered whole (Web Crypto has no incremental digest), upgrade when archives outgrow memory.
13
+ */
14
+ export declare function verifyArchive(data: Uint8Array | ArrayBuffer | ReadableStream<Uint8Array>, file: Pick<ArchiveFile, "sha256" | "size">): Promise<VerifyResult>;
package/dist/verify.js ADDED
@@ -0,0 +1,15 @@
1
+ // SPEC.md §6: the sha256 check on a downloaded archive. Web Crypto only, so it runs in a browser.
2
+ const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
3
+ /**
4
+ * Checks bytes against a `file` entry: size first, then sha256. A consumer discards the archive on `ok: false`.
5
+ * shortcut: a stream is buffered whole (Web Crypto has no incremental digest), upgrade when archives outgrow memory.
6
+ */
7
+ export async function verifyArchive(data, file) {
8
+ const bytes = data instanceof ReadableStream
9
+ ? new Uint8Array(await new Response(data).arrayBuffer())
10
+ : new Uint8Array(data);
11
+ if (bytes.byteLength !== file.size)
12
+ return { ok: false, reason: "size", expected: file.size, actual: bytes.byteLength };
13
+ const actual = hex(await crypto.subtle.digest("SHA-256", bytes));
14
+ return actual === file.sha256 ? { ok: true } : { ok: false, reason: "sha256", expected: file.sha256, actual };
15
+ }
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "gm-pantry",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Game repositories: static directories of game archives with a repo.json, plus a parser, validator and CLI.",
5
+ "keywords": [
6
+ "gamemaker",
7
+ "repository",
8
+ "archive",
9
+ "static"
10
+ ],
11
+ "homepage": "https://github.com/QuentinJanuel/GM-Pantry#readme",
12
+ "bugs": "https://github.com/QuentinJanuel/GM-Pantry/issues",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/QuentinJanuel/GM-Pantry.git"
16
+ },
17
+ "license": "MIT",
18
+ "type": "module",
19
+ "files": [
20
+ "dist",
21
+ "LICENSE"
22
+ ],
23
+ "main": "dist/index.js",
24
+ "types": "dist/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "default": "./dist/index.js"
29
+ }
30
+ },
31
+ "bin": {
32
+ "gm-pantry": "dist/cli.js"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "scripts": {
38
+ "build": "tsc -p tsconfig.build.json",
39
+ "typecheck": "tsc",
40
+ "test": "node --test test/*.test.ts scripts/*.test.mjs",
41
+ "prepublishOnly": "npm run build"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^24.0.0",
45
+ "typescript": "^5.9.0"
46
+ },
47
+ "engines": {
48
+ "node": ">=24"
49
+ }
50
+ }