@mpgd/cli 0.30.1 → 0.32.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.
@@ -0,0 +1,32 @@
1
+ export interface AssetPackBuildReport {
2
+ readonly outDir: string;
3
+ readonly manifestPath: string;
4
+ readonly outputs: readonly {
5
+ readonly path: string;
6
+ readonly bytes: number;
7
+ readonly sha256: string;
8
+ readonly action: 'written' | 'unchanged';
9
+ }[];
10
+ readonly archives: readonly {
11
+ readonly packId: string;
12
+ readonly path: string;
13
+ readonly entryCount: number;
14
+ readonly storeEntries: number;
15
+ readonly deflateEntries: number;
16
+ readonly sourceBytes: number;
17
+ readonly archiveBytes: number;
18
+ readonly sha256: string;
19
+ }[];
20
+ }
21
+ /**
22
+ * Build files or ZIP delivery artifacts for the configured packs. The same
23
+ * inputs, options and Node/zlib build produce byte-identical outputs; builds
24
+ * never modify sources, never delete existing output, reject different bytes
25
+ * at existing output paths, and write the manifest only after every artifact
26
+ * is in place.
27
+ */
28
+ export declare function buildAssetPacks(options: {
29
+ readonly configPath: string;
30
+ readonly outDir: string;
31
+ readonly cwd?: string;
32
+ }): AssetPackBuildReport;
@@ -0,0 +1,411 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, linkSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
3
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
4
+ import { deflateRawSync } from 'node:zlib';
5
+ import { PHASER_PACK_DELIVERY_FORMAT, PHASER_PACK_DELIVERY_VERSION, phaserPackMediaTypeForPath, validatePhaserPackBuildConfig, validatePhaserPackDeliveryManifest, } from '@mpgd/phaser-assets/pack-format';
6
+ import { createDeterministicZip } from './asset-pack-zip.js';
7
+ const manifestFileName = 'asset-pack-delivery.json';
8
+ let stagingSequence = 0;
9
+ const sha256Of = (data) => createHash('sha256').update(data).digest('hex');
10
+ /** Resolve symlinks for the longest existing ancestor, keeping the remainder. */
11
+ function realpathBestEffort(target) {
12
+ // lstat-based existence stops the walk at dangling symlinks, so realpath
13
+ // fails closed on them instead of lexically skipping the link.
14
+ const exists = (path) => {
15
+ try {
16
+ lstatSync(path);
17
+ return true;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ };
23
+ let existing = target;
24
+ while (!exists(existing)) {
25
+ const parent = dirname(existing);
26
+ if (parent === existing) {
27
+ return existing;
28
+ }
29
+ existing = parent;
30
+ }
31
+ let real;
32
+ try {
33
+ real = realpathSync(existing);
34
+ }
35
+ catch (error) {
36
+ throw new Error(`Output path resolves through a broken symlink: ${target}`, { cause: error });
37
+ }
38
+ return existing === target ? real : resolve(real, relative(existing, target));
39
+ }
40
+ /** Artifact writes must never traverse symlinks below the output root. */
41
+ function assertNoSymlinkUnder(base, relativePath) {
42
+ let current = base;
43
+ for (const component of relativePath.split('/')) {
44
+ current = join(current, component);
45
+ let stat;
46
+ try {
47
+ stat = lstatSync(current);
48
+ }
49
+ catch {
50
+ return;
51
+ }
52
+ if (stat.isSymbolicLink()) {
53
+ throw new Error(`Output path traverses a symbolic link below the output root: ${join(base, relativePath)}`);
54
+ }
55
+ }
56
+ }
57
+ function readJsonConfig(configPath) {
58
+ let raw;
59
+ try {
60
+ raw = readFileSync(configPath, 'utf8');
61
+ }
62
+ catch {
63
+ throw new Error(`Cannot read asset pack build config: ${configPath}`);
64
+ }
65
+ let parsed;
66
+ try {
67
+ parsed = JSON.parse(raw);
68
+ }
69
+ catch (error) {
70
+ throw new Error(`Asset pack build config is not valid JSON (${configPath}): ${String(error)}`);
71
+ }
72
+ return validatePhaserPackBuildConfig(parsed);
73
+ }
74
+ /** Reject symlinks and any path component escaping the source root. */
75
+ function readSourceFile(rootPath, entryPath) {
76
+ let current = rootPath;
77
+ for (const component of entryPath.split('/')) {
78
+ current = join(current, component);
79
+ let stat;
80
+ try {
81
+ stat = lstatSync(current);
82
+ }
83
+ catch {
84
+ throw new Error(`Missing pack source file: ${entryPath}`);
85
+ }
86
+ if (stat.isSymbolicLink()) {
87
+ throw new Error(`Pack source path must not contain symbolic links: ${entryPath}`);
88
+ }
89
+ }
90
+ if (!lstatSync(current).isFile()) {
91
+ throw new Error(`Pack source path is not a regular file: ${entryPath}`);
92
+ }
93
+ const relativeToRoot = relative(realpathSync(rootPath), realpathSync(current));
94
+ // Exact '..' components only: names like '..dots' stay inside the root.
95
+ if (relativeToRoot === '..' || relativeToRoot.startsWith('../') || isAbsolute(relativeToRoot)) {
96
+ throw new Error(`Pack source path escapes the source root: ${entryPath}`);
97
+ }
98
+ const data = readFileSync(current);
99
+ if (data.length === 0) {
100
+ throw new Error(`Pack source file is empty: ${entryPath}`);
101
+ }
102
+ return data;
103
+ }
104
+ function planAssetFiles(asset, rootPath, delivery) {
105
+ const sources = asset.kind === 'atlas'
106
+ ? [
107
+ { role: 'texture', entryPath: asset.texture },
108
+ { role: 'atlas', entryPath: asset.atlas },
109
+ ]
110
+ : [{ role: 'texture', entryPath: asset.file }];
111
+ return sources.map(({ role, entryPath }) => {
112
+ const media = phaserPackMediaTypeForPath(entryPath);
113
+ if (media === null) {
114
+ throw new Error(`Unsupported pack source extension: ${asset.key}/${entryPath}`);
115
+ }
116
+ if (role === 'texture' && !media.mediaType.startsWith('image/')) {
117
+ throw new Error(`Texture sources must be images: ${asset.key}/${entryPath}`);
118
+ }
119
+ if (role === 'atlas' && media.mediaType !== 'application/json') {
120
+ throw new Error(`Atlas metadata sources must be JSON: ${asset.key}/${entryPath}`);
121
+ }
122
+ const data = readSourceFile(rootPath, entryPath);
123
+ let method = asset.compression ?? media.defaultMethod;
124
+ let stored;
125
+ if (delivery === 'zip' && method === 'deflate') {
126
+ const deflated = deflateRawSync(data, { level: 9 });
127
+ // Default policy may fall back to STORE when DEFLATE is not smaller;
128
+ // an explicit per-asset override forces its method. The trial result is
129
+ // reused by the ZIP writer instead of deflating the same bytes again.
130
+ if (asset.compression === undefined && deflated.length >= data.length) {
131
+ method = 'store';
132
+ }
133
+ else {
134
+ stored = deflated;
135
+ }
136
+ }
137
+ return {
138
+ role, entryPath, data, mediaType: media.mediaType, method, stored,
139
+ };
140
+ });
141
+ }
142
+ function deliveryAsset(asset, files, pack) {
143
+ const outputPrefix = `packs/${pack.id}@${pack.revision}`;
144
+ const deliveryFiles = files.map((file) => ({
145
+ role: file.role,
146
+ mediaType: file.mediaType,
147
+ bytes: file.data.length,
148
+ sha256: sha256Of(file.data),
149
+ path: pack.delivery === 'files' ? `${outputPrefix}/${file.entryPath}` : file.entryPath,
150
+ ...(pack.delivery === 'zip' ? { method: file.method } : {}),
151
+ }));
152
+ return {
153
+ assetKey: asset.key,
154
+ kind: asset.kind,
155
+ ...(asset.kind === 'spritesheet' ? { frameConfig: asset.frameConfig } : {}),
156
+ files: deliveryFiles,
157
+ };
158
+ }
159
+ /**
160
+ * Build files or ZIP delivery artifacts for the configured packs. The same
161
+ * inputs, options and Node/zlib build produce byte-identical outputs; builds
162
+ * never modify sources, never delete existing output, reject different bytes
163
+ * at existing output paths, and write the manifest only after every artifact
164
+ * is in place.
165
+ */
166
+ export function buildAssetPacks(options) {
167
+ const cwd = options.cwd ?? process.cwd();
168
+ const configPath = resolve(cwd, options.configPath);
169
+ const config = readJsonConfig(configPath);
170
+ const rootPath = resolve(dirname(configPath), config.root);
171
+ if (!existsSync(rootPath)) {
172
+ throw new Error(`Pack source root does not exist: ${rootPath}`);
173
+ }
174
+ const outPath = resolve(cwd, options.outDir);
175
+ // Compare resolved paths, not just lexical ones: an existing symlinked
176
+ // output ancestor could point back into the source root.
177
+ const realOutPath = realpathBestEffort(outPath);
178
+ const realRootPath = realpathSync(rootPath);
179
+ const overlaps = (from, to) => {
180
+ const step = relative(from, to);
181
+ // Cross-volume paths are drive-qualified and can never be nested.
182
+ if (isAbsolute(step)) {
183
+ return false;
184
+ }
185
+ return step === '' || (step !== '..' && !step.startsWith('../'));
186
+ };
187
+ if (overlaps(realOutPath, realRootPath) || overlaps(realRootPath, realOutPath)) {
188
+ throw new Error(`Output directory must be outside the pack source root: ${outPath} vs ${rootPath}`);
189
+ }
190
+ const revisions = new Map(config.packs.map((pack) => [pack.id, pack.revision]));
191
+ const outputs = new Map();
192
+ const archives = [];
193
+ const deliveryPacks = [];
194
+ for (const pack of config.packs) {
195
+ const packOutputPrefix = `packs/${pack.id}@${pack.revision}`;
196
+ const planned = [];
197
+ for (const asset of pack.assets) {
198
+ const files = planAssetFiles(asset, rootPath, pack.delivery);
199
+ if (pack.delivery === 'files') {
200
+ for (const file of files) {
201
+ outputs.set(`${packOutputPrefix}/${file.entryPath}`, file.data);
202
+ }
203
+ }
204
+ planned.push({ asset, files });
205
+ }
206
+ let archive;
207
+ if (pack.delivery === 'zip') {
208
+ const entries = [];
209
+ let sourceBytes = 0;
210
+ let storeEntries = 0;
211
+ let deflateEntries = 0;
212
+ for (const { files } of planned) {
213
+ for (const file of files) {
214
+ entries.push({
215
+ path: file.entryPath,
216
+ data: file.data,
217
+ method: file.method,
218
+ ...(file.stored === undefined ? {} : { stored: file.stored }),
219
+ });
220
+ sourceBytes += file.data.length;
221
+ if (file.method === 'store') {
222
+ storeEntries++;
223
+ }
224
+ else {
225
+ deflateEntries++;
226
+ }
227
+ }
228
+ }
229
+ const archiveBytes = createDeterministicZip(entries);
230
+ const archivePath = `${packOutputPrefix}.zip`;
231
+ outputs.set(archivePath, archiveBytes);
232
+ archive = {
233
+ path: archivePath,
234
+ bytes: archiveBytes.length,
235
+ sha256: sha256Of(archiveBytes),
236
+ entryCount: entries.length,
237
+ };
238
+ archives.push({
239
+ packId: pack.id,
240
+ path: archivePath,
241
+ entryCount: entries.length,
242
+ storeEntries,
243
+ deflateEntries,
244
+ sourceBytes,
245
+ archiveBytes: archiveBytes.length,
246
+ sha256: archive.sha256,
247
+ });
248
+ }
249
+ deliveryPacks.push({
250
+ packId: pack.id,
251
+ revision: pack.revision,
252
+ dependencies: (pack.dependsOn ?? []).map((dependency) => ({
253
+ packId: dependency,
254
+ revision: revisions.get(dependency),
255
+ })),
256
+ delivery: pack.delivery,
257
+ assets: planned.map(({ asset, files }) => deliveryAsset(asset, files, pack)),
258
+ ...(archive === undefined ? {} : { archive }),
259
+ });
260
+ }
261
+ const manifest = {
262
+ format: PHASER_PACK_DELIVERY_FORMAT,
263
+ version: PHASER_PACK_DELIVERY_VERSION,
264
+ packs: deliveryPacks,
265
+ };
266
+ const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
267
+ // The manifest describes the artifacts; it never travels inside them, and
268
+ // consumers verify archives and entries against these external digests.
269
+ validatePhaserPackDeliveryManifest(JSON.parse(manifestBytes.toString('utf8')));
270
+ // Pack artifacts under packs/ are immutable: a rebuild refuses to overwrite
271
+ // different bytes at an existing artifact path. The manifest at the output
272
+ // root is the replaceable summary of the latest successful build; it is
273
+ // only written after every artifact is in place. Each write lands through a
274
+ // staging file plus rename so a crash can never leave truncated bytes at a
275
+ // path later builds treat as immutable.
276
+ const outputRecords = [];
277
+ for (const [path, data] of outputs) {
278
+ const target = join(outPath, path);
279
+ if (!existsSync(target)) {
280
+ outputRecords.push({
281
+ path,
282
+ bytes: data.length,
283
+ sha256: sha256Of(data),
284
+ action: 'written',
285
+ data,
286
+ });
287
+ continue;
288
+ }
289
+ let existing;
290
+ try {
291
+ existing = readFileSync(target);
292
+ }
293
+ catch {
294
+ throw new Error(`Output path is not a readable file: ${path}`);
295
+ }
296
+ if (!existing.equals(data)) {
297
+ throw new Error(`Output path already holds different bytes (immutable conflict): ${path}. `
298
+ + 'Change the pack content or revision, or build into a new directory.');
299
+ }
300
+ outputRecords.push({
301
+ path,
302
+ bytes: data.length,
303
+ sha256: sha256Of(data),
304
+ action: 'unchanged',
305
+ data,
306
+ });
307
+ }
308
+ const manifestTarget = join(outPath, manifestFileName);
309
+ const manifestUnchanged = existsSync(manifestTarget)
310
+ && (() => {
311
+ try {
312
+ return readFileSync(manifestTarget).equals(manifestBytes);
313
+ }
314
+ catch {
315
+ return false;
316
+ }
317
+ })();
318
+ outputRecords.push({
319
+ path: manifestFileName,
320
+ bytes: manifestBytes.length,
321
+ sha256: sha256Of(manifestBytes),
322
+ action: manifestUnchanged ? 'unchanged' : 'written',
323
+ data: manifestBytes,
324
+ });
325
+ // Every output path — including unchanged ones — must stay free of
326
+ // symlinks below the output root, so the unchanged shortcut can never
327
+ // bless a symlinked descendant.
328
+ const outputPaths = new Set(outputRecords.map((record) => record.path));
329
+ for (const record of outputRecords) {
330
+ assertNoSymlinkUnder(outPath, record.path);
331
+ }
332
+ for (const record of outputRecords) {
333
+ if (record.action === 'unchanged') {
334
+ continue;
335
+ }
336
+ const target = join(outPath, record.path);
337
+ mkdirSync(dirname(target), { recursive: true });
338
+ // Exclusive creation with a per-process sequence keeps staging names
339
+ // unique even across builders that share a PID.
340
+ let stagingPath = '';
341
+ let staging = '';
342
+ try {
343
+ for (let attempt = 0; attempt < 64; attempt++) {
344
+ const candidate = `${record.path}.mpgd-staging-${process.pid}-${++stagingSequence}`;
345
+ if (outputPaths.has(candidate)) {
346
+ continue;
347
+ }
348
+ try {
349
+ staging = join(outPath, candidate);
350
+ writeFileSync(staging, record.data, { flag: 'wx' });
351
+ stagingPath = candidate;
352
+ break;
353
+ }
354
+ catch (error) {
355
+ if (error.code !== 'EEXIST') {
356
+ throw error;
357
+ }
358
+ }
359
+ }
360
+ if (stagingPath === '') {
361
+ throw new Error(`Cannot reserve a unique staging path for ${record.path}`);
362
+ }
363
+ if (record.path === manifestFileName) {
364
+ // The manifest is the replaceable summary of the latest build.
365
+ renameSync(staging, target);
366
+ }
367
+ else {
368
+ // Pack artifacts are immutable: link fails atomically when another
369
+ // build already published this path, instead of replacing it.
370
+ try {
371
+ linkSync(staging, target);
372
+ }
373
+ catch (error) {
374
+ if (error.code !== 'EEXIST') {
375
+ throw error;
376
+ }
377
+ let published;
378
+ try {
379
+ published = readFileSync(target);
380
+ }
381
+ catch {
382
+ throw new Error(`Output path is not a readable file: ${record.path}`);
383
+ }
384
+ if (!published.equals(record.data)) {
385
+ throw new Error(`Output path already holds different bytes (immutable conflict): ${record.path}. `
386
+ + 'Change the pack content or revision, or build into a new directory.');
387
+ }
388
+ }
389
+ // The published artifact keeps the staging inode; drop the staging name.
390
+ rmSync(staging, { force: true });
391
+ }
392
+ }
393
+ catch (error) {
394
+ // Best-effort cleanup: a locked or unreadable staging file must not
395
+ // mask the original write or rename failure.
396
+ try {
397
+ rmSync(staging, { force: true });
398
+ }
399
+ catch {
400
+ // Ignore cleanup failures.
401
+ }
402
+ throw error;
403
+ }
404
+ }
405
+ return {
406
+ outDir: outPath,
407
+ manifestPath: manifestTarget,
408
+ outputs: outputRecords,
409
+ archives,
410
+ };
411
+ }
@@ -0,0 +1,76 @@
1
+ /** One verification failure with the stage and stable code it surfaced at. */
2
+ export interface AssetPackVerifyFailure {
3
+ readonly stage: 'args' | 'manifest' | 'paths' | 'files' | 'archive' | 'limits';
4
+ readonly code: string;
5
+ readonly message: string;
6
+ readonly packId?: string | undefined;
7
+ }
8
+ /** Optional static-host object limits; all finite positive safe integers. */
9
+ export interface AssetPackVerifyHostLimits {
10
+ readonly maxObjectBytes?: number | undefined;
11
+ readonly maxFiles?: number | undefined;
12
+ readonly maxTotalBytes?: number | undefined;
13
+ }
14
+ export interface AssetPackVerifyReport {
15
+ readonly ok: boolean;
16
+ readonly manifest: {
17
+ readonly sha256: string;
18
+ readonly bytes: number;
19
+ readonly format: string;
20
+ readonly version: number;
21
+ readonly packs: number;
22
+ };
23
+ /** Referenced objects as declared by the chosen manifest; verification
24
+ * outcomes live in `failures`, so these totals stay meaningful even
25
+ * when artifacts are missing or unreadable. */
26
+ readonly referenced: {
27
+ readonly files: number;
28
+ readonly bytes: number;
29
+ };
30
+ /** Root inventory: every regular file physically under --root. Only
31
+ * computed when host limits are requested. `largest` is the biggest
32
+ * regular file seen; on a truncated walk it is a lower bound. */
33
+ readonly inventory?: {
34
+ readonly files: number;
35
+ readonly bytes: number;
36
+ readonly largest: number;
37
+ readonly truncated: boolean;
38
+ } | undefined;
39
+ readonly archives: readonly {
40
+ readonly packId: string;
41
+ readonly entries: number;
42
+ readonly expandedBytes: number;
43
+ }[];
44
+ readonly limits?: AssetPackVerifyHostLimits | undefined;
45
+ readonly failures: readonly AssetPackVerifyFailure[];
46
+ /** Conditions this local, read-only check cannot verify. */
47
+ readonly notVerified: readonly string[];
48
+ }
49
+ export interface AssetPackVerifyOptions {
50
+ /** Path to the delivery manifest JSON document. */
51
+ readonly manifestPath: string;
52
+ /** Deployment root the manifest artifact paths resolve against. */
53
+ readonly root: string;
54
+ readonly hostLimits?: AssetPackVerifyHostLimits | undefined;
55
+ /** Cap on the manifest document itself; default 32 MiB. */
56
+ readonly manifestByteCap?: number | undefined;
57
+ /** Whole-verification wall-clock budget in milliseconds; default 120000. */
58
+ readonly verifyTimeoutMs?: number | undefined;
59
+ /** Cap on a single zip archive materialized for decode; default 512 MiB.
60
+ * Larger declared archives fail at the limits stage before buffering. */
61
+ readonly maxArchiveBytes?: number | undefined;
62
+ /** Independent cap on one expanded zip entry, whatever the manifest
63
+ * declares; default 256 MiB. Larger declared entries fail at the limits
64
+ * stage before any decompression allocation. */
65
+ readonly maxEntryBytes?: number | undefined;
66
+ /** Independent cap on one archive's total expanded bytes, whatever the
67
+ * manifest declares; default 1 GiB. Larger declared totals fail at the
68
+ * limits stage before any decompression allocation. */
69
+ readonly maxExpandedBytes?: number | undefined;
70
+ }
71
+ /** Verify a delivery manifest's referenced artifacts against a deployment
72
+ * root: read-only, deterministic, no network and no extraction. Every
73
+ * referenced byte is read and hashed; zip archives are additionally decoded
74
+ * through the shared pure core. Verification continues after the first
75
+ * failure so one report can carry every distinct problem. */
76
+ export declare function verifyAssetPackDelivery(options: AssetPackVerifyOptions): Promise<AssetPackVerifyReport>;