@mpgd/cli 0.53.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.
@@ -5,7 +5,7 @@ import { spawnSync } from 'node:child_process';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { DOMParser } from '@xmldom/xmldom';
7
7
  import { satisfies, valid, validRange } from 'semver';
8
- import { capacitorGatewayRegistersNotifications, conflictingPushPlugin, notificationsCapacitorGatewayModules, notificationsCapacitorGateways, notificationsCapacitorSupportFiles, notificationsGameDependencies, notificationsMessagingFirebaseRange, notificationsMessagingPlugin, notificationsProviderId, notificationsShellPlugins, wireCapacitorGatewayNotifications, } from './notifications-starter.js';
8
+ import { capacitorGatewayRegistersNotifications, conflictingPushPlugin, notificationsCapacitorGatewayModules, notificationsCapacitorGateways, notificationsCapacitorSupportFiles, notificationsCapacitorTemplateSupportFiles, notificationsGameDependencies, notificationsMessagingFirebaseRange, notificationsMessagingPlugin, notificationsProviderId, notificationsShellPlugins, wireCapacitorGatewayNotifications, } from './notifications-starter.js';
9
9
  import { isNonPublicServiceHostname } from './production-target-readiness.js';
10
10
  import { assertAndroidSettingsAppProject, assertAndroidSettingsNoAppRemap, assertIosReleaseInfoPlistExpansion, assertIosReleasePackagingSettings, assertIosReleaseProductName, assertNativeShellIdentity, hasAndroidDisplayNameResourceOverride, hasAndroidManifestSourceSetOverride, hasAndroidResourceSourceSetOverride, hasGradleIdentityMutation, hasGradleTaskAction, maskGradleStrings, readIosAppTargetId, readIosReleaseInfoPlist, stripGradleComments, } from './native-shell-identity.js';
11
11
  const shellPath = 'apps/mobile-capacitor';
@@ -2793,6 +2793,14 @@ function planNotificationsProvider(input) {
2793
2793
  files.push({ path: file, content: starterFile(overlayRoot, file) });
2794
2794
  }
2795
2795
  }
2796
+ // Base-template modules they import, missing from a game an earlier release created.
2797
+ if (input.templateRoot !== undefined) {
2798
+ for (const file of notificationsCapacitorTemplateSupportFiles) {
2799
+ if (readExisting(safeDestination(gameRoot, file)) === undefined) {
2800
+ files.push({ path: file, content: starterFile(input.templateRoot, file) });
2801
+ }
2802
+ }
2803
+ }
2796
2804
  warnings.push('The notification settings screen is not added to an existing game; render one from '
2797
2805
  + 'src/platform/notificationSettings.ts (see docs/NOTIFICATIONS.md) so players can turn '
2798
2806
  + 'reminders and advertising consent on and off.');
@@ -356,8 +356,8 @@ function assertGameOwnedNativePaths(gameRoot, target, config) {
356
356
  throw new Error(`${target} ${field} still points to a Kit checkout; initialize a game-owned shell.`);
357
357
  }
358
358
  const expanded = value
359
- .replaceAll('${MPGD_GAME_ROOT}', root)
360
- .replaceAll('${MPGD_GAME_APP_ROOT}', root);
359
+ .replaceAll('${MPGD_GAME_ROOT}', () => root)
360
+ .replaceAll('${MPGD_GAME_APP_ROOT}', () => root);
361
361
  const candidate = resolve(root, expanded);
362
362
  let ancestor = candidate;
363
363
  while (!existsSync(ancestor)) {
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),
@@ -2976,7 +2979,14 @@ function createGameApp(input) {
2976
2979
  relativePath: file.relativePath,
2977
2980
  content: file.content,
2978
2981
  });
2979
- return prepared === undefined ? [] : [{ ...file, content: renderTemplate(prepared, context) }];
2982
+ return prepared === undefined
2983
+ ? []
2984
+ : [
2985
+ {
2986
+ ...file,
2987
+ content: renderTemplate(prepared, context, file.relativePath),
2988
+ },
2989
+ ];
2980
2990
  });
2981
2991
  if (input.dryRun) {
2982
2992
  console.info(`Would create ${path.relative(process.cwd(), appDir) || appDir}:`);
@@ -3187,8 +3197,10 @@ function assertValidGameTitle(title) {
3187
3197
  if (title.trim().length === 0) {
3188
3198
  throw new Error('Game title cannot be empty.');
3189
3199
  }
3190
- if (/[\u0000-\u001f<>"'\\]/u.test(title)) {
3191
- throw new Error('Game title cannot contain control characters, quotes, backslashes, or angle brackets.');
3200
+ // Quotes, backslashes, and `$` are allowed: JSON files and JS string literals
3201
+ // escape them. `<` would break the generated HTML title.
3202
+ if (/[\u0000-\u001f<>]/u.test(title)) {
3203
+ throw new Error('Game title cannot contain control characters or angle brackets.');
3192
3204
  }
3193
3205
  }
3194
3206
  function collectTemplateFiles(templateDir) {
@@ -3210,28 +3222,79 @@ function walkTemplateDir(templateDir, relativeDir, files) {
3210
3222
  });
3211
3223
  }
3212
3224
  }
3213
- function renderTemplate(content, context) {
3214
- let rendered = content
3215
- .replaceAll("from '@mpgd/cli/devvit'", `from ${context.devvitOutputPluginImport}`)
3216
- .replaceAll('__GAME_NAME__', context.gameName)
3217
- .replaceAll('__DEVVIT_APP_NAME__', context.devvitAppName)
3218
- .replaceAll('__GAME_TITLE_TS_LITERAL__', context.gameTitleTsLiteral)
3219
- .replaceAll('__GAME_TITLE__', context.gameTitle)
3220
- .replaceAll('__LEGAL_LAST_UPDATED__', context.legalLastUpdated)
3221
- .replaceAll('__PACKAGE_NAME__', context.packageName)
3222
- .replaceAll('__MPGD_DEPENDENCY_VERSION__', context.defaultDependencyVersion)
3223
- .replaceAll('__TSCONFIG_EXTENDS_LINE__', context.tsconfigExtendsLine)
3224
- .replaceAll('__TSCONFIG_ROOT_DIR__', context.tsconfigRootDir)
3225
- .replaceAll('__TSCONFIG_WORKSPACE_INCLUDES__', context.tsconfigWorkspaceIncludes)
3226
- .replaceAll('__TSCONFIG_WORKSPACE_EXCLUDES__', context.tsconfigWorkspaceExcludes)
3227
- .replaceAll('__WORKSPACE_I18N_BUILD_PREFIX__', context.workspaceI18nBuildPrefix)
3228
- .replaceAll('__DEFAULT_KIT_PATH__', context.defaultKitPath)
3229
- .replaceAll('__RECOMMENDED_MATRIX_TARGETS__', context.recommendedMatrixTargets)
3230
- .replaceAll('__PNPM_WORKSPACE_KIT_PACKAGES__', context.pnpmWorkspaceKitPackages)
3231
- .replaceAll('__PASCAL_NAME__', context.pascalName)
3232
- .replaceAll('__CAMEL_NAME__', context.camelName);
3225
+ /**
3226
+ * String replacements treat `$&`, `$'`, `` $` ``, and `$$` as patterns.
3227
+ * A function replacer inserts `value` literally.
3228
+ */
3229
+ function replaceLiteral(content, placeholder, value) {
3230
+ return content.replaceAll(placeholder, () => value);
3231
+ }
3232
+ /** Interior of a JSON string, without the surrounding quotes. */
3233
+ function jsonStringContents(value) {
3234
+ return JSON.stringify(value).slice(1, -1);
3235
+ }
3236
+ /**
3237
+ * Placeholders substituted inside an existing JSON string. Structural
3238
+ * fragments (`__TSCONFIG_EXTENDS_LINE__` and the workspace include/exclude
3239
+ * lists) stay raw so their quotes remain JSON syntax.
3240
+ */
3241
+ function jsonEscapedPlaceholder(placeholder) {
3242
+ return placeholder === '__GAME_TITLE__'
3243
+ || placeholder === '__GAME_NAME__'
3244
+ || placeholder === '__PACKAGE_NAME__'
3245
+ || placeholder === '__DEVVIT_APP_NAME__'
3246
+ || placeholder === '__DEFAULT_KIT_PATH__'
3247
+ || placeholder === '__WORKSPACE_I18N_BUILD_PREFIX__'
3248
+ || placeholder === '__TSCONFIG_ROOT_DIR__'
3249
+ || placeholder === '__LEGAL_LAST_UPDATED__'
3250
+ || placeholder === '__MPGD_DEPENDENCY_VERSION__'
3251
+ || placeholder.startsWith('__MPGD_DEPENDENCY_VERSION_');
3252
+ }
3253
+ /** Interior of a single-quoted JS string. Backslash first, then quote. */
3254
+ function singleQuotedContents(value) {
3255
+ return value.replaceAll('\\', () => '\\\\').replaceAll("'", () => "\\'");
3256
+ }
3257
+ function renderTemplate(content, context, relativePath) {
3258
+ // `.webmanifest` is JSON. Hosted PWA deploy and Microsoft Store submission parse it.
3259
+ const jsonFile = relativePath.endsWith('.json') || relativePath.endsWith('.webmanifest');
3260
+ const typescriptFile = relativePath.endsWith('.ts');
3261
+ const insert = (placeholder, value) => {
3262
+ if (jsonFile && jsonEscapedPlaceholder(placeholder)) {
3263
+ return jsonStringContents(value);
3264
+ }
3265
+ // Template titles sit in single quotes (`title: '__GAME_TITLE__'`).
3266
+ if (typescriptFile && placeholder === '__GAME_TITLE__') {
3267
+ return singleQuotedContents(value);
3268
+ }
3269
+ return value;
3270
+ };
3271
+ const pairs = [
3272
+ [`from '@mpgd/cli/devvit'`, `from ${context.devvitOutputPluginImport}`],
3273
+ ['__GAME_NAME__', context.gameName],
3274
+ ['__DEVVIT_APP_NAME__', context.devvitAppName],
3275
+ // Longer than `__GAME_TITLE__`, so a title is not written into the literal name.
3276
+ ['__GAME_TITLE_TS_LITERAL__', context.gameTitleTsLiteral],
3277
+ ['__GAME_TITLE__', context.gameTitle],
3278
+ ['__LEGAL_LAST_UPDATED__', context.legalLastUpdated],
3279
+ ['__PACKAGE_NAME__', context.packageName],
3280
+ ['__MPGD_DEPENDENCY_VERSION__', context.defaultDependencyVersion],
3281
+ ['__TSCONFIG_EXTENDS_LINE__', context.tsconfigExtendsLine],
3282
+ ['__TSCONFIG_ROOT_DIR__', context.tsconfigRootDir],
3283
+ ['__TSCONFIG_WORKSPACE_INCLUDES__', context.tsconfigWorkspaceIncludes],
3284
+ ['__TSCONFIG_WORKSPACE_EXCLUDES__', context.tsconfigWorkspaceExcludes],
3285
+ ['__WORKSPACE_I18N_BUILD_PREFIX__', context.workspaceI18nBuildPrefix],
3286
+ ['__DEFAULT_KIT_PATH__', context.defaultKitPath],
3287
+ ['__RECOMMENDED_MATRIX_TARGETS__', context.recommendedMatrixTargets],
3288
+ ['__PNPM_WORKSPACE_KIT_PACKAGES__', context.pnpmWorkspaceKitPackages],
3289
+ ['__PASCAL_NAME__', context.pascalName],
3290
+ ['__CAMEL_NAME__', context.camelName],
3291
+ ];
3292
+ let rendered = content;
3293
+ for (const [placeholder, value] of pairs) {
3294
+ rendered = replaceLiteral(rendered, placeholder, insert(placeholder, value));
3295
+ }
3233
3296
  for (const replacement of context.mpgdDependencyVersionReplacements) {
3234
- rendered = rendered.replaceAll(replacement.placeholder, replacement.version);
3297
+ rendered = replaceLiteral(rendered, replacement.placeholder, insert(replacement.placeholder, replacement.version));
3235
3298
  }
3236
3299
  return rendered;
3237
3300
  }
@@ -3287,7 +3350,11 @@ function createTargetCommandEnv(values, options = {}) {
3287
3350
  });
3288
3351
  const env = {
3289
3352
  ...process.env,
3290
- ...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
+ }),
3291
3358
  MPGD_PLATFORM_TARGETS_FILE: preparedTargetsFile,
3292
3359
  ...(targetConfigExtensionsFile === undefined
3293
3360
  ? {}
@@ -3336,8 +3403,8 @@ function resolveStandaloneNativeBuild(targetsFile, requestedTarget, values) {
3336
3403
  return undefined;
3337
3404
  }
3338
3405
  const candidate = path.resolve(gameRoot, configuredPath
3339
- .replaceAll('${MPGD_GAME_ROOT}', gameRoot)
3340
- .replaceAll('${MPGD_GAME_APP_ROOT}', gameRoot));
3406
+ .replaceAll('${MPGD_GAME_ROOT}', () => gameRoot)
3407
+ .replaceAll('${MPGD_GAME_APP_ROOT}', () => gameRoot));
3341
3408
  let existingAncestor = candidate;
3342
3409
  while (!existsSync(existingAncestor)) {
3343
3410
  const parent = path.dirname(existingAncestor);
@@ -3390,10 +3457,9 @@ function resolveTargetConfigExtensionsFile(gameRoot, configuredFile) {
3390
3457
  const gameOwnedFile = path.join(gameRoot, 'mpgd.target-config.json');
3391
3458
  return existsSync(gameOwnedFile) ? gameOwnedFile : undefined;
3392
3459
  }
3393
- function createGameOwnedReleaseEnv(gameRoot, baseEnv) {
3460
+ function createGameOwnedReleaseEnv(gameRoot, baseEnv, options) {
3394
3461
  const monetizationFiles = resolveGameOwnedMonetizationFiles(gameRoot, baseEnv);
3395
- const sourceGitSha = readOptionalString(baseEnv.MPGD_SOURCE_GIT_SHA)
3396
- ?? readSourceGitSha(gameRoot);
3462
+ const sourceGitSha = resolveCommandSourceGitSha(gameRoot, baseEnv, options.requireSourceRevision);
3397
3463
  const appVersion = readOptionalString(baseEnv.APP_VERSION)
3398
3464
  ?? readGamePackageVersion(gameRoot);
3399
3465
  const notificationTopicsFile = readConfiguredPath(baseEnv.MPGD_NOTIFICATION_TOPICS_FILE);
@@ -3404,7 +3470,7 @@ function createGameOwnedReleaseEnv(gameRoot, baseEnv) {
3404
3470
  ...(notificationTopicsFile === undefined
3405
3471
  ? {}
3406
3472
  : { MPGD_NOTIFICATION_TOPICS_FILE: path.resolve(gameRoot, notificationTopicsFile) }),
3407
- MPGD_SOURCE_GIT_SHA: sourceGitSha,
3473
+ ...(sourceGitSha === undefined ? {} : { MPGD_SOURCE_GIT_SHA: sourceGitSha }),
3408
3474
  ...(appVersion === undefined ? {} : { APP_VERSION: appVersion }),
3409
3475
  };
3410
3476
  }
@@ -3439,15 +3505,48 @@ function readConfiguredPath(value) {
3439
3505
  const normalized = value?.trim();
3440
3506
  return normalized === undefined || normalized.length === 0 ? undefined : normalized;
3441
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
+ }
3442
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) {
3443
3532
  const result = spawnSync('git', ['-C', gameRoot, 'rev-parse', 'HEAD'], {
3444
3533
  encoding: 'utf8',
3445
3534
  stdio: ['ignore', 'pipe', 'ignore'],
3446
3535
  });
3447
3536
  if (result.error !== undefined || result.status !== 0) {
3448
- return 'uncommitted';
3537
+ return undefined;
3538
+ }
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.');
3449
3545
  }
3450
- return result.stdout.trim() || 'uncommitted';
3546
+ return value;
3547
+ }
3548
+ function isSourceGitSha(value) {
3549
+ return /^[0-9a-f]{40}$/u.test(value);
3451
3550
  }
3452
3551
  function readGamePackageVersion(gameRoot) {
3453
3552
  const packageFile = path.join(gameRoot, 'package.json');
@@ -3495,10 +3594,7 @@ function formatError(error) {
3495
3594
  }
3496
3595
  function replaceTargetTokens(value, context) {
3497
3596
  if (typeof value === 'string') {
3498
- return value
3499
- .replaceAll('${MPGD_KIT_PATH}', context.kitPath)
3500
- .replaceAll('${MPGD_GAME_ROOT}', context.gameRoot)
3501
- .replaceAll('${MPGD_GAME_APP_ROOT}', context.gameRoot);
3597
+ return replaceLiteral(replaceLiteral(replaceLiteral(value, '${MPGD_KIT_PATH}', context.kitPath), '${MPGD_GAME_ROOT}', context.gameRoot), '${MPGD_GAME_APP_ROOT}', context.gameRoot);
3502
3598
  }
3503
3599
  if (Array.isArray(value)) {
3504
3600
  return value.map((entry) => replaceTargetTokens(entry, context));
@@ -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));
@@ -278,8 +283,8 @@ function discoverGameManifests(gameRoot, notes, blockers, targetsFileName) {
278
283
  continue;
279
284
  }
280
285
  const resolvedLocation = location
281
- .replaceAll('${MPGD_GAME_ROOT}', targetsDirectory)
282
- .replaceAll('${MPGD_GAME_APP_ROOT}', targetsDirectory);
286
+ .replaceAll('${MPGD_GAME_ROOT}', () => targetsDirectory)
287
+ .replaceAll('${MPGD_GAME_APP_ROOT}', () => targetsDirectory);
283
288
  if (resolvedLocation.includes('$' + '{')) {
284
289
  notes.push('Skipped target with unresolved path token: ' + location);
285
290
  continue;
@@ -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;