@jgengine/assets 0.10.0 → 0.11.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/CHANGELOG.md +13 -2
- package/README.md +103 -50
- package/dist/aliases.d.ts +5 -0
- package/dist/aliases.js +28 -9
- package/dist/catalogs/build.js +9 -0
- package/dist/catalogs/buildSprites.d.ts +10 -0
- package/dist/catalogs/buildSprites.js +15 -0
- package/dist/catalogs/starter.d.ts +5 -0
- package/dist/catalogs/starter.js +10 -1
- package/dist/cli/paths.d.ts +9 -1
- package/dist/cli/paths.js +11 -1
- package/dist/cli/pull.d.ts +12 -0
- package/dist/cli/pull.js +210 -15
- package/dist/download.d.ts +46 -3
- package/dist/download.js +158 -9
- package/dist/find.d.ts +20 -1
- package/dist/find.js +52 -2
- package/dist/generated/index.js +18 -6
- package/dist/generated/kaykit-adventurers.json +674 -0
- package/dist/generated/kaykit-city-builder.json +863 -0
- package/dist/generated/kaykit-dungeon.json +4265 -0
- package/dist/generated/kaykit-furniture.json +1115 -0
- package/dist/generated/kaykit-skeletons.json +410 -0
- package/dist/generated/kaykit-space-base.json +1199 -0
- package/dist/generated/quaternius-medieval-village.json +3698 -0
- package/dist/generated/quaternius-modular-scifi.json +4013 -0
- package/dist/generated/quaternius-stylized-nature.json +1430 -0
- package/dist/generated-sprites/index.d.ts +5 -0
- package/dist/generated-sprites/index.js +4 -0
- package/dist/index.d.ts +9 -2
- package/dist/index.js +9 -2
- package/dist/indexGen.js +7 -5
- package/dist/manifest.d.ts +12 -1
- package/dist/materials.d.ts +61 -0
- package/dist/materials.js +98 -0
- package/dist/packs/starter.d.ts +46 -0
- package/dist/packs/starter.js +248 -0
- package/dist/provisioning.d.ts +88 -0
- package/dist/provisioning.js +74 -0
- package/dist/registry-catalog.json +40 -0
- package/dist/snippet.d.ts +4 -0
- package/dist/snippet.js +27 -0
- package/dist/sources/ambientcg.d.ts +5 -0
- package/dist/sources/ambientcg.js +64 -0
- package/dist/sources/gameicons.d.ts +10 -0
- package/dist/sources/gameicons.js +21 -0
- package/dist/sources/index.d.ts +9 -2
- package/dist/sources/index.js +11 -3
- package/dist/sources/kaykit.js +55 -6
- package/dist/sources/quaternius.js +60 -9
- package/dist/spriteIndexGen.d.ts +17 -0
- package/dist/spriteIndexGen.js +108 -0
- package/dist/verify.d.ts +1 -0
- package/dist/verify.js +38 -1
- package/package.json +4 -4
- package/skills/jgengine/SKILL.md +51 -0
- package/skills/jgengine/api.md +434 -0
- package/skills/jgengine/capabilities.md +29 -0
- package/skills/jgengine/reference-cartridge.md +72 -0
- package/skills/jgengine-assets/SKILL.md +41 -0
- package/skills/jgengine-assets/api.md +273 -0
- package/skills/jgengine-assets/capabilities.md +13 -0
- package/dist/generated/kenney-blaster.json +0 -362
- package/dist/generated/kenney-nature.json +0 -3292
- package/dist/generated/kenney-space.json +0 -1532
- package/dist/sources/kenney.d.ts +0 -2
- package/dist/sources/kenney.js +0 -36
- package/llms.txt +0 -1010
package/dist/cli/pull.js
CHANGED
|
@@ -1,26 +1,34 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
2
3
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { downloadPackArchive, extractGlbs, extractTextures } from "../download.js";
|
|
6
|
+
import { downloadPackArchive, extractGlbs, extractSpriteFiles, extractTextures } from "../download.js";
|
|
6
7
|
import { rankAssets } from "../find.js";
|
|
7
8
|
import { generatedIndex } from "../generated/index.js";
|
|
9
|
+
import { generatedSpriteIndex } from "../generated-sprites/index.js";
|
|
8
10
|
import { reindex } from "../indexGen.js";
|
|
9
11
|
import { isScrapeDownload } from "../manifest.js";
|
|
12
|
+
import { extractMaterialMaps } from "../materials.js";
|
|
13
|
+
import { validateAssetReferences } from "../provisioning.js";
|
|
10
14
|
import { registryCatalog } from "../registry.js";
|
|
11
|
-
import { componentWiringSnippet, iconWiringSnippet, modelWiringSnippet } from "../snippet.js";
|
|
12
|
-
import { sourceById } from "../sources/index.js";
|
|
15
|
+
import { componentWiringSnippet, iconWiringSnippet, materialWiringSnippet, modelWiringSnippet, spriteWiringSnippet, } from "../snippet.js";
|
|
16
|
+
import { materialSources, sourceById, spriteSources } from "../sources/index.js";
|
|
17
|
+
import { reindexSprites } from "../spriteIndexGen.js";
|
|
13
18
|
import { verifyManifest } from "../verify.js";
|
|
14
|
-
import { resolveGeneratedDir, resolvePackageRoot, resolvePackageTreeRoot } from "./paths.js";
|
|
19
|
+
import { resolveGeneratedDir, resolveGeneratedSpritesDir, resolvePackageRoot, resolvePackageTreeRoot } from "./paths.js";
|
|
15
20
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
16
21
|
const packageTreeRoot = resolvePackageTreeRoot(here);
|
|
17
22
|
const pkgRoot = resolvePackageRoot(here);
|
|
18
23
|
/** Sibling of `cli/` under `src/` (dev) or `dist/` (published) so reindex writes the tree consumers import. */
|
|
19
24
|
export const generatedDir = resolveGeneratedDir(here);
|
|
25
|
+
/** Sprite-pack counterpart of `generatedDir`. */
|
|
26
|
+
export const generatedSpritesDir = resolveGeneratedSpritesDir(here);
|
|
20
27
|
export { resolveGeneratedDir } from "./paths.js";
|
|
21
28
|
const singlesJson = join(packageTreeRoot, "singles.json");
|
|
22
29
|
const localDir = join(pkgRoot, "local");
|
|
23
30
|
const CDN_BASE = "https://cdn.jsdelivr.net/gh/Noisemaker111/jgengine@main/packages/assets/local";
|
|
31
|
+
/** @internal */
|
|
24
32
|
export function flag(argv, name) {
|
|
25
33
|
const index = argv.indexOf(`--${name}`);
|
|
26
34
|
return index >= 0 ? argv[index + 1] : undefined;
|
|
@@ -29,6 +37,31 @@ function fail(message) {
|
|
|
29
37
|
console.error(`error: ${message}`);
|
|
30
38
|
process.exit(1);
|
|
31
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* The runtime's own fetch, with a curl fallback for hosts it cannot reach —
|
|
42
|
+
* some proxied sandboxes tear down bun's TLS stream to GitHub's release-asset
|
|
43
|
+
* host while curl (which honors SSL_CERT_FILE) gets through fine.
|
|
44
|
+
*/
|
|
45
|
+
export const cliFetch = (async (input, init) => {
|
|
46
|
+
try {
|
|
47
|
+
return await fetch(input, init);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
51
|
+
if (init?.method !== undefined && init.method !== "GET")
|
|
52
|
+
throw error;
|
|
53
|
+
try {
|
|
54
|
+
const bytes = execFileSync("curl", ["-fsSL", "--max-time", "600", url], {
|
|
55
|
+
maxBuffer: 1024 * 1024 * 1024,
|
|
56
|
+
});
|
|
57
|
+
return new Response(new Uint8Array(bytes));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
/** @internal */
|
|
32
65
|
export function describeNetworkFailure(error) {
|
|
33
66
|
const chain = [];
|
|
34
67
|
let current = error;
|
|
@@ -46,6 +79,31 @@ function cmdList(argv) {
|
|
|
46
79
|
const category = flag(argv, "category");
|
|
47
80
|
const source = flag(argv, "source");
|
|
48
81
|
const limit = Number(flag(argv, "limit") ?? "50");
|
|
82
|
+
if (flag(argv, "kind") === "material") {
|
|
83
|
+
const rows = materialSources.filter((entry) => category === undefined || entry.categories.includes(category));
|
|
84
|
+
for (const entry of rows.slice(0, limit)) {
|
|
85
|
+
console.log(`${entry.id}\t[${entry.categories.join(",")}]\t${entry.license}`);
|
|
86
|
+
}
|
|
87
|
+
console.log(`— ${Math.min(rows.length, limit)} of ${rows.length} materials`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (flag(argv, "kind") === "sprite") {
|
|
91
|
+
const rows = generatedSpriteIndex.filter((entry) => (category === undefined || entry.categories.includes(category)) &&
|
|
92
|
+
(source === undefined || entry.source === source));
|
|
93
|
+
for (const entry of rows.slice(0, limit)) {
|
|
94
|
+
console.log(`${entry.id}\t[${entry.categories.join(",")}]\t${entry.file}`);
|
|
95
|
+
}
|
|
96
|
+
console.log(`— ${Math.min(rows.length, limit)} of ${rows.length} sprite/icon files`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (flag(argv, "kind") === "spritePack") {
|
|
100
|
+
const rows = spriteSources.filter((entry) => category === undefined || entry.categories.includes(category));
|
|
101
|
+
for (const entry of rows.slice(0, limit)) {
|
|
102
|
+
console.log(`${entry.id}\t[${entry.categories.join(",")}]\t${entry.license}`);
|
|
103
|
+
}
|
|
104
|
+
console.log(`— ${Math.min(rows.length, limit)} of ${rows.length} sprite/icon packs`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
49
107
|
const rows = generatedIndex.filter((entry) => (category === undefined || entry.categories.includes(category)) &&
|
|
50
108
|
(source === undefined || entry.source === source));
|
|
51
109
|
for (const entry of rows.slice(0, limit)) {
|
|
@@ -65,9 +123,11 @@ function cmdSearch(argv) {
|
|
|
65
123
|
}
|
|
66
124
|
console.log(`— ${Math.min(rows.length, limit)} of ${rows.length} matches for "${term}"`);
|
|
67
125
|
}
|
|
126
|
+
/** @internal */
|
|
68
127
|
export function isPopulated(dir) {
|
|
69
128
|
return existsSync(dir) && readdirSync(dir).length > 0;
|
|
70
129
|
}
|
|
130
|
+
/** @internal */
|
|
71
131
|
export async function cmdPull(argv) {
|
|
72
132
|
const sourceId = argv[0];
|
|
73
133
|
if (sourceId === undefined) {
|
|
@@ -77,7 +137,7 @@ export async function cmdPull(argv) {
|
|
|
77
137
|
if (source === undefined)
|
|
78
138
|
fail(`unknown source: ${sourceId}`);
|
|
79
139
|
const outRoot = resolve(flag(argv, "dir") ?? "public");
|
|
80
|
-
const outDir = join(outRoot,
|
|
140
|
+
const outDir = join(outRoot, packSubdir(source), sourceId);
|
|
81
141
|
const offline = argv.includes("--offline");
|
|
82
142
|
const mirrorBase = flag(argv, "mirror") ?? process.env.JGENGINE_ASSETS_MIRROR;
|
|
83
143
|
if (offline) {
|
|
@@ -89,15 +149,53 @@ export async function cmdPull(argv) {
|
|
|
89
149
|
return;
|
|
90
150
|
}
|
|
91
151
|
const result = await fetchPackInto(source, outRoot, { mirrorBase });
|
|
152
|
+
console.log(describeFetchResult(source, result));
|
|
153
|
+
}
|
|
154
|
+
function packSubdir(source) {
|
|
155
|
+
if (source.kind === "material")
|
|
156
|
+
return "materials";
|
|
157
|
+
if (source.kind === "sprite")
|
|
158
|
+
return "sprites";
|
|
159
|
+
return "models";
|
|
160
|
+
}
|
|
161
|
+
function describeFetchResult(source, result) {
|
|
162
|
+
if (source.kind === "material") {
|
|
163
|
+
return `pulled ${result.textures} material map(s) -> ${result.outDir}`;
|
|
164
|
+
}
|
|
165
|
+
if (source.kind === "sprite") {
|
|
166
|
+
return `pulled ${result.models} sprite/icon file(s) -> ${result.outDir}`;
|
|
167
|
+
}
|
|
92
168
|
const textureNote = result.textures > 0 ? ` + ${result.textures} texture(s)` : "";
|
|
93
|
-
|
|
169
|
+
return `pulled ${result.models} models${textureNote} -> ${result.outDir}`;
|
|
94
170
|
}
|
|
95
171
|
async function fetchPackInto(source, outRoot, options) {
|
|
96
|
-
const outDir = join(outRoot,
|
|
172
|
+
const outDir = join(outRoot, packSubdir(source), source.id);
|
|
97
173
|
console.log(`resolving ${source.id}${isScrapeDownload(source.download) ? " (scrape)" : ""}` +
|
|
98
174
|
`${options.mirrorBase !== undefined ? ` [mirror override: ${options.mirrorBase}]` : ""}…`);
|
|
99
|
-
const { archive, url, attempted } = await downloadPackArchive(source, {
|
|
175
|
+
const { archive, url, attempted } = await downloadPackArchive(source, {
|
|
176
|
+
mirrorBase: options.mirrorBase,
|
|
177
|
+
fetchImpl: cliFetch,
|
|
178
|
+
});
|
|
100
179
|
console.log(`downloaded ${url}${attempted.length > 1 ? ` (after ${attempted.length - 1} failed attempt(s))` : ""}`);
|
|
180
|
+
if (source.kind === "material") {
|
|
181
|
+
const maps = extractMaterialMaps(archive);
|
|
182
|
+
if (maps.every((map) => map.role !== "color")) {
|
|
183
|
+
fail(`no color map found in ${source.id} archive`);
|
|
184
|
+
}
|
|
185
|
+
mkdirSync(outDir, { recursive: true });
|
|
186
|
+
for (const map of maps)
|
|
187
|
+
writeFileSync(join(outDir, map.file), map.bytes);
|
|
188
|
+
return { outDir, models: 0, textures: maps.length, url };
|
|
189
|
+
}
|
|
190
|
+
if (source.kind === "sprite") {
|
|
191
|
+
const files = extractSpriteFiles(archive);
|
|
192
|
+
if (files.length === 0)
|
|
193
|
+
fail(`no .svg/.png files found in ${source.id} archive`);
|
|
194
|
+
mkdirSync(outDir, { recursive: true });
|
|
195
|
+
for (const file of files)
|
|
196
|
+
writeFileSync(join(outDir, file.file), file.bytes);
|
|
197
|
+
return { outDir, models: files.length, textures: 0, url };
|
|
198
|
+
}
|
|
101
199
|
const glbs = extractGlbs(archive);
|
|
102
200
|
if (glbs.length === 0)
|
|
103
201
|
fail(`no .glb files found in ${source.id} archive`);
|
|
@@ -157,17 +255,31 @@ function cmdRegisterSingle(argv) {
|
|
|
157
255
|
writeSingles(singles);
|
|
158
256
|
console.log(`added single ${id} -> ${url}${isUrl ? " (0 bytes stored)" : " (copied to local/)"}`);
|
|
159
257
|
}
|
|
160
|
-
const KINDS = [
|
|
258
|
+
const KINDS = [
|
|
259
|
+
"model",
|
|
260
|
+
"pack",
|
|
261
|
+
"material",
|
|
262
|
+
"component",
|
|
263
|
+
"icon",
|
|
264
|
+
"sprite",
|
|
265
|
+
"spritePack",
|
|
266
|
+
];
|
|
161
267
|
function describeMatch(match) {
|
|
162
268
|
switch (match.kind) {
|
|
163
269
|
case "model":
|
|
164
270
|
return `[model] ${match.id}${match.via === "alias" ? " (alias)" : match.via === "single" ? " (single)" : ""}`;
|
|
165
271
|
case "pack":
|
|
166
272
|
return `[pack] ${match.source} — ${match.title}`;
|
|
273
|
+
case "material":
|
|
274
|
+
return `[material] ${match.id} — ${match.title}`;
|
|
167
275
|
case "component":
|
|
168
276
|
return `[component] ${match.name} — ${match.title}`;
|
|
169
277
|
case "icon":
|
|
170
278
|
return `[icon] ${match.name}`;
|
|
279
|
+
case "sprite":
|
|
280
|
+
return `[sprite] ${match.id}`;
|
|
281
|
+
case "spritePack":
|
|
282
|
+
return `[spritePack] ${match.source} — ${match.title}`;
|
|
171
283
|
}
|
|
172
284
|
}
|
|
173
285
|
async function performAdd(match, argv) {
|
|
@@ -184,19 +296,58 @@ async function performAdd(match, argv) {
|
|
|
184
296
|
console.log(iconWiringSnippet(match.name));
|
|
185
297
|
return;
|
|
186
298
|
}
|
|
299
|
+
const outRoot = resolve(flag(argv, "dir") ?? "public");
|
|
300
|
+
const mirrorBase = flag(argv, "mirror") ?? process.env.JGENGINE_ASSETS_MIRROR;
|
|
301
|
+
if (match.kind === "material") {
|
|
302
|
+
const source = sourceById.get(match.id);
|
|
303
|
+
if (source === undefined)
|
|
304
|
+
fail(`match resolves to unknown source: ${match.id}`);
|
|
305
|
+
const outDir = join(outRoot, "materials", match.id);
|
|
306
|
+
if (isPopulated(outDir)) {
|
|
307
|
+
console.log(`${match.id} already present in ${outDir}`);
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
const result = await fetchPackInto(source, outRoot, { mirrorBase });
|
|
311
|
+
console.log(describeFetchResult(source, result));
|
|
312
|
+
}
|
|
313
|
+
console.log(`\nwire it in:\n`);
|
|
314
|
+
console.log(materialWiringSnippet(match.id));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (match.kind === "sprite" || match.kind === "spritePack") {
|
|
318
|
+
const source = sourceById.get(match.source);
|
|
319
|
+
if (source === undefined)
|
|
320
|
+
fail(`match resolves to unknown source: ${match.source}`);
|
|
321
|
+
const outDir = join(outRoot, "sprites", match.source);
|
|
322
|
+
if (isPopulated(outDir)) {
|
|
323
|
+
console.log(`${match.source} already present in ${outDir}`);
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
const result = await fetchPackInto(source, outRoot, { mirrorBase });
|
|
327
|
+
console.log(describeFetchResult(source, result));
|
|
328
|
+
}
|
|
329
|
+
const result = reindexSprites(join(outRoot, "sprites"), generatedSpritesDir);
|
|
330
|
+
console.log(`reindexed ${result.total} entries -> ${generatedSpritesDir}`);
|
|
331
|
+
if (match.kind === "sprite") {
|
|
332
|
+
console.log(`\nwire it in:\n`);
|
|
333
|
+
console.log(spriteWiringSnippet(match.id));
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
console.log(`\n${match.title}: browse ids with \`assets list --kind sprite --source ${match.source}\`, then wire like:\n`);
|
|
337
|
+
console.log(spriteWiringSnippet(`${match.source}/<icon>`));
|
|
338
|
+
}
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
187
341
|
const source = sourceById.get(match.source);
|
|
188
342
|
if (source === undefined)
|
|
189
343
|
fail(`match resolves to unknown source: ${match.source}`);
|
|
190
|
-
const outRoot = resolve(flag(argv, "dir") ?? "public");
|
|
191
344
|
const outDir = join(outRoot, "models", match.source);
|
|
192
|
-
const mirrorBase = flag(argv, "mirror") ?? process.env.JGENGINE_ASSETS_MIRROR;
|
|
193
345
|
if (isPopulated(outDir)) {
|
|
194
346
|
console.log(`${match.source} already present in ${outDir}`);
|
|
195
347
|
}
|
|
196
348
|
else {
|
|
197
349
|
const result = await fetchPackInto(source, outRoot, { mirrorBase });
|
|
198
|
-
|
|
199
|
-
console.log(`pulled ${result.models} models${textureNote} -> ${result.outDir}`);
|
|
350
|
+
console.log(describeFetchResult(source, result));
|
|
200
351
|
}
|
|
201
352
|
const result = reindex(join(outRoot, "models"), generatedDir);
|
|
202
353
|
console.log(`reindexed ${result.total} entries -> ${generatedDir}`);
|
|
@@ -212,7 +363,7 @@ async function performAdd(match, argv) {
|
|
|
212
363
|
async function cmdAdd(argv) {
|
|
213
364
|
const query = argv[0];
|
|
214
365
|
if (query === undefined) {
|
|
215
|
-
fail("usage: add <query> [--kind model|pack|component|icon] [--dir <dir>] [--mirror <baseUrl>] [--json]\n" +
|
|
366
|
+
fail("usage: add <query> [--kind model|pack|material|component|icon|sprite|spritePack] [--dir <dir>] [--mirror <baseUrl>] [--json]\n" +
|
|
216
367
|
" add <path|url> --license <l> [--category <c>] (register a one-off single into the shipped index)");
|
|
217
368
|
}
|
|
218
369
|
// Back-compat: the old `add` registered a one-off single, always with --license.
|
|
@@ -255,6 +406,15 @@ function cmdReindex(argv) {
|
|
|
255
406
|
console.log(` ${row.source}: ${row.count}`);
|
|
256
407
|
console.log(`reindexed ${result.total} entries -> ${generatedDir}`);
|
|
257
408
|
}
|
|
409
|
+
function cmdReindexSprites(argv) {
|
|
410
|
+
const spritesDir = resolve(argv[0] ?? join("public", "sprites"));
|
|
411
|
+
if (!existsSync(spritesDir))
|
|
412
|
+
fail(`sprites dir not found: ${spritesDir}`);
|
|
413
|
+
const result = reindexSprites(spritesDir, generatedSpritesDir);
|
|
414
|
+
for (const row of result.perSource)
|
|
415
|
+
console.log(` ${row.source}: ${row.count}`);
|
|
416
|
+
console.log(`reindexed ${result.total} entries -> ${generatedSpritesDir}`);
|
|
417
|
+
}
|
|
258
418
|
function cmdVerify() {
|
|
259
419
|
const result = verifyManifest();
|
|
260
420
|
if (result.ok) {
|
|
@@ -265,6 +425,35 @@ function cmdVerify() {
|
|
|
265
425
|
console.error(` ✗ ${error}`);
|
|
266
426
|
fail(`verify failed with ${result.errors.length} problem(s)`);
|
|
267
427
|
}
|
|
428
|
+
/**
|
|
429
|
+
* Resolve each id against the provisioning contract and print its owner —
|
|
430
|
+
* `committed`, `provisioned` (with the `assets pull <source>` step), or
|
|
431
|
+
* `dangling`. Exits non-zero if any id is dangling, so a clean-clone gate can
|
|
432
|
+
* feed it logical ids and fail on references no source or single owns.
|
|
433
|
+
*/
|
|
434
|
+
function cmdProvenance(argv) {
|
|
435
|
+
const ids = argv.filter((arg) => !arg.startsWith("--"));
|
|
436
|
+
if (ids.length === 0)
|
|
437
|
+
fail("usage: assets provenance <id> [<id>...]");
|
|
438
|
+
const references = ids.map((id) => ({ consumer: "cli", id }));
|
|
439
|
+
const result = validateAssetReferences(references);
|
|
440
|
+
for (const row of result.results) {
|
|
441
|
+
const p = row.provenance;
|
|
442
|
+
const detail = p.kind === "provisioned"
|
|
443
|
+
? `${p.resolvedPath} (${p.provisioningStep})`
|
|
444
|
+
: p.kind === "committed"
|
|
445
|
+
? `${p.resolvedPath}`
|
|
446
|
+
: "no owner";
|
|
447
|
+
const mark = row.ok ? "✓" : "✗";
|
|
448
|
+
console.log(` ${mark} ${p.id}: ${p.kind} — ${detail}`);
|
|
449
|
+
}
|
|
450
|
+
if (result.provisioning.length > 0) {
|
|
451
|
+
console.log(`provisioning steps: ${result.provisioning.join(", ")}`);
|
|
452
|
+
}
|
|
453
|
+
if (!result.ok)
|
|
454
|
+
fail(`provenance: ${result.errors.length} dangling reference(s)`);
|
|
455
|
+
console.log("provenance: ok");
|
|
456
|
+
}
|
|
268
457
|
if (import.meta.main) {
|
|
269
458
|
const [command, ...rest] = process.argv.slice(2);
|
|
270
459
|
switch (command) {
|
|
@@ -286,11 +475,17 @@ if (import.meta.main) {
|
|
|
286
475
|
case "reindex":
|
|
287
476
|
cmdReindex(rest);
|
|
288
477
|
break;
|
|
478
|
+
case "reindex-sprites":
|
|
479
|
+
cmdReindexSprites(rest);
|
|
480
|
+
break;
|
|
289
481
|
case "verify":
|
|
290
482
|
cmdVerify();
|
|
291
483
|
break;
|
|
484
|
+
case "provenance":
|
|
485
|
+
cmdProvenance(rest);
|
|
486
|
+
break;
|
|
292
487
|
default:
|
|
293
|
-
console.log("usage: assets <add|list|search|pull|register|reindex|verify> [...args]");
|
|
488
|
+
console.log("usage: assets <add|list|search|pull|register|reindex|reindex-sprites|verify|provenance> [...args]");
|
|
294
489
|
if (command !== undefined && command !== "help")
|
|
295
490
|
process.exit(1);
|
|
296
491
|
}
|
package/dist/download.d.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
|
+
import { type UnzipFileFilter } from "fflate";
|
|
1
2
|
import { type AssetSource } from "./manifest.js";
|
|
2
3
|
export type FetchLike = typeof fetch;
|
|
4
|
+
/** Max size of a downloaded (still-compressed) archive, in bytes. Provider zips run tens of MB; this leaves headroom without buffering an unbounded response. */
|
|
5
|
+
export declare const MAX_ARCHIVE_DOWNLOAD_BYTES: number;
|
|
6
|
+
/** Max total uncompressed size this module will inflate out of one archive, in bytes. */
|
|
7
|
+
export declare const MAX_ARCHIVE_UNCOMPRESSED_BYTES: number;
|
|
8
|
+
/** Max number of entries this module will extract out of one archive. */
|
|
9
|
+
export declare const MAX_ARCHIVE_ENTRY_COUNT = 20000;
|
|
10
|
+
/** Max allowed originalSize/size ratio for a single archive entry — past this it's treated as a zip bomb. */
|
|
11
|
+
export declare const MAX_ARCHIVE_COMPRESSION_RATIO = 100;
|
|
12
|
+
/** @internal */
|
|
13
|
+
export declare function boundedExtractFilter(matches: UnzipFileFilter): UnzipFileFilter;
|
|
3
14
|
export interface ExtractedGlb {
|
|
4
15
|
file: string;
|
|
5
16
|
bytes: Uint8Array;
|
|
@@ -9,14 +20,18 @@ export interface ExtractedTexture {
|
|
|
9
20
|
file: string;
|
|
10
21
|
bytes: Uint8Array;
|
|
11
22
|
}
|
|
23
|
+
/** @internal */
|
|
12
24
|
export declare function findArchiveUrl(html: string, pageUrl: string): string | null;
|
|
25
|
+
/** @internal */
|
|
13
26
|
export declare function resolveArchiveUrl(source: AssetSource, fetchImpl?: FetchLike): Promise<string>;
|
|
27
|
+
/** @internal */
|
|
14
28
|
export declare function downloadArchive(url: string, fetchImpl?: FetchLike): Promise<Uint8Array>;
|
|
15
29
|
/**
|
|
16
30
|
* Layout for the `--mirror` / `JGENGINE_ASSETS_MIRROR` base URL override: the
|
|
17
31
|
* archive for a pack is expected at `<baseUrl>/<provider>/<packId>.zip`, e.g.
|
|
18
32
|
* `https://my-mirror.example.com/kenney/kenney-nature.zip`.
|
|
19
|
-
|
|
33
|
+
* @internal
|
|
34
|
+
*/
|
|
20
35
|
export declare function mirrorOverrideUrl(baseUrl: string, source: AssetSource): string;
|
|
21
36
|
/**
|
|
22
37
|
* Default asset mirror: this repo's own GitHub Releases, reachable from every
|
|
@@ -29,7 +44,9 @@ export declare function mirrorOverrideUrl(baseUrl: string, source: AssetSource):
|
|
|
29
44
|
* `JGENGINE_ASSETS_NO_DEFAULT_MIRROR=1`.
|
|
30
45
|
*/
|
|
31
46
|
export declare const DEFAULT_RELEASE_BASE = "https://github.com/Noisemaker111/jgengine/releases/download/packs";
|
|
32
|
-
/** URL of `source`'s archive on the default GitHub-release mirror.
|
|
47
|
+
/** URL of `source`'s archive on the default GitHub-release mirror.
|
|
48
|
+
* @internal
|
|
49
|
+
*/
|
|
33
50
|
export declare function defaultReleaseUrl(source: AssetSource): string;
|
|
34
51
|
export interface DownloadPackOptions {
|
|
35
52
|
/** Explicit mirror base override (CLI `--mirror` / `JGENGINE_ASSETS_MIRROR`), tried before the primary provider path. */
|
|
@@ -52,8 +69,34 @@ export interface DownloadPackResult {
|
|
|
52
69
|
* source supplied the bytes; a mismatch is treated as a failed attempt so the
|
|
53
70
|
* next source in the chain is tried. Throws with every attempted URL and its
|
|
54
71
|
* failure reason when all sources fail.
|
|
55
|
-
|
|
72
|
+
* @internal
|
|
73
|
+
*/
|
|
56
74
|
export declare function downloadPackArchive(source: AssetSource, options?: DownloadPackOptions): Promise<DownloadPackResult>;
|
|
75
|
+
/**
|
|
76
|
+
* Pack a `.gltf` + optional external `.bin` into a single `.glb` so reindex /
|
|
77
|
+
* the shell only ever deal in one-file models. Strips buffer `uri` fields so
|
|
78
|
+
* the binary chunk is self-contained. External image URIs stay as-is (loaders
|
|
79
|
+
* resolve relative to the catalog URL only for GLB-embedded images).
|
|
80
|
+
* @internal
|
|
81
|
+
*/
|
|
82
|
+
export declare function packGltfToGlb(gltfBytes: Uint8Array, binBytes?: Uint8Array): Uint8Array;
|
|
83
|
+
/**
|
|
84
|
+
* Pull every GLB out of an archive. Also converts co-located `.gltf` + `.bin`
|
|
85
|
+
* pairs (Quaternius Standard packs on OpenGameArt) into `.glb` so the catalog
|
|
86
|
+
* stays one-file-per-model. GLB wins when both formats share a basename.
|
|
87
|
+
* @internal
|
|
88
|
+
*/
|
|
57
89
|
export declare function extractGlbs(archive: Uint8Array): ExtractedGlb[];
|
|
90
|
+
/** One SVG/PNG file pulled out of a sprite/icon-pack archive by `extractSpriteFiles`. */
|
|
91
|
+
export interface ExtractedSpriteFile {
|
|
92
|
+
file: string;
|
|
93
|
+
bytes: Uint8Array;
|
|
94
|
+
}
|
|
95
|
+
/** Pulls every SVG/PNG out of a sprite/icon-pack archive, deduped by basename regardless of nesting depth.
|
|
96
|
+
* @internal
|
|
97
|
+
*/
|
|
98
|
+
export declare function extractSpriteFiles(archive: Uint8Array): ExtractedSpriteFile[];
|
|
99
|
+
/** @internal */
|
|
58
100
|
export declare function extractTextures(archive: Uint8Array): ExtractedTexture[];
|
|
101
|
+
/** @internal */
|
|
59
102
|
export declare function sha256Hex(bytes: Uint8Array): Promise<string>;
|
package/dist/download.js
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
import { unzipSync } from "fflate";
|
|
2
2
|
import { isScrapeDownload } from "./manifest.js";
|
|
3
|
+
/** Max size of a downloaded (still-compressed) archive, in bytes. Provider zips run tens of MB; this leaves headroom without buffering an unbounded response. */
|
|
4
|
+
export const MAX_ARCHIVE_DOWNLOAD_BYTES = 256 * 1024 * 1024;
|
|
5
|
+
/** Max total uncompressed size this module will inflate out of one archive, in bytes. */
|
|
6
|
+
export const MAX_ARCHIVE_UNCOMPRESSED_BYTES = 512 * 1024 * 1024;
|
|
7
|
+
/** Max number of entries this module will extract out of one archive. */
|
|
8
|
+
export const MAX_ARCHIVE_ENTRY_COUNT = 20_000;
|
|
9
|
+
/** Max allowed originalSize/size ratio for a single archive entry — past this it's treated as a zip bomb. */
|
|
10
|
+
export const MAX_ARCHIVE_COMPRESSION_RATIO = 100;
|
|
11
|
+
/** @internal */
|
|
12
|
+
export function boundedExtractFilter(matches) {
|
|
13
|
+
let totalUncompressedBytes = 0;
|
|
14
|
+
let entryCount = 0;
|
|
15
|
+
return (file) => {
|
|
16
|
+
if (!matches(file))
|
|
17
|
+
return false;
|
|
18
|
+
if (file.size > 0 && file.originalSize / file.size > MAX_ARCHIVE_COMPRESSION_RATIO) {
|
|
19
|
+
throw new Error(`archive entry "${file.name}" has a ${(file.originalSize / file.size).toFixed(0)}x compression ratio, exceeds the ${MAX_ARCHIVE_COMPRESSION_RATIO}x zip-bomb guard`);
|
|
20
|
+
}
|
|
21
|
+
totalUncompressedBytes += file.originalSize;
|
|
22
|
+
if (totalUncompressedBytes > MAX_ARCHIVE_UNCOMPRESSED_BYTES) {
|
|
23
|
+
throw new Error(`archive would extract more than the ${MAX_ARCHIVE_UNCOMPRESSED_BYTES} byte uncompressed-size cap`);
|
|
24
|
+
}
|
|
25
|
+
entryCount += 1;
|
|
26
|
+
if (entryCount > MAX_ARCHIVE_ENTRY_COUNT) {
|
|
27
|
+
throw new Error(`archive has more than the ${MAX_ARCHIVE_ENTRY_COUNT} entry cap`);
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
3
32
|
function resolveRelative(path, pageUrl) {
|
|
4
33
|
try {
|
|
5
34
|
return new URL(path, pageUrl).toString();
|
|
@@ -8,6 +37,7 @@ function resolveRelative(path, pageUrl) {
|
|
|
8
37
|
return path;
|
|
9
38
|
}
|
|
10
39
|
}
|
|
40
|
+
/** @internal */
|
|
11
41
|
export function findArchiveUrl(html, pageUrl) {
|
|
12
42
|
const matches = html.match(/[^\s"'()<>]+\.zip/gi);
|
|
13
43
|
if (matches === null)
|
|
@@ -26,6 +56,7 @@ function score(candidate) {
|
|
|
26
56
|
value += 1;
|
|
27
57
|
return value;
|
|
28
58
|
}
|
|
59
|
+
/** @internal */
|
|
29
60
|
export async function resolveArchiveUrl(source, fetchImpl = fetch) {
|
|
30
61
|
const download = source.download;
|
|
31
62
|
if (!isScrapeDownload(download))
|
|
@@ -40,17 +71,30 @@ export async function resolveArchiveUrl(source, fetchImpl = fetch) {
|
|
|
40
71
|
}
|
|
41
72
|
return url;
|
|
42
73
|
}
|
|
74
|
+
/** @internal */
|
|
43
75
|
export async function downloadArchive(url, fetchImpl = fetch) {
|
|
44
76
|
const response = await fetchImpl(url, { redirect: "follow" });
|
|
45
77
|
if (!response.ok)
|
|
46
78
|
throw new Error(`download ${url} -> HTTP ${response.status}`);
|
|
47
|
-
|
|
79
|
+
const declaredLength = response.headers.get("content-length");
|
|
80
|
+
if (declaredLength !== null) {
|
|
81
|
+
const declared = Number(declaredLength);
|
|
82
|
+
if (Number.isFinite(declared) && declared > MAX_ARCHIVE_DOWNLOAD_BYTES) {
|
|
83
|
+
throw new Error(`download ${url} declares ${declared} bytes, exceeds the ${MAX_ARCHIVE_DOWNLOAD_BYTES} byte cap`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
87
|
+
if (bytes.byteLength > MAX_ARCHIVE_DOWNLOAD_BYTES) {
|
|
88
|
+
throw new Error(`download ${url} is ${bytes.byteLength} bytes, exceeds the ${MAX_ARCHIVE_DOWNLOAD_BYTES} byte cap`);
|
|
89
|
+
}
|
|
90
|
+
return bytes;
|
|
48
91
|
}
|
|
49
92
|
/**
|
|
50
93
|
* Layout for the `--mirror` / `JGENGINE_ASSETS_MIRROR` base URL override: the
|
|
51
94
|
* archive for a pack is expected at `<baseUrl>/<provider>/<packId>.zip`, e.g.
|
|
52
95
|
* `https://my-mirror.example.com/kenney/kenney-nature.zip`.
|
|
53
|
-
|
|
96
|
+
* @internal
|
|
97
|
+
*/
|
|
54
98
|
export function mirrorOverrideUrl(baseUrl, source) {
|
|
55
99
|
const base = baseUrl.replace(/\/+$/, "");
|
|
56
100
|
return `${base}/${source.provider}/${source.id}.zip`;
|
|
@@ -66,7 +110,9 @@ export function mirrorOverrideUrl(baseUrl, source) {
|
|
|
66
110
|
* `JGENGINE_ASSETS_NO_DEFAULT_MIRROR=1`.
|
|
67
111
|
*/
|
|
68
112
|
export const DEFAULT_RELEASE_BASE = "https://github.com/Noisemaker111/jgengine/releases/download/packs";
|
|
69
|
-
/** URL of `source`'s archive on the default GitHub-release mirror.
|
|
113
|
+
/** URL of `source`'s archive on the default GitHub-release mirror.
|
|
114
|
+
* @internal
|
|
115
|
+
*/
|
|
70
116
|
export function defaultReleaseUrl(source) {
|
|
71
117
|
return `${DEFAULT_RELEASE_BASE}/${source.provider}-${source.id}.zip`;
|
|
72
118
|
}
|
|
@@ -89,7 +135,8 @@ async function verifyPinnedSha(source, archive) {
|
|
|
89
135
|
* source supplied the bytes; a mismatch is treated as a failed attempt so the
|
|
90
136
|
* next source in the chain is tried. Throws with every attempted URL and its
|
|
91
137
|
* failure reason when all sources fail.
|
|
92
|
-
|
|
138
|
+
* @internal
|
|
139
|
+
*/
|
|
93
140
|
export async function downloadPackArchive(source, options = {}) {
|
|
94
141
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
95
142
|
const attempted = [];
|
|
@@ -135,10 +182,8 @@ export async function downloadPackArchive(source, options = {}) {
|
|
|
135
182
|
throw new Error(`failed to download ${source.id} from all ${attempted.length} source(s):\n` +
|
|
136
183
|
errors.map((line) => ` - ${line}`).join("\n"));
|
|
137
184
|
}
|
|
138
|
-
|
|
139
|
-
const entries = unzipSync(archive, {
|
|
140
|
-
filter: (file) => /\.glb$/i.test(file.name),
|
|
141
|
-
});
|
|
185
|
+
function dedupeByBasename(archive, pattern) {
|
|
186
|
+
const entries = unzipSync(archive, { filter: boundedExtractFilter((file) => pattern.test(file.name)) });
|
|
142
187
|
const byName = new Map();
|
|
143
188
|
for (const [path, bytes] of Object.entries(entries)) {
|
|
144
189
|
const base = path.split("/").pop();
|
|
@@ -149,10 +194,113 @@ export function extractGlbs(archive) {
|
|
|
149
194
|
}
|
|
150
195
|
return Array.from(byName, ([file, bytes]) => ({ file, bytes })).sort((a, b) => a.file.localeCompare(b.file));
|
|
151
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* Pack a `.gltf` + optional external `.bin` into a single `.glb` so reindex /
|
|
199
|
+
* the shell only ever deal in one-file models. Strips buffer `uri` fields so
|
|
200
|
+
* the binary chunk is self-contained. External image URIs stay as-is (loaders
|
|
201
|
+
* resolve relative to the catalog URL only for GLB-embedded images).
|
|
202
|
+
* @internal
|
|
203
|
+
*/
|
|
204
|
+
export function packGltfToGlb(gltfBytes, binBytes) {
|
|
205
|
+
const json = JSON.parse(new TextDecoder().decode(gltfBytes));
|
|
206
|
+
if (Array.isArray(json.buffers)) {
|
|
207
|
+
for (const buffer of json.buffers) {
|
|
208
|
+
delete buffer.uri;
|
|
209
|
+
if (binBytes !== undefined)
|
|
210
|
+
buffer.byteLength = binBytes.byteLength;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const jsonText = JSON.stringify(json);
|
|
214
|
+
const jsonPad = (4 - (jsonText.length % 4)) % 4;
|
|
215
|
+
const jsonChunk = new Uint8Array(jsonText.length + jsonPad);
|
|
216
|
+
new TextEncoder().encodeInto(jsonText, jsonChunk);
|
|
217
|
+
for (let i = jsonText.length; i < jsonChunk.length; i++)
|
|
218
|
+
jsonChunk[i] = 0x20;
|
|
219
|
+
const binPad = binBytes === undefined ? 0 : (4 - (binBytes.byteLength % 4)) % 4;
|
|
220
|
+
const binChunkLen = binBytes === undefined ? 0 : binBytes.byteLength + binPad;
|
|
221
|
+
const total = 12 +
|
|
222
|
+
8 +
|
|
223
|
+
jsonChunk.byteLength +
|
|
224
|
+
(binBytes === undefined ? 0 : 8 + binChunkLen);
|
|
225
|
+
const out = new Uint8Array(total);
|
|
226
|
+
const view = new DataView(out.buffer);
|
|
227
|
+
view.setUint32(0, 0x46546c67, true); // glTF
|
|
228
|
+
view.setUint32(4, 2, true);
|
|
229
|
+
view.setUint32(8, total, true);
|
|
230
|
+
let o = 12;
|
|
231
|
+
view.setUint32(o, jsonChunk.byteLength, true);
|
|
232
|
+
view.setUint32(o + 4, 0x4e4f534a, true); // JSON
|
|
233
|
+
out.set(jsonChunk, o + 8);
|
|
234
|
+
o += 8 + jsonChunk.byteLength;
|
|
235
|
+
if (binBytes !== undefined) {
|
|
236
|
+
view.setUint32(o, binChunkLen, true);
|
|
237
|
+
view.setUint32(o + 4, 0x004e4942, true); // BIN
|
|
238
|
+
out.set(binBytes, o + 8);
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Pull every GLB out of an archive. Also converts co-located `.gltf` + `.bin`
|
|
244
|
+
* pairs (Quaternius Standard packs on OpenGameArt) into `.glb` so the catalog
|
|
245
|
+
* stays one-file-per-model. GLB wins when both formats share a basename.
|
|
246
|
+
* @internal
|
|
247
|
+
*/
|
|
248
|
+
export function extractGlbs(archive) {
|
|
249
|
+
const entries = unzipSync(archive, {
|
|
250
|
+
filter: boundedExtractFilter((file) => /\.(glb|gltf|bin)$/i.test(file.name)),
|
|
251
|
+
});
|
|
252
|
+
const byDirBase = new Map();
|
|
253
|
+
for (const [path, bytes] of Object.entries(entries)) {
|
|
254
|
+
const parts = path.replace(/\\/g, "/").split("/");
|
|
255
|
+
const file = parts.pop();
|
|
256
|
+
if (file === undefined || file.length === 0)
|
|
257
|
+
continue;
|
|
258
|
+
const dir = parts.join("/");
|
|
259
|
+
// KayKit ships some files as `name.gltf.glb` — strip both suffixes for a clean id.
|
|
260
|
+
const base = file.replace(/\.gltf\.glb$/i, "").replace(/\.(glb|gltf|bin)$/i, "");
|
|
261
|
+
if (base.length === 0 || base === file)
|
|
262
|
+
continue;
|
|
263
|
+
const extMatch = file.match(/\.(glb|gltf|bin)$/i);
|
|
264
|
+
if (extMatch === null)
|
|
265
|
+
continue;
|
|
266
|
+
const ext = extMatch[1].toLowerCase();
|
|
267
|
+
const key = `${dir}\0${base}`;
|
|
268
|
+
const slot = byDirBase.get(key) ?? {};
|
|
269
|
+
if (ext === "glb")
|
|
270
|
+
slot.glb = bytes;
|
|
271
|
+
else if (ext === "gltf")
|
|
272
|
+
slot.gltf = bytes;
|
|
273
|
+
else if (ext === "bin")
|
|
274
|
+
slot.bin = bytes;
|
|
275
|
+
byDirBase.set(key, slot);
|
|
276
|
+
}
|
|
277
|
+
const byName = new Map();
|
|
278
|
+
for (const [key, slot] of byDirBase) {
|
|
279
|
+
const base = key.split("\0")[1];
|
|
280
|
+
const file = `${base}.glb`;
|
|
281
|
+
if (slot.glb !== undefined) {
|
|
282
|
+
if (!byName.has(file))
|
|
283
|
+
byName.set(file, slot.glb);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (slot.gltf !== undefined) {
|
|
287
|
+
if (!byName.has(file))
|
|
288
|
+
byName.set(file, packGltfToGlb(slot.gltf, slot.bin));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return Array.from(byName, ([file, bytes]) => ({ file, bytes })).sort((a, b) => a.file.localeCompare(b.file));
|
|
292
|
+
}
|
|
293
|
+
/** Pulls every SVG/PNG out of a sprite/icon-pack archive, deduped by basename regardless of nesting depth.
|
|
294
|
+
* @internal
|
|
295
|
+
*/
|
|
296
|
+
export function extractSpriteFiles(archive) {
|
|
297
|
+
return dedupeByBasename(archive, /\.(svg|png)$/i);
|
|
298
|
+
}
|
|
152
299
|
const TEXTURE_ENTRY = /(?:^|\/)(Textures\/[^/]+)$/i;
|
|
300
|
+
/** @internal */
|
|
153
301
|
export function extractTextures(archive) {
|
|
154
302
|
const entries = unzipSync(archive, {
|
|
155
|
-
filter: (file) => TEXTURE_ENTRY.test(file.name),
|
|
303
|
+
filter: boundedExtractFilter((file) => TEXTURE_ENTRY.test(file.name)),
|
|
156
304
|
});
|
|
157
305
|
const byRel = new Map();
|
|
158
306
|
for (const [path, bytes] of Object.entries(entries)) {
|
|
@@ -165,6 +313,7 @@ export function extractTextures(archive) {
|
|
|
165
313
|
}
|
|
166
314
|
return Array.from(byRel, ([file, bytes]) => ({ file, bytes })).sort((a, b) => a.file.localeCompare(b.file));
|
|
167
315
|
}
|
|
316
|
+
/** @internal */
|
|
168
317
|
export async function sha256Hex(bytes) {
|
|
169
318
|
const buffer = new ArrayBuffer(bytes.byteLength);
|
|
170
319
|
new Uint8Array(buffer).set(bytes);
|