@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.
- package/dist/capacitor-shell-starter.js +9 -1
- package/dist/deploy-planning.js +2 -2
- package/dist/index.js +148 -52
- package/dist/kit-upgrade.js +105 -6
- package/dist/legal-site-output.d.ts +12 -0
- package/dist/legal-site-output.js +226 -0
- package/dist/native-build-info.json +2 -2
- package/dist/native-build-target.js +166 -14
- package/dist/notifications-starter.d.ts +6 -0
- package/dist/notifications-starter.js +9 -0
- package/dist/offline-playtest.js +1 -1
- package/dist/tooling-migration.js +1 -1
- package/package.json +17 -17
- package/templates/notifications-overlay/common/docs/NOTIFICATIONS.md +36 -7
- package/templates/notifications-overlay/common/src/platform/notificationBackend.ts +157 -0
- package/templates/notifications-overlay/common/src/platform/notifications.ts +67 -35
- package/templates/notifications-overlay/phaser/src/scenes/NotificationSettingsScene.ts +26 -7
- package/templates/phaser-game/src/platform/gameServices.ts +3 -0
- package/templates/phaser-game/src/platform/serverSession.ts +66 -0
|
@@ -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.');
|
package/dist/deploy-planning.js
CHANGED
|
@@ -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, {
|
|
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
|
-
|
|
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
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
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
|
|
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
|
-
|
|
3191
|
-
|
|
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
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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));
|
package/dist/kit-upgrade.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|