@mpgd/cli 0.54.0 → 0.54.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ import { buildAssetPacks } from './asset-pack-build.js';
12
12
  import { verifyAssetPackDelivery } from './asset-pack-verify.js';
13
13
  import { buildAssetPackTarget } from './asset-pack-target.js';
14
14
  import { publishAssetPacks } from './asset-pack-publish.js';
15
+ import { assertLegalProjectPath, publishLegalSiteFiles } from './legal-site-output.js';
15
16
  import { assertAssetPackTargetPolicy } from '@mpgd/target-config/asset-packs';
16
17
  import { materializeCapacitorShellStarter, planCapacitorShellStarter, } from './capacitor-shell-starter.js';
17
18
  import { doctorNativeDeployment, initializeDeployConfig, parseDeployTargets, planNativeDeployment, readNativeDeploymentPlan, writeNativeDeploymentPlan, } from './deploy-planning.js';
@@ -1818,7 +1819,10 @@ const targetCommand = defineI18n({
1818
1819
  action: 'build',
1819
1820
  target,
1820
1821
  profile,
1821
- env: createTargetCommandEnv(ctx.values, { nativeBuildTarget: target }),
1822
+ env: createTargetCommandEnv(ctx.values, {
1823
+ nativeBuildTarget: target,
1824
+ requireSourceRevision: true,
1825
+ }),
1822
1826
  ...(variant === undefined ? {} : { variant }),
1823
1827
  });
1824
1828
  },
@@ -2448,7 +2452,7 @@ const targetCommand = defineI18n({
2448
2452
  run: (ctx) => {
2449
2453
  const aitVariant = readOptionalString(ctx.values['ait-variant']);
2450
2454
  const iosVariant = readOptionalString(ctx.values['ios-variant']);
2451
- const env = createTargetCommandEnv(ctx.values);
2455
+ const env = createTargetCommandEnv(ctx.values, { requireSourceRevision: true });
2452
2456
  runTargetMatrix({
2453
2457
  action: 'build',
2454
2458
  targets: parseTargetList(readOptionalString(ctx.values.targets), env),
@@ -2774,9 +2778,13 @@ function resolveLegalCommandOptions(values) {
2774
2778
  function buildLegalSite(input) {
2775
2779
  const legalDir = path.resolve(process.cwd(), input.legalDir);
2776
2780
  const outDir = path.resolve(process.cwd(), input.outDir);
2781
+ assertLegalProjectPath('legal-dir', legalDir);
2782
+ assertLegalProjectPath('out-dir', outDir);
2777
2783
  const outputs = createLegalSiteOutputs(legalDir, outDir);
2778
2784
  if (input.check) {
2785
+ assertLegalProjectPath('out-dir', outDir);
2779
2786
  const stale = outputs.filter((output) => {
2787
+ assertLegalProjectPath('out-dir', output.file);
2780
2788
  if (!existsSync(output.file)) {
2781
2789
  return true;
2782
2790
  }
@@ -2792,29 +2800,24 @@ function buildLegalSite(input) {
2792
2800
  outDir,
2793
2801
  };
2794
2802
  }
2795
- for (const output of outputs) {
2796
- mkdirSync(path.dirname(output.file), { recursive: true });
2797
- writeFileSync(output.file, output.content);
2798
- }
2803
+ publishLegalSiteFiles(outDir, outputs);
2799
2804
  return {
2800
2805
  outDir,
2801
2806
  };
2802
2807
  }
2803
2808
  function createLegalSiteOutputs(legalDir, outDir) {
2804
- const relativeLegalDir = path.relative(process.cwd(), legalDir);
2805
- const relativeOutDir = path.relative(process.cwd(), outDir);
2806
- if (relativeLegalDir.startsWith('..') || path.isAbsolute(relativeLegalDir)) {
2807
- throw new Error(`legal-dir must be inside the project root: ${legalDir}`);
2808
- }
2809
- if (relativeOutDir.startsWith('..') || path.isAbsolute(relativeOutDir)) {
2810
- throw new Error(`out-dir must be inside the project root: ${outDir}`);
2811
- }
2812
- const sourceRoot = toTemplatePath(relativeLegalDir || '.');
2809
+ assertLegalProjectPath('legal-dir', legalDir);
2810
+ assertLegalProjectPath('out-dir', outDir);
2811
+ const sourceRoot = toTemplatePath(path.relative(process.cwd(), legalDir) || '.');
2813
2812
  const outputs = legalPageSlugs.map((slug) => {
2814
2813
  const sourceFile = path.join(legalDir, `${slug}.html`);
2814
+ // existsSync and readFileSync follow a page symlink. Check the file
2815
+ // itself, not only the legal directory, immediately before reading it.
2816
+ assertLegalProjectPath('legal-dir', sourceFile);
2815
2817
  if (!existsSync(sourceFile)) {
2816
2818
  throw new Error(`Missing legal page source: ${sourceFile}`);
2817
2819
  }
2820
+ assertLegalProjectPath('legal-dir', sourceFile);
2818
2821
  return {
2819
2822
  file: path.join(outDir, slug, 'index.html'),
2820
2823
  content: normalizeLegalHtml(readFileSync(sourceFile, 'utf8'), sourceFile),
@@ -3347,7 +3350,11 @@ function createTargetCommandEnv(values, options = {}) {
3347
3350
  });
3348
3351
  const env = {
3349
3352
  ...process.env,
3350
- ...createGameOwnedReleaseEnv(gameRoot, process.env),
3353
+ // Release provenance is recorded only by build commands. Smoke, doctor,
3354
+ // icons, and submission preflight must still run on an exported game tree.
3355
+ ...createGameOwnedReleaseEnv(gameRoot, process.env, {
3356
+ requireSourceRevision: options.requireSourceRevision === true,
3357
+ }),
3351
3358
  MPGD_PLATFORM_TARGETS_FILE: preparedTargetsFile,
3352
3359
  ...(targetConfigExtensionsFile === undefined
3353
3360
  ? {}
@@ -3450,10 +3457,9 @@ function resolveTargetConfigExtensionsFile(gameRoot, configuredFile) {
3450
3457
  const gameOwnedFile = path.join(gameRoot, 'mpgd.target-config.json');
3451
3458
  return existsSync(gameOwnedFile) ? gameOwnedFile : undefined;
3452
3459
  }
3453
- function createGameOwnedReleaseEnv(gameRoot, baseEnv) {
3460
+ function createGameOwnedReleaseEnv(gameRoot, baseEnv, options) {
3454
3461
  const monetizationFiles = resolveGameOwnedMonetizationFiles(gameRoot, baseEnv);
3455
- const sourceGitSha = readOptionalString(baseEnv.MPGD_SOURCE_GIT_SHA)
3456
- ?? readSourceGitSha(gameRoot);
3462
+ const sourceGitSha = resolveCommandSourceGitSha(gameRoot, baseEnv, options.requireSourceRevision);
3457
3463
  const appVersion = readOptionalString(baseEnv.APP_VERSION)
3458
3464
  ?? readGamePackageVersion(gameRoot);
3459
3465
  const notificationTopicsFile = readConfiguredPath(baseEnv.MPGD_NOTIFICATION_TOPICS_FILE);
@@ -3464,7 +3470,7 @@ function createGameOwnedReleaseEnv(gameRoot, baseEnv) {
3464
3470
  ...(notificationTopicsFile === undefined
3465
3471
  ? {}
3466
3472
  : { MPGD_NOTIFICATION_TOPICS_FILE: path.resolve(gameRoot, notificationTopicsFile) }),
3467
- MPGD_SOURCE_GIT_SHA: sourceGitSha,
3473
+ ...(sourceGitSha === undefined ? {} : { MPGD_SOURCE_GIT_SHA: sourceGitSha }),
3468
3474
  ...(appVersion === undefined ? {} : { APP_VERSION: appVersion }),
3469
3475
  };
3470
3476
  }
@@ -3499,15 +3505,48 @@ function readConfiguredPath(value) {
3499
3505
  const normalized = value?.trim();
3500
3506
  return normalized === undefined || normalized.length === 0 ? undefined : normalized;
3501
3507
  }
3508
+ function resolveCommandSourceGitSha(gameRoot, baseEnv, requireSourceRevision) {
3509
+ const configuredSourceGitSha = readOptionalString(baseEnv.MPGD_SOURCE_GIT_SHA);
3510
+ if (requireSourceRevision) {
3511
+ return configuredSourceGitSha === undefined
3512
+ ? readSourceGitSha(gameRoot)
3513
+ : requireSourceGitSha(configuredSourceGitSha);
3514
+ }
3515
+ if (configuredSourceGitSha !== undefined) {
3516
+ return isSourceGitSha(configuredSourceGitSha) ? configuredSourceGitSha : undefined;
3517
+ }
3518
+ const detectedSourceGitSha = readGitHead(gameRoot);
3519
+ return detectedSourceGitSha !== undefined && isSourceGitSha(detectedSourceGitSha)
3520
+ ? detectedSourceGitSha
3521
+ : undefined;
3522
+ }
3502
3523
  function readSourceGitSha(gameRoot) {
3524
+ const head = readGitHead(gameRoot);
3525
+ if (head === undefined) {
3526
+ throw new Error(`Failed to resolve the game source Git revision for ${gameRoot}. `
3527
+ + 'Set MPGD_SOURCE_GIT_SHA to its lowercase 40-character commit SHA.');
3528
+ }
3529
+ return requireSourceGitSha(head);
3530
+ }
3531
+ function readGitHead(gameRoot) {
3503
3532
  const result = spawnSync('git', ['-C', gameRoot, 'rev-parse', 'HEAD'], {
3504
3533
  encoding: 'utf8',
3505
3534
  stdio: ['ignore', 'pipe', 'ignore'],
3506
3535
  });
3507
3536
  if (result.error !== undefined || result.status !== 0) {
3508
- return 'uncommitted';
3537
+ return undefined;
3509
3538
  }
3510
- return result.stdout.trim() || 'uncommitted';
3539
+ const head = result.stdout.trim();
3540
+ return head.length === 0 ? undefined : head;
3541
+ }
3542
+ function requireSourceGitSha(value) {
3543
+ if (!isSourceGitSha(value)) {
3544
+ throw new Error('MPGD_SOURCE_GIT_SHA must be a lowercase 40-character game source SHA.');
3545
+ }
3546
+ return value;
3547
+ }
3548
+ function isSourceGitSha(value) {
3549
+ return /^[0-9a-f]{40}$/u.test(value);
3511
3550
  }
3512
3551
  function readGamePackageVersion(gameRoot) {
3513
3552
  const packageFile = path.join(gameRoot, 'package.json');
@@ -1,6 +1,6 @@
1
1
  import { execFile as execFileCallback, spawnSync } from 'node:child_process';
2
- import { createHash } from 'node:crypto';
3
- import { existsSync, lstatSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { existsSync, lstatSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  import { compare, intersects, minVersion, satisfies, valid, validRange } from 'semver';
@@ -154,6 +154,9 @@ export function applyKitUpgrade(plan, runLockfileUpdate = updatePnpmLockfile) {
154
154
  ...plan.manifestDigests,
155
155
  ...plan.lockfileDigests,
156
156
  })) {
157
+ // Re-check links before the digest read. readFileSync follows a symlink,
158
+ // so a same-bytes swap would otherwise look unchanged.
159
+ assertOwnedUpgradePath(upgradeWriteAnchor(plan, file), file);
157
160
  const current = readFileSync(file, 'utf8');
158
161
  if (digest(current) !== expected) {
159
162
  throw new Error('File changed after upgrade planning: ' + file);
@@ -176,9 +179,11 @@ export function applyKitUpgrade(plan, runLockfileUpdate = updatePnpmLockfile) {
176
179
  section[update.packageName] = update.next;
177
180
  parsed[update.section] = section;
178
181
  }
179
- writeFileSync(manifest, stringifyLike(source, parsed));
182
+ writeUpgradeFile(plan, manifest, stringifyLike(source, parsed));
180
183
  }
181
184
  for (const root of plan.workspaceRoots) {
185
+ const lockfile = path.join(root, 'pnpm-lock.yaml');
186
+ assertOwnedUpgradePath(upgradeWriteAnchor(plan, lockfile), lockfile);
182
187
  runLockfileUpdate(root);
183
188
  }
184
189
  }
@@ -186,7 +191,7 @@ export function applyKitUpgrade(plan, runLockfileUpdate = updatePnpmLockfile) {
186
191
  const restoreFailures = [];
187
192
  for (const [file, original] of snapshots) {
188
193
  try {
189
- writeFileSync(file, original);
194
+ writeUpgradeFile(plan, file, original);
190
195
  }
191
196
  catch (restoreError) {
192
197
  restoreFailures.push(file + ': ' + formatError(restoreError));
@@ -473,6 +478,100 @@ function stringifyLike(original, value) {
473
478
  return JSON.stringify(value, null, indent).replaceAll('\n', newline)
474
479
  + (original.endsWith('\n') ? newline : '');
475
480
  }
481
+ function upgradeWriteAnchor(plan, file) {
482
+ const resolved = path.resolve(file);
483
+ let anchor = plan.gameRoot;
484
+ while (!inside(anchor, resolved)) {
485
+ const parent = path.dirname(anchor);
486
+ if (parent === anchor) {
487
+ throw new Error('Refusing to write outside the planned upgrade directory: ' + file);
488
+ }
489
+ anchor = parent;
490
+ }
491
+ // In-game files used to return plan.gameRoot before this check. A game
492
+ // directory or parent swapped for a symlink after planning then skipped
493
+ // lstat/realpath, and assertOwnedUpgradePath only inspects children.
494
+ let anchorStat;
495
+ try {
496
+ anchorStat = lstatSync(anchor);
497
+ }
498
+ catch (error) {
499
+ throw new Error('Refusing to write through a symbolic link: ' + anchor, { cause: error });
500
+ }
501
+ if (anchorStat.isSymbolicLink()) {
502
+ throw new Error('Refusing to write through a symbolic link: ' + anchor);
503
+ }
504
+ const anchorReal = realpathSync(anchor);
505
+ // Compare with the game root captured at planning time. A fresh realpath of
506
+ // both sides would follow a parent swapped for a symlink and look consistent.
507
+ if (!inside(anchorReal, plan.gameRoot)
508
+ && path.resolve(anchorReal) !== path.resolve(plan.gameRoot)) {
509
+ throw new Error('Refusing to write through a symbolic link: ' + anchor);
510
+ }
511
+ return anchor;
512
+ }
513
+ function assertOwnedUpgradePath(anchor, file) {
514
+ const resolved = path.resolve(file);
515
+ if (!inside(anchor, resolved)) {
516
+ throw new Error('Refusing to write outside the planned upgrade directory: ' + file);
517
+ }
518
+ const relative = path.relative(anchor, resolved);
519
+ const parts = relative.split(path.sep).filter((part) => part !== '' && part !== '.');
520
+ let current = anchor;
521
+ for (let index = 0; index < parts.length; index += 1) {
522
+ const part = parts[index];
523
+ if (part === undefined) {
524
+ continue;
525
+ }
526
+ current = path.join(current, part);
527
+ const isFinal = index === parts.length - 1;
528
+ let stat;
529
+ try {
530
+ stat = lstatSync(current);
531
+ }
532
+ catch (error) {
533
+ if (isErrno(error, 'ENOENT') && isFinal) {
534
+ return;
535
+ }
536
+ throw new Error('Upgrade file disappeared before write: ' + file, { cause: error });
537
+ }
538
+ if (stat.isSymbolicLink()) {
539
+ throw new Error('Refusing to write through a symbolic link: ' + current);
540
+ }
541
+ }
542
+ if (!inside(realpathSync(anchor), realpathSync(resolved))) {
543
+ throw new Error('Refusing to write through a symbolic link: ' + file);
544
+ }
545
+ }
546
+ function writeUpgradeFile(plan, file, source) {
547
+ const anchor = upgradeWriteAnchor(plan, file);
548
+ assertOwnedUpgradePath(anchor, file);
549
+ const temporary = `${file}.mpgd-${randomUUID()}.tmp`;
550
+ try {
551
+ let mode = 0o644;
552
+ try {
553
+ const stat = lstatSync(file);
554
+ if (stat.isSymbolicLink()) {
555
+ throw new Error('Refusing to write through a symbolic link: ' + file);
556
+ }
557
+ mode = stat.mode & 0o777;
558
+ }
559
+ catch (error) {
560
+ if (!isErrno(error, 'ENOENT')) {
561
+ throw error;
562
+ }
563
+ }
564
+ writeFileSync(temporary, source, { flag: 'wx', mode });
565
+ assertOwnedUpgradePath(anchor, file);
566
+ renameSync(temporary, file);
567
+ }
568
+ finally {
569
+ rmSync(temporary, { force: true });
570
+ }
571
+ }
572
+ function isErrno(error, code) {
573
+ return error !== null && typeof error === 'object' && 'code' in error && error.code === code;
574
+ }
476
575
  function inside(root, candidate) {
477
576
  const relative = path.relative(root, candidate);
478
577
  return relative === '' || (!relative.startsWith('..' + path.sep) && relative !== '..'
@@ -0,0 +1,12 @@
1
+ export interface LegalSiteFile {
2
+ readonly file: string;
3
+ readonly content: string;
4
+ }
5
+ /**
6
+ * Legal-site paths are contained only after the nearest existing ancestor is
7
+ * canonicalized. A lexical `path.relative` check treats a symlinked `artifacts`
8
+ * directory as inside the project while later writes follow the link.
9
+ */
10
+ export declare function assertLegalProjectPath(label: string, resolved: string): void;
11
+ /** Stage the site beside the destination, then publish with rename. */
12
+ export declare function publishLegalSiteFiles(outDir: string, outputs: readonly LegalSiteFile[]): void;
@@ -0,0 +1,226 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { lstatSync, mkdirSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ /**
5
+ * Legal-site paths are contained only after the nearest existing ancestor is
6
+ * canonicalized. A lexical `path.relative` check treats a symlinked `artifacts`
7
+ * directory as inside the project while later writes follow the link.
8
+ */
9
+ export function assertLegalProjectPath(label, resolved) {
10
+ const lexicalRoot = process.cwd();
11
+ const lexical = path.relative(lexicalRoot, resolved);
12
+ if (lexical.startsWith('..') || path.isAbsolute(lexical)) {
13
+ throw new Error(`${label} must be inside the project root: ${resolved}`);
14
+ }
15
+ assertNoLegalSymlink(lexicalRoot, resolved, label);
16
+ const canonicalRoot = realpathSync(lexicalRoot);
17
+ const canonical = realpathBestEffort(resolved);
18
+ const canonicalRelative = path.relative(canonicalRoot, canonical);
19
+ if (canonicalRelative.startsWith('..') || path.isAbsolute(canonicalRelative)) {
20
+ throw new Error(`${label} must be inside the project root: ${resolved}`);
21
+ }
22
+ }
23
+ /** Stage the site beside the destination, then publish with rename. */
24
+ export function publishLegalSiteFiles(outDir, outputs) {
25
+ assertLegalProjectPath('out-dir', outDir);
26
+ // A temp dir on the project root is a different filesystem when out-dir is a
27
+ // mount or sits under a bind-mounted artifacts directory. Rename then fails
28
+ // with EXDEV. Stage on the destination filesystem instead.
29
+ const staging = path.join(legalStagingParent(outDir), `.mpgd-legal-${randomUUID()}`);
30
+ let publishedDirectory = false;
31
+ try {
32
+ for (const output of outputs) {
33
+ const relative = path.relative(outDir, output.file);
34
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
35
+ throw new Error(`Legal site output escapes the output directory: ${output.file}`);
36
+ }
37
+ const staged = path.join(staging, relative);
38
+ mkdirSync(path.dirname(staged), { recursive: true });
39
+ writeFileSync(staged, output.content, { flag: 'wx' });
40
+ }
41
+ assertLegalProjectPath('out-dir', outDir);
42
+ let existing;
43
+ try {
44
+ existing = lstatSync(outDir);
45
+ }
46
+ catch (error) {
47
+ if (!isErrno(error, 'ENOENT')) {
48
+ throw error;
49
+ }
50
+ }
51
+ if (existing?.isSymbolicLink() === true) {
52
+ throw new Error(`Refusing to write legal site output through a symbolic link: ${outDir}`);
53
+ }
54
+ if (existing === undefined) {
55
+ ensureLegalDirectory(path.dirname(outDir));
56
+ assertLegalProjectPath('out-dir', outDir);
57
+ renameSync(staging, outDir);
58
+ publishedDirectory = true;
59
+ return;
60
+ }
61
+ if (!existing.isDirectory()) {
62
+ throw new Error(`Legal site output path is not a directory: ${outDir}`);
63
+ }
64
+ for (const output of outputs) {
65
+ assertNoLegalSymlink(process.cwd(), output.file, 'out-dir');
66
+ ensureLegalDirectory(path.dirname(output.file));
67
+ assertNoLegalSymlink(process.cwd(), output.file, 'out-dir');
68
+ renameSync(path.join(staging, path.relative(outDir, output.file)), output.file);
69
+ }
70
+ }
71
+ finally {
72
+ if (!publishedDirectory) {
73
+ rmSync(staging, { recursive: true, force: true });
74
+ }
75
+ }
76
+ }
77
+ /**
78
+ * Parent for the staging directory. An existing out-dir may itself be a mount,
79
+ * so stage inside it. A missing out-dir is created on its nearest existing
80
+ * ancestor, which is the filesystem the later rename must stay on.
81
+ */
82
+ function legalStagingParent(outDir) {
83
+ const existing = lstatIfPresent(outDir);
84
+ if (existing !== undefined) {
85
+ if (existing.isSymbolicLink()) {
86
+ throw new Error(`Refusing to write legal site output through a symbolic link: ${outDir}`);
87
+ }
88
+ if (!existing.isDirectory()) {
89
+ throw new Error(`Legal site output path is not a directory: ${outDir}`);
90
+ }
91
+ return outDir;
92
+ }
93
+ let current = path.dirname(outDir);
94
+ for (;;) {
95
+ const stat = lstatIfPresent(current);
96
+ if (stat !== undefined) {
97
+ if (stat.isSymbolicLink()) {
98
+ throw new Error(`Refusing to write legal site output through a symbolic link: ${current}`);
99
+ }
100
+ if (!stat.isDirectory()) {
101
+ throw new Error(`Legal site output path is not a directory: ${current}`);
102
+ }
103
+ return current;
104
+ }
105
+ const parent = path.dirname(current);
106
+ if (parent === current) {
107
+ throw new Error(`out-dir must be inside the project root: ${outDir}`);
108
+ }
109
+ current = parent;
110
+ }
111
+ }
112
+ function lstatIfPresent(target) {
113
+ try {
114
+ return lstatSync(target);
115
+ }
116
+ catch (error) {
117
+ if (isErrno(error, 'ENOENT')) {
118
+ return undefined;
119
+ }
120
+ throw error;
121
+ }
122
+ }
123
+ /** Resolve symlinks for the longest existing ancestor, keeping the remainder. */
124
+ function realpathBestEffort(target) {
125
+ // lstat-based existence stops the walk at dangling symlinks, so realpath
126
+ // fails closed on them instead of lexically skipping the link.
127
+ const exists = (candidate) => {
128
+ try {
129
+ lstatSync(candidate);
130
+ return true;
131
+ }
132
+ catch {
133
+ return false;
134
+ }
135
+ };
136
+ let existing = target;
137
+ while (!exists(existing)) {
138
+ const parent = path.dirname(existing);
139
+ if (parent === existing) {
140
+ return existing;
141
+ }
142
+ existing = parent;
143
+ }
144
+ let real;
145
+ try {
146
+ real = realpathSync(existing);
147
+ }
148
+ catch (error) {
149
+ throw new Error(`Legal site path resolves through a broken symbolic link: ${target}`, {
150
+ cause: error,
151
+ });
152
+ }
153
+ return existing === target ? real : path.resolve(real, path.relative(existing, target));
154
+ }
155
+ function assertNoLegalSymlink(root, target, label) {
156
+ const relative = path.relative(root, target);
157
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
158
+ throw new Error(`${label} must be inside the project root: ${target}`);
159
+ }
160
+ const verb = label === 'legal-dir' ? 'read legal sources' : 'write legal site output';
161
+ let current = root;
162
+ for (const part of relative.split(path.sep)) {
163
+ if (part === '' || part === '.') {
164
+ continue;
165
+ }
166
+ current = path.join(current, part);
167
+ let stat;
168
+ try {
169
+ stat = lstatSync(current);
170
+ }
171
+ catch (error) {
172
+ if (isErrno(error, 'ENOENT')) {
173
+ return;
174
+ }
175
+ throw error;
176
+ }
177
+ if (stat.isSymbolicLink()) {
178
+ throw new Error(`Refusing to ${verb} through a symbolic link: ${current}`);
179
+ }
180
+ }
181
+ }
182
+ function ensureLegalDirectory(directory) {
183
+ const root = process.cwd();
184
+ const relative = path.relative(root, directory);
185
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
186
+ throw new Error(`out-dir must be inside the project root: ${directory}`);
187
+ }
188
+ if (relative === '') {
189
+ return;
190
+ }
191
+ let current = root;
192
+ for (const part of relative.split(path.sep)) {
193
+ if (part === '' || part === '.') {
194
+ continue;
195
+ }
196
+ current = path.join(current, part);
197
+ let stat;
198
+ try {
199
+ stat = lstatSync(current);
200
+ }
201
+ catch (error) {
202
+ if (!isErrno(error, 'ENOENT')) {
203
+ throw error;
204
+ }
205
+ try {
206
+ mkdirSync(current);
207
+ continue;
208
+ }
209
+ catch (mkdirError) {
210
+ if (!isErrno(mkdirError, 'EEXIST')) {
211
+ throw mkdirError;
212
+ }
213
+ stat = lstatSync(current);
214
+ }
215
+ }
216
+ if (stat.isSymbolicLink()) {
217
+ throw new Error(`Refusing to write legal site output through a symbolic link: ${current}`);
218
+ }
219
+ if (!stat.isDirectory()) {
220
+ throw new Error(`Legal site output path is not a directory: ${current}`);
221
+ }
222
+ }
223
+ }
224
+ function isErrno(error, code) {
225
+ return error !== null && typeof error === 'object' && 'code' in error && error.code === code;
226
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
- "kitGitSha": "95ea69d3e9755256b27a3b0ea230812a632ed03b",
2
+ "kitGitSha": "ed8837541cc3c5737c21638a596ba2dc121112da",
3
3
  "kitDirty": false,
4
- "packageVersion": "0.54.0"
4
+ "packageVersion": "0.54.1"
5
5
  }
@@ -9502,9 +9502,21 @@ var assertReleaseManifestStructure = /* @__PURE__ */ (() => {
9502
9502
  };
9503
9503
  })();
9504
9504
  var fullGitShaPattern = /^[0-9a-f]{40}$/u;
9505
+ var sha256DigestPattern = /^[0-9a-f]{64}$/u;
9505
9506
  function assertReleaseManifest(input) {
9506
9507
  const manifest = assertReleaseManifestStructure(input);
9508
+ if (!fullGitShaPattern.test(manifest.gitSha)) {
9509
+ throw new TypeError("Release manifest gitSha must be a lowercase 40-character SHA.");
9510
+ }
9511
+ if (!fullGitShaPattern.test(manifest.kitGitSha)) {
9512
+ throw new TypeError("Release manifest kitGitSha must be a lowercase 40-character SHA.");
9513
+ }
9507
9514
  for (const [target2, entry] of Object.entries(manifest.targets)) {
9515
+ assertSha256Digest(entry.effectiveConfig.digest, `Release manifest ${target2} effectiveConfig.digest`);
9516
+ assertSha256Digest(entry.iconManifest.digest, `Release manifest ${target2} iconManifest.digest`);
9517
+ assertSha256Digest(entry.iconManifest.sourceSha256, `Release manifest ${target2} iconManifest.sourceSha256`);
9518
+ assertSha256Digest(entry.iconManifest.sharedConfigSha256, `Release manifest ${target2} iconManifest.sharedConfigSha256`);
9519
+ assertSha256Digest(entry.iconManifest.renderConfigSha256, `Release manifest ${target2} iconManifest.renderConfigSha256`);
9508
9520
  const delivery = entry.nativeDelivery;
9509
9521
  if (delivery === void 0) {
9510
9522
  continue;
@@ -9519,9 +9531,6 @@ function assertReleaseManifest(input) {
9519
9531
  throw new TypeError(`Release manifest production native delivery is unsigned: ${target2}.`);
9520
9532
  }
9521
9533
  }
9522
- if (!fullGitShaPattern.test(manifest.kitGitSha)) {
9523
- throw new TypeError("Release manifest kitGitSha must be a lowercase 40-character SHA.");
9524
- }
9525
9534
  if (manifest.releaseIdentity !== void 0) {
9526
9535
  const releaseIdentity = createMpgdReleaseIdentity({
9527
9536
  gameVersion: manifest.releaseIdentity.gameVersion,
@@ -9543,6 +9552,11 @@ function assertReleaseManifest(input) {
9543
9552
  }
9544
9553
  return manifest;
9545
9554
  }
9555
+ function assertSha256Digest(value, label) {
9556
+ if (!sha256DigestPattern.test(value)) {
9557
+ throw new TypeError(`${label} must be a lowercase 64-character SHA-256 digest.`);
9558
+ }
9559
+ }
9546
9560
 
9547
9561
  // node_modules/.cache/mpgd-native-build/tools/target/release-manifest-lock.js
9548
9562
  import { randomUUID } from "node:crypto";
@@ -9641,7 +9655,7 @@ function canRecoverLock(lockFile) {
9641
9655
  // node_modules/.cache/mpgd-native-build/tools/target/generate-release-manifest.js
9642
9656
  var __typia_transform__accessExpressionAsString6 = _accessExpressionAsString_110._accessExpressionAsString;
9643
9657
  var assertProductCatalog = /* @__PURE__ */ (() => {
9644
- const _ae0 = '({ readonly type: "currency"; readonly currency: string & MinLength<1>; readonly amount: number; } | { readonly type: "entitlement"; readonly entitlement: string; } | { readonly type: "resource"; readonly resource: string & MinLength<1>; readonly amount: number & ExclusiveMinimum<0> & Maximum<1.7976931348623157e+308>; })';
9658
+ const _ae0 = '({ readonly type: "currency"; readonly currency: string & MinLength<1>; readonly amount: PositiveLedgerAmount; } | { readonly type: "entitlement"; readonly entitlement: string; } | { readonly type: "resource"; readonly resource: string & MinLength<1>; readonly amount: number & ExclusiveMinimum<0> & Maximum<1.7976931348623157e+308>; })';
9645
9659
  const _ao0 = (input, _path, _exceptionable = true) => ("string" === typeof input.version || _assertGuard_19._assertGuard(_exceptionable, {
9646
9660
  method: "typia.createAssert",
9647
9661
  path: _path + ".version",
@@ -9739,10 +9753,25 @@ var assertProductCatalog = /* @__PURE__ */ (() => {
9739
9753
  path: _path + ".currency",
9740
9754
  expected: "(string & MinLength<1>)",
9741
9755
  value: input.currency
9742
- }, _errorFactory)) && ("number" === typeof input.amount || _assertGuard_19._assertGuard(_exceptionable, {
9756
+ }, _errorFactory)) && ("number" === typeof input.amount && (1 <= input.amount || _assertGuard_19._assertGuard(_exceptionable, {
9743
9757
  method: "typia.createAssert",
9744
9758
  path: _path + ".amount",
9745
- expected: "number",
9759
+ expected: "number & Minimum<1>",
9760
+ value: input.amount
9761
+ }, _errorFactory)) && (input.amount <= 9007199254740991 || _assertGuard_19._assertGuard(_exceptionable, {
9762
+ method: "typia.createAssert",
9763
+ path: _path + ".amount",
9764
+ expected: "number & Maximum<9007199254740991>",
9765
+ value: input.amount
9766
+ }, _errorFactory)) && (input.amount % 1 === 0 || _assertGuard_19._assertGuard(_exceptionable, {
9767
+ method: "typia.createAssert",
9768
+ path: _path + ".amount",
9769
+ expected: "number & MultipleOf<1>",
9770
+ value: input.amount
9771
+ }, _errorFactory)) || _assertGuard_19._assertGuard(_exceptionable, {
9772
+ method: "typia.createAssert",
9773
+ path: _path + ".amount",
9774
+ expected: "(number & Minimum<1> & Maximum<9007199254740991> & MultipleOf<1>)",
9746
9775
  value: input.amount
9747
9776
  }, _errorFactory));
9748
9777
  const _ao3 = (input, _path, _exceptionable = true) => ("entitlement" === input.type || _assertGuard_19._assertGuard(_exceptionable, {
@@ -9815,7 +9844,7 @@ var assertProductCatalog = /* @__PURE__ */ (() => {
9815
9844
  })();
9816
9845
  const _io0 = (input) => "string" === typeof input.version && (void 0 === input.currencies || Array.isArray(input.currencies) && (1 <= input.currencies.length && input.currencies.every((elem) => "string" === typeof elem && 1 <= elem.length))) && (Array.isArray(input.products) && input.products.every((elem) => "object" === typeof elem && null !== elem && _io1(elem)));
9817
9846
  const _io1 = (input) => "string" === typeof input.id && ("consumable" === input.type || "non_consumable" === input.type || "subscription" === input.type) && ("object" === typeof input.grant && null !== input.grant && _iu0(input.grant)) && ("object" === typeof input.platformProductIds && null !== input.platformProductIds && false === Array.isArray(input.platformProductIds) && _io5(input.platformProductIds));
9818
- const _io2 = (input) => "currency" === input.type && ("string" === typeof input.currency && 1 <= input.currency.length) && "number" === typeof input.amount;
9847
+ const _io2 = (input) => "currency" === input.type && ("string" === typeof input.currency && 1 <= input.currency.length) && ("number" === typeof input.amount && (1 <= input.amount && input.amount <= 9007199254740991 && input.amount % 1 === 0));
9819
9848
  const _io3 = (input) => "entitlement" === input.type && "string" === typeof input.entitlement;
9820
9849
  const _io4 = (input) => "resource" === input.type && ("string" === typeof input.resource && 1 <= input.resource.length) && ("number" === typeof input.amount && (0 < input.amount && input.amount <= 17976931348623157e292));
9821
9850
  const _io5 = (input) => Object.keys(input).every((key) => {
@@ -9855,16 +9884,31 @@ var assertProductCatalog = /* @__PURE__ */ (() => {
9855
9884
  };
9856
9885
  })();
9857
9886
  var assertAdPlacements = /* @__PURE__ */ (() => {
9858
- const _ip0 = (input) => "number" === typeof input["amount"];
9887
+ const _ip0 = (input) => "number" === typeof input["amount"] && (1 <= input["amount"] && input["amount"] <= 9007199254740991 && input["amount"] % 1 === 0);
9859
9888
  const _ae0 = '(AdPlacementEntryBase & { readonly type: "banner" | "interstitial"; readonly reward?: never; } | AdPlacementEntryBase & { readonly type: "rewarded"; readonly reward?: AdReward; })';
9860
- const _ae1 = '(undefined | { readonly type: "continue"; readonly amount: number; } | { readonly type: "currency"; readonly amount: number; readonly currency: string & MinLength<1>; })';
9861
- const _ap0 = (input, _path, _exceptionable = true) => "number" === typeof input["amount"] || _assertGuard_19._assertGuard(_exceptionable, {
9889
+ const _ae1 = '(undefined | { readonly type: "continue"; readonly amount: PositiveLedgerAmount; } | { readonly type: "currency"; readonly amount: PositiveLedgerAmount; readonly currency: string & MinLength<1>; })';
9890
+ const _ap0 = (input, _path, _exceptionable = true) => "number" === typeof input["amount"] && (1 <= input["amount"] || _assertGuard_19._assertGuard(_exceptionable, {
9862
9891
  method: "typia.createAssert",
9863
9892
  path: _path + ".amount",
9864
- expected: "number",
9893
+ expected: "number & Minimum<1>",
9894
+ value: input["amount"]
9895
+ }, _errorFactory)) && (input["amount"] <= 9007199254740991 || _assertGuard_19._assertGuard(_exceptionable, {
9896
+ method: "typia.createAssert",
9897
+ path: _path + ".amount",
9898
+ expected: "number & Maximum<9007199254740991>",
9899
+ value: input["amount"]
9900
+ }, _errorFactory)) && (input["amount"] % 1 === 0 || _assertGuard_19._assertGuard(_exceptionable, {
9901
+ method: "typia.createAssert",
9902
+ path: _path + ".amount",
9903
+ expected: "number & MultipleOf<1>",
9904
+ value: input["amount"]
9905
+ }, _errorFactory)) || _assertGuard_19._assertGuard(_exceptionable, {
9906
+ method: "typia.createAssert",
9907
+ path: _path + ".amount",
9908
+ expected: "(number & Minimum<1> & Maximum<9007199254740991> & MultipleOf<1>)",
9865
9909
  value: input["amount"]
9866
9910
  }, _errorFactory);
9867
- const _ae2 = '({ readonly type: "continue"; readonly amount: number; } | { readonly type: "currency"; readonly amount: number; readonly currency: string & MinLength<1>; })';
9911
+ const _ae2 = '({ readonly type: "continue"; readonly amount: PositiveLedgerAmount; } | { readonly type: "currency"; readonly amount: PositiveLedgerAmount; readonly currency: string & MinLength<1>; })';
9868
9912
  const _ao0 = (input, _path, _exceptionable = true) => ("string" === typeof input.version || _assertGuard_19._assertGuard(_exceptionable, {
9869
9913
  method: "typia.createAssert",
9870
9914
  path: _path + ".version",
@@ -10373,9 +10417,47 @@ function readSdkMajor(envValue, metadataValue) {
10373
10417
  function getSourceGitSha(kitGitSha) {
10374
10418
  const configuredGitSha = readOptionalString2(process.env.MPGD_SOURCE_GIT_SHA);
10375
10419
  if (configuredGitSha !== void 0) {
10376
- return configuredGitSha;
10420
+ return requireFullGitSha(configuredGitSha, "MPGD_SOURCE_GIT_SHA");
10377
10421
  }
10378
- return kitGitSha;
10422
+ if (isInRepoKitBuild()) {
10423
+ return inRepoKitSourceGitSha(kitGitSha);
10424
+ }
10425
+ throw new Error("MPGD_SOURCE_GIT_SHA is required for a downstream game build and must be a lowercase 40-character SHA. Refusing to record the kit revision as the game revision.");
10426
+ }
10427
+ function inRepoKitSourceGitSha(kitGitSha) {
10428
+ return requireFullGitSha(kitGitSha, "in-repo kit source Git SHA");
10429
+ }
10430
+ function isInRepoKitBuild() {
10431
+ if (process.env.MPGD_NATIVE_PACKAGED_BUILD === "1") {
10432
+ return false;
10433
+ }
10434
+ const targetsFile = readOptionalString2(process.env.MPGD_PLATFORM_TARGETS_FILE);
10435
+ if (targetsFile === void 0) {
10436
+ return true;
10437
+ }
10438
+ return isInsideDirectory(resolveKitRoot(), targetsFile);
10439
+ }
10440
+ function isInsideDirectory(root, candidate) {
10441
+ let resolvedRoot;
10442
+ try {
10443
+ resolvedRoot = realpathSync6(root);
10444
+ } catch {
10445
+ return false;
10446
+ }
10447
+ const resolvedCandidate = resolve12(candidate);
10448
+ let comparable = resolvedCandidate;
10449
+ try {
10450
+ comparable = realpathSync6(resolvedCandidate);
10451
+ } catch {
10452
+ }
10453
+ const rel = relative9(resolvedRoot, comparable);
10454
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute8(rel);
10455
+ }
10456
+ function requireFullGitSha(value, label) {
10457
+ if (!/^[0-9a-f]{40}$/u.test(value)) {
10458
+ throw new Error(`${label} must be a lowercase 40-character SHA.`);
10459
+ }
10460
+ return value;
10379
10461
  }
10380
10462
  function resolveKitGitSha() {
10381
10463
  if (process.env.MPGD_NATIVE_PACKAGED_BUILD === "1") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/cli",
3
- "version": "0.54.0",
3
+ "version": "0.54.1",
4
4
  "description": "Gunshi CLI for mpgd-kit starter generation and target workflow orchestration.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -70,10 +70,10 @@
70
70
  "parse5": "7.3.0",
71
71
  "postcss": "8.5.28",
72
72
  "postcss-value-parser": "4.2.0",
73
- "@mpgd/catalog": "0.10.5",
74
- "@mpgd/platform": "0.27.0",
73
+ "@mpgd/catalog": "0.10.6",
74
+ "@mpgd/platform": "0.27.1",
75
75
  "@mpgd/phaser-assets": "0.9.1",
76
- "@mpgd/target-config": "0.23.5"
76
+ "@mpgd/target-config": "0.23.6"
77
77
  },
78
78
  "devDependencies": {
79
79
  "@types/node": "^24.0.0",
@@ -81,18 +81,18 @@
81
81
  "@types/yauzl": "3.4.0",
82
82
  "ttsc": "0.32.1",
83
83
  "typescript": "7.0.2",
84
- "@mpgd/adapter-ait": "0.20.1",
85
- "@mpgd/adapter-browser": "0.11.5",
86
- "@mpgd/adapter-devvit": "0.13.3",
87
- "@mpgd/adapter-verse8": "0.7.5",
88
- "@mpgd/bridge": "0.14.2",
89
- "@mpgd/adapter-capacitor": "0.12.1",
90
- "@mpgd/analytics": "0.4.14",
91
- "@mpgd/capacitor-play-billing": "0.3.5",
92
- "@mpgd/capacitor-storekit": "0.3.5",
93
- "@mpgd/game-services": "0.27.0",
94
- "@mpgd/i18n": "0.6.19",
95
- "@mpgd/game-runtime": "0.7.5",
84
+ "@mpgd/adapter-browser": "0.11.6",
85
+ "@mpgd/adapter-capacitor": "0.12.2",
86
+ "@mpgd/adapter-ait": "0.20.2",
87
+ "@mpgd/adapter-verse8": "0.7.6",
88
+ "@mpgd/bridge": "0.14.3",
89
+ "@mpgd/analytics": "0.4.15",
90
+ "@mpgd/adapter-devvit": "0.13.4",
91
+ "@mpgd/capacitor-play-billing": "0.3.6",
92
+ "@mpgd/capacitor-storekit": "0.3.6",
93
+ "@mpgd/game-runtime": "0.7.6",
94
+ "@mpgd/game-services": "0.27.1",
95
+ "@mpgd/i18n": "0.6.20",
96
96
  "@mpgd/lite-shell": "0.1.0"
97
97
  },
98
98
  "main": "./dist/index.js",