@mpgd/cli 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/game-acceptance.d.ts +65 -0
- package/dist/game-acceptance.js +245 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +404 -3
- package/dist/production-target-readiness.d.ts +8 -0
- package/dist/production-target-readiness.js +207 -0
- package/package.json +11 -11
- package/templates/phaser-game/README.md +71 -1
- package/templates/phaser-game/agent/acceptance.md +12 -6
- package/templates/phaser-game/agent/brief.md +3 -1
- package/templates/phaser-game/agent/game-manifest.json +7 -0
- package/templates/phaser-game/apps/target-devvit/README.md +30 -0
- package/templates/phaser-game/apps/target-devvit/devvit.json +7 -0
- package/templates/phaser-game/apps/target-devvit/package.json +8 -5
- package/templates/phaser-game/apps/target-devvit/src/server/postOperationStore.ts +9 -0
- package/templates/phaser-game/game.html +13 -0
- package/templates/phaser-game/gitignore +2 -0
- package/templates/phaser-game/index.html +2 -2
- package/templates/phaser-game/mpgd.game.json +8 -0
- package/templates/phaser-game/mpgd.targets.json +6 -0
- package/templates/phaser-game/package.json +7 -1
- package/templates/phaser-game/public/manifest.webmanifest +1 -0
- package/templates/phaser-game/src/entry.ts +7 -0
- package/templates/phaser-game/src/env.d.ts +3 -0
- package/templates/phaser-game/src/gameEntry.ts +3 -0
- package/templates/phaser-game/src/main.ts +11 -2
- package/templates/phaser-game/src/platform/buildGatewayModule.ts +5 -0
- package/templates/phaser-game/src/platform/buildGateways/ait.ts +11 -0
- package/templates/phaser-game/src/platform/buildGateways/aitSandbox.ts +12 -0
- package/templates/phaser-game/src/platform/buildGateways/browser.ts +8 -0
- package/templates/phaser-game/src/platform/buildGateways/capacitorAndroid.ts +12 -0
- package/templates/phaser-game/src/platform/buildGateways/capacitorIos.ts +12 -0
- package/templates/phaser-game/src/platform/buildGateways/reddit.ts +11 -0
- package/templates/phaser-game/src/platform/buildGateways/redditSandbox.ts +15 -0
- package/templates/phaser-game/src/platform/devvitEntrypoint.ts +57 -0
- package/templates/phaser-game/src/platform/devvitInlinePreview.css +75 -0
- package/templates/phaser-game/src/platform/gameServices.ts +18 -61
- package/templates/phaser-game/src/platform/installPlatform.ts +6 -50
- package/templates/phaser-game/src/platform/microsoftStorePwa.ts +99 -0
- package/templates/phaser-game/tools/run-game-acceptance.mjs +22 -0
- package/templates/phaser-game/vite.config.ts +106 -4
package/dist/index.js
CHANGED
|
@@ -6,6 +6,9 @@ import completion from '@gunshi/plugin-completion';
|
|
|
6
6
|
import i18n, { defineI18n } from '@gunshi/plugin-i18n';
|
|
7
7
|
import resources from '@gunshi/resources';
|
|
8
8
|
import { cli } from 'gunshi';
|
|
9
|
+
import { defaultGameAcceptanceCommandTimeoutMs, resolveGameAcceptanceReleaseManifestFile, runGameAcceptance, } from './game-acceptance.js';
|
|
10
|
+
export { renderGameAcceptanceMarkdown, resolveGameAcceptanceReleaseManifestFile, runGameAcceptance, defaultGameAcceptanceCommandTimeoutMs, } from './game-acceptance.js';
|
|
11
|
+
export { assertProductionTargetReadiness, } from './production-target-readiness.js';
|
|
9
12
|
const sourceDir = path.dirname(fileURLToPath(import.meta.url));
|
|
10
13
|
const packageRoot = resolvePackageRoot(sourceDir);
|
|
11
14
|
const detectedKitRoot = resolveDefaultKitRoot();
|
|
@@ -50,6 +53,15 @@ const supportedBuildTargets = [
|
|
|
50
53
|
'reddit',
|
|
51
54
|
];
|
|
52
55
|
const supportedVariants = ['wrapper', 'sync', 'simulator', 'archive'];
|
|
56
|
+
const acceptanceStepIds = {
|
|
57
|
+
check: 'check',
|
|
58
|
+
test: 'test',
|
|
59
|
+
build: 'build',
|
|
60
|
+
graph: 'graph-preflight',
|
|
61
|
+
playtest: 'playtest',
|
|
62
|
+
targetBuild: 'target-build',
|
|
63
|
+
targetSmoke: 'target-smoke',
|
|
64
|
+
};
|
|
53
65
|
export async function runMpgdCli(args) {
|
|
54
66
|
await cli([...args], entryCommand, {
|
|
55
67
|
name: 'mpgd',
|
|
@@ -213,15 +225,402 @@ const gameCommand = defineI18n({
|
|
|
213
225
|
});
|
|
214
226
|
},
|
|
215
227
|
}),
|
|
228
|
+
accept: defineI18n({
|
|
229
|
+
name: 'accept',
|
|
230
|
+
description: 'Run the reusable game handoff acceptance workflow.',
|
|
231
|
+
resource: commandResource({
|
|
232
|
+
en: 'Run check, test, build, graph, playtest, and target acceptance with handoff reports.',
|
|
233
|
+
ko: 'check, test, build, graph, playtest, 타깃 인수 검증과 인계 리포트를 실행합니다.',
|
|
234
|
+
}, {
|
|
235
|
+
game: {
|
|
236
|
+
en: 'Game root containing package.json and mpgd.targets.json.',
|
|
237
|
+
ko: 'package.json과 mpgd.targets.json이 있는 게임 루트.',
|
|
238
|
+
},
|
|
239
|
+
targets: {
|
|
240
|
+
en: 'Comma-separated target list, default, or all.',
|
|
241
|
+
ko: '쉼표로 구분한 타깃 목록, default 또는 all.',
|
|
242
|
+
},
|
|
243
|
+
profile: {
|
|
244
|
+
en: 'Target build profile. Defaults to staging.',
|
|
245
|
+
ko: '타깃 빌드 프로필. 기본값은 staging.',
|
|
246
|
+
},
|
|
247
|
+
'playtest-script': {
|
|
248
|
+
en: 'Optional game-owned package script used for automated playtesting.',
|
|
249
|
+
ko: '자동 플레이테스트에 사용할 선택적 게임 소유 패키지 스크립트.',
|
|
250
|
+
},
|
|
251
|
+
'report-dir': {
|
|
252
|
+
en: 'JSON and Markdown handoff report directory.',
|
|
253
|
+
ko: 'JSON 및 Markdown 인계 리포트 디렉터리.',
|
|
254
|
+
},
|
|
255
|
+
'timeout-ms': {
|
|
256
|
+
en: 'Maximum duration for each acceptance command.',
|
|
257
|
+
ko: '각 인수 검증 명령의 최대 실행 시간.',
|
|
258
|
+
},
|
|
259
|
+
'kit-path': {
|
|
260
|
+
en: 'Path to the mpgd-kit checkout.',
|
|
261
|
+
ko: 'mpgd-kit 체크아웃 경로.',
|
|
262
|
+
},
|
|
263
|
+
}),
|
|
264
|
+
args: {
|
|
265
|
+
game: {
|
|
266
|
+
type: 'positional',
|
|
267
|
+
required: true,
|
|
268
|
+
description: 'Game root containing package.json and mpgd.targets.json.',
|
|
269
|
+
},
|
|
270
|
+
targets: {
|
|
271
|
+
type: 'string',
|
|
272
|
+
required: false,
|
|
273
|
+
default: 'default',
|
|
274
|
+
description: 'Comma-separated target list, default, or all.',
|
|
275
|
+
},
|
|
276
|
+
profile: {
|
|
277
|
+
type: 'string',
|
|
278
|
+
required: false,
|
|
279
|
+
default: 'staging',
|
|
280
|
+
description: 'Target build profile.',
|
|
281
|
+
},
|
|
282
|
+
'ait-variant': {
|
|
283
|
+
type: 'enum',
|
|
284
|
+
choices: supportedVariants,
|
|
285
|
+
required: false,
|
|
286
|
+
default: 'wrapper',
|
|
287
|
+
description: 'Variant for the ait target.',
|
|
288
|
+
},
|
|
289
|
+
'ios-variant': {
|
|
290
|
+
type: 'enum',
|
|
291
|
+
choices: supportedVariants,
|
|
292
|
+
required: false,
|
|
293
|
+
description: 'Variant for the ios target.',
|
|
294
|
+
},
|
|
295
|
+
'playtest-script': {
|
|
296
|
+
type: 'string',
|
|
297
|
+
required: false,
|
|
298
|
+
default: 'playtest',
|
|
299
|
+
description: 'Game-owned package script used for automated playtesting.',
|
|
300
|
+
},
|
|
301
|
+
'report-dir': {
|
|
302
|
+
type: 'string',
|
|
303
|
+
required: false,
|
|
304
|
+
description: 'JSON and Markdown handoff report directory.',
|
|
305
|
+
},
|
|
306
|
+
'timeout-ms': {
|
|
307
|
+
type: 'string',
|
|
308
|
+
required: false,
|
|
309
|
+
default: String(defaultGameAcceptanceCommandTimeoutMs),
|
|
310
|
+
description: 'Maximum duration for each acceptance command in milliseconds.',
|
|
311
|
+
},
|
|
312
|
+
'kit-path': {
|
|
313
|
+
type: 'string',
|
|
314
|
+
required: false,
|
|
315
|
+
description: 'Path to the mpgd-kit checkout.',
|
|
316
|
+
},
|
|
317
|
+
'skip-test': {
|
|
318
|
+
type: 'boolean',
|
|
319
|
+
required: false,
|
|
320
|
+
description: 'Skip the game-owned test package script.',
|
|
321
|
+
},
|
|
322
|
+
'skip-graph': {
|
|
323
|
+
type: 'boolean',
|
|
324
|
+
required: false,
|
|
325
|
+
description: 'Skip the kit graph preflight.',
|
|
326
|
+
},
|
|
327
|
+
'skip-playtest': {
|
|
328
|
+
type: 'boolean',
|
|
329
|
+
required: false,
|
|
330
|
+
description: 'Skip the game-owned playtest package script.',
|
|
331
|
+
},
|
|
332
|
+
'skip-target-build': {
|
|
333
|
+
type: 'boolean',
|
|
334
|
+
required: false,
|
|
335
|
+
description: 'Skip the target build matrix.',
|
|
336
|
+
},
|
|
337
|
+
'skip-target-smoke': {
|
|
338
|
+
type: 'boolean',
|
|
339
|
+
required: false,
|
|
340
|
+
description: 'Skip the target smoke matrix.',
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
run: (ctx) => {
|
|
344
|
+
const positionals = readLocalPositionals(ctx.positionals, ['game', 'accept']);
|
|
345
|
+
const gameRoot = path.resolve(readRequiredPositional(positionals, 0, 'game'));
|
|
346
|
+
const iosVariant = readOptionalString(ctx.values['ios-variant']);
|
|
347
|
+
const reportDir = readOptionalString(ctx.values['report-dir']);
|
|
348
|
+
const kitPath = readOptionalString(ctx.values['kit-path']);
|
|
349
|
+
const timeoutMs = readPositiveInteger(readOptionalString(ctx.values['timeout-ms'])
|
|
350
|
+
?? String(defaultGameAcceptanceCommandTimeoutMs), '--timeout-ms');
|
|
351
|
+
acceptGame({
|
|
352
|
+
gameRoot,
|
|
353
|
+
targets: readOptionalString(ctx.values.targets) ?? 'default',
|
|
354
|
+
profile: readOptionalString(ctx.values.profile) ?? 'staging',
|
|
355
|
+
aitVariant: readOptionalString(ctx.values['ait-variant']) ?? 'wrapper',
|
|
356
|
+
...(iosVariant === undefined ? {} : { iosVariant }),
|
|
357
|
+
playtestScript: readOptionalString(ctx.values['playtest-script']) ?? 'playtest',
|
|
358
|
+
...(reportDir === undefined ? {} : { reportDir }),
|
|
359
|
+
...(kitPath === undefined ? {} : { kitPath }),
|
|
360
|
+
timeoutMs,
|
|
361
|
+
skipTest: ctx.values['skip-test'] === true,
|
|
362
|
+
skipGraph: ctx.values['skip-graph'] === true,
|
|
363
|
+
skipPlaytest: ctx.values['skip-playtest'] === true,
|
|
364
|
+
skipTargetBuild: ctx.values['skip-target-build'] === true,
|
|
365
|
+
skipTargetSmoke: ctx.values['skip-target-smoke'] === true,
|
|
366
|
+
});
|
|
367
|
+
},
|
|
368
|
+
}),
|
|
369
|
+
icons: defineI18n({
|
|
370
|
+
name: 'icons',
|
|
371
|
+
description: 'Generate and verify target app icons from the game brand source.',
|
|
372
|
+
resource: commandResource({
|
|
373
|
+
en: 'Generate and verify target app icons from the game brand source.',
|
|
374
|
+
ko: '게임 브랜드 원본에서 타깃별 앱 아이콘을 생성하고 검증합니다.',
|
|
375
|
+
}),
|
|
376
|
+
subCommands: Object.fromEntries(['generate', 'verify', 'inspect'].map((action) => [
|
|
377
|
+
action,
|
|
378
|
+
defineI18n({
|
|
379
|
+
name: action,
|
|
380
|
+
description: `${action} target app icon assets.`,
|
|
381
|
+
resource: commandResource({
|
|
382
|
+
en: `${action} target app icon assets.`,
|
|
383
|
+
ko: `타깃 앱 아이콘 자산을 ${action}합니다.`,
|
|
384
|
+
}),
|
|
385
|
+
args: {
|
|
386
|
+
game: {
|
|
387
|
+
type: 'positional',
|
|
388
|
+
required: true,
|
|
389
|
+
description: 'Game root containing mpgd.game.json and mpgd.targets.json.',
|
|
390
|
+
},
|
|
391
|
+
targets: {
|
|
392
|
+
type: 'string',
|
|
393
|
+
required: false,
|
|
394
|
+
description: 'Comma-separated target names. Defaults to every configured target.',
|
|
395
|
+
},
|
|
396
|
+
profile: {
|
|
397
|
+
type: 'string',
|
|
398
|
+
required: false,
|
|
399
|
+
default: action === 'verify' ? 'production' : 'development',
|
|
400
|
+
description: 'Validation profile (development or production).',
|
|
401
|
+
},
|
|
402
|
+
'kit-path': {
|
|
403
|
+
type: 'string',
|
|
404
|
+
required: false,
|
|
405
|
+
description: 'Path to the mpgd-kit checkout.',
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
run: (ctx) => {
|
|
409
|
+
const positionals = readLocalPositionals(ctx.positionals, ['game', 'icons', action]);
|
|
410
|
+
const gameRoot = path.resolve(readRequiredPositional(positionals, 0, 'game'));
|
|
411
|
+
const env = createTargetCommandEnv({
|
|
412
|
+
'targets-file': path.join(gameRoot, 'mpgd.targets.json'),
|
|
413
|
+
'kit-path': ctx.values['kit-path'],
|
|
414
|
+
});
|
|
415
|
+
const targets = readOptionalString(ctx.values.targets) ?? '';
|
|
416
|
+
const profile = readOptionalString(ctx.values.profile)
|
|
417
|
+
?? (action === 'verify' ? 'production' : 'development');
|
|
418
|
+
runPnpm([`game:icons:${action}`, '--', targets, profile], env);
|
|
419
|
+
},
|
|
420
|
+
}),
|
|
421
|
+
])),
|
|
422
|
+
run: () => {
|
|
423
|
+
console.log('Use "mpgd game icons generate <game>", verify, or inspect.');
|
|
424
|
+
},
|
|
425
|
+
}),
|
|
216
426
|
},
|
|
217
427
|
run: () => {
|
|
218
|
-
console.log('Use "mpgd game create <directory>".');
|
|
428
|
+
console.log('Use "mpgd game create <directory>", "mpgd game accept <game>", or "mpgd game icons generate <game>".');
|
|
219
429
|
},
|
|
220
430
|
});
|
|
221
431
|
const defaultLegalDir = 'legal';
|
|
222
432
|
const defaultLegalOutDir = 'artifacts/legal-site';
|
|
223
433
|
const legalPageSlugs = ['privacy', 'support', 'terms'];
|
|
224
434
|
const legalPageFileList = legalPageSlugs.map((slug) => `${slug}.html`).join(', ');
|
|
435
|
+
function acceptGame(input) {
|
|
436
|
+
if (input.skipTargetBuild && !input.skipTargetSmoke) {
|
|
437
|
+
throw new Error('Use --skip-target-smoke when --skip-target-build is set.');
|
|
438
|
+
}
|
|
439
|
+
const gameRoot = assertGameAcceptanceRoot(input.gameRoot);
|
|
440
|
+
const gamePackage = readPackageJsonOrUndefined(gameRoot, { strictParse: true });
|
|
441
|
+
if (gamePackage === undefined) {
|
|
442
|
+
throw new Error(`Missing game package.json: ${gameRoot}`);
|
|
443
|
+
}
|
|
444
|
+
const targetsFile = path.join(gameRoot, 'mpgd.targets.json');
|
|
445
|
+
if (!existsSync(targetsFile)) {
|
|
446
|
+
throw new Error(`Missing game target config: ${targetsFile}`);
|
|
447
|
+
}
|
|
448
|
+
const kitPath = resolveKitPathForTarget({
|
|
449
|
+
...(input.kitPath === undefined ? {} : { 'kit-path': input.kitPath }),
|
|
450
|
+
});
|
|
451
|
+
const packageManager = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
|
|
452
|
+
const steps = [
|
|
453
|
+
packageScriptAcceptanceStep({
|
|
454
|
+
id: acceptanceStepIds.check,
|
|
455
|
+
label: 'Game check',
|
|
456
|
+
script: 'check',
|
|
457
|
+
gameRoot,
|
|
458
|
+
packageManager,
|
|
459
|
+
scripts: gamePackage.scripts,
|
|
460
|
+
required: true,
|
|
461
|
+
}),
|
|
462
|
+
packageScriptAcceptanceStep({
|
|
463
|
+
id: acceptanceStepIds.test,
|
|
464
|
+
label: 'Game tests',
|
|
465
|
+
script: 'test',
|
|
466
|
+
gameRoot,
|
|
467
|
+
packageManager,
|
|
468
|
+
scripts: gamePackage.scripts,
|
|
469
|
+
required: false,
|
|
470
|
+
skipped: input.skipTest,
|
|
471
|
+
}),
|
|
472
|
+
packageScriptAcceptanceStep({
|
|
473
|
+
id: acceptanceStepIds.build,
|
|
474
|
+
label: 'Game build',
|
|
475
|
+
script: 'build',
|
|
476
|
+
gameRoot,
|
|
477
|
+
packageManager,
|
|
478
|
+
scripts: gamePackage.scripts,
|
|
479
|
+
required: true,
|
|
480
|
+
}),
|
|
481
|
+
input.skipGraph
|
|
482
|
+
? skippedAcceptanceStep(acceptanceStepIds.graph, 'TypeScript graph preflight', 'Disabled by --skip-graph.')
|
|
483
|
+
: {
|
|
484
|
+
id: acceptanceStepIds.graph,
|
|
485
|
+
label: 'TypeScript graph preflight',
|
|
486
|
+
command: packageManager,
|
|
487
|
+
args: ['graph:preflight'],
|
|
488
|
+
cwd: kitPath,
|
|
489
|
+
},
|
|
490
|
+
packageScriptAcceptanceStep({
|
|
491
|
+
id: acceptanceStepIds.playtest,
|
|
492
|
+
label: 'Automated playtest',
|
|
493
|
+
script: input.playtestScript,
|
|
494
|
+
gameRoot,
|
|
495
|
+
packageManager,
|
|
496
|
+
scripts: gamePackage.scripts,
|
|
497
|
+
required: false,
|
|
498
|
+
skipped: input.skipPlaytest,
|
|
499
|
+
}),
|
|
500
|
+
targetMatrixAcceptanceStep({
|
|
501
|
+
action: 'build-all',
|
|
502
|
+
id: acceptanceStepIds.targetBuild,
|
|
503
|
+
label: 'Target build matrix',
|
|
504
|
+
kitPath,
|
|
505
|
+
packageManager,
|
|
506
|
+
targetsFile,
|
|
507
|
+
targets: input.targets,
|
|
508
|
+
profile: input.profile,
|
|
509
|
+
aitVariant: input.aitVariant,
|
|
510
|
+
...(input.iosVariant === undefined ? {} : { iosVariant: input.iosVariant }),
|
|
511
|
+
skipped: input.skipTargetBuild,
|
|
512
|
+
}),
|
|
513
|
+
targetMatrixAcceptanceStep({
|
|
514
|
+
action: 'smoke-all',
|
|
515
|
+
id: acceptanceStepIds.targetSmoke,
|
|
516
|
+
label: 'Target smoke matrix',
|
|
517
|
+
kitPath,
|
|
518
|
+
packageManager,
|
|
519
|
+
targetsFile,
|
|
520
|
+
targets: input.targets,
|
|
521
|
+
profile: input.profile,
|
|
522
|
+
aitVariant: input.aitVariant,
|
|
523
|
+
...(input.iosVariant === undefined ? {} : { iosVariant: input.iosVariant }),
|
|
524
|
+
skipped: input.skipTargetSmoke,
|
|
525
|
+
}),
|
|
526
|
+
];
|
|
527
|
+
const reportDir = path.resolve(gameRoot, input.reportDir ?? 'artifacts/acceptance');
|
|
528
|
+
const releaseManifestFile = resolveGameAcceptanceReleaseManifestFile(gameRoot);
|
|
529
|
+
const result = runGameAcceptance({
|
|
530
|
+
gameRoot,
|
|
531
|
+
reportDir,
|
|
532
|
+
...(input.skipTargetBuild
|
|
533
|
+
? {}
|
|
534
|
+
: { releaseManifestFile }),
|
|
535
|
+
options: {
|
|
536
|
+
targets: input.targets,
|
|
537
|
+
profile: input.profile,
|
|
538
|
+
aitVariant: input.aitVariant,
|
|
539
|
+
iosVariant: input.iosVariant ?? null,
|
|
540
|
+
playtestScript: input.playtestScript,
|
|
541
|
+
skipTest: input.skipTest,
|
|
542
|
+
skipGraph: input.skipGraph,
|
|
543
|
+
skipPlaytest: input.skipPlaytest,
|
|
544
|
+
skipTargetBuild: input.skipTargetBuild,
|
|
545
|
+
skipTargetSmoke: input.skipTargetSmoke,
|
|
546
|
+
timeoutMs: String(input.timeoutMs),
|
|
547
|
+
},
|
|
548
|
+
steps,
|
|
549
|
+
commandTimeoutMs: input.timeoutMs,
|
|
550
|
+
env: {
|
|
551
|
+
...process.env,
|
|
552
|
+
MPGD_KIT_PATH: kitPath,
|
|
553
|
+
MPGD_RELEASE_MANIFEST_FILE: releaseManifestFile,
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
console.log(`[mpgd:accept] JSON report: ${result.jsonFile}`);
|
|
557
|
+
console.log(`[mpgd:accept] Markdown report: ${result.markdownFile}`);
|
|
558
|
+
if (result.report.status === 'failed') {
|
|
559
|
+
throw new Error(`Game acceptance failed. Report: ${result.markdownFile}`);
|
|
560
|
+
}
|
|
561
|
+
console.log('[mpgd:accept] passed');
|
|
562
|
+
}
|
|
563
|
+
function assertGameAcceptanceRoot(gameRoot) {
|
|
564
|
+
const resolved = path.resolve(gameRoot);
|
|
565
|
+
if (!existsSync(resolved)) {
|
|
566
|
+
throw new Error(`Game root does not exist: ${resolved}`);
|
|
567
|
+
}
|
|
568
|
+
return realpathSync(resolved);
|
|
569
|
+
}
|
|
570
|
+
function packageScriptAcceptanceStep(input) {
|
|
571
|
+
if (input.skipped === true) {
|
|
572
|
+
return skippedAcceptanceStep(input.id, input.label, 'Disabled by command option.');
|
|
573
|
+
}
|
|
574
|
+
if (input.scripts?.[input.script] === undefined && !input.required) {
|
|
575
|
+
return skippedAcceptanceStep(input.id, input.label, `Optional package script "${input.script}" is not configured.`);
|
|
576
|
+
}
|
|
577
|
+
return {
|
|
578
|
+
id: input.id,
|
|
579
|
+
label: input.label,
|
|
580
|
+
command: input.packageManager,
|
|
581
|
+
args: ['run', input.script],
|
|
582
|
+
cwd: input.gameRoot,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
function targetMatrixAcceptanceStep(input) {
|
|
586
|
+
if (input.skipped) {
|
|
587
|
+
return skippedAcceptanceStep(input.id, input.label, 'Disabled by command option.');
|
|
588
|
+
}
|
|
589
|
+
const args = [
|
|
590
|
+
'mpgd',
|
|
591
|
+
'target',
|
|
592
|
+
input.action,
|
|
593
|
+
'--targets-file',
|
|
594
|
+
input.targetsFile,
|
|
595
|
+
'--kit-path',
|
|
596
|
+
input.kitPath,
|
|
597
|
+
'--targets',
|
|
598
|
+
input.targets,
|
|
599
|
+
];
|
|
600
|
+
if (input.action === 'build-all') {
|
|
601
|
+
args.push('--profile', input.profile, '--ait-variant', input.aitVariant);
|
|
602
|
+
if (input.iosVariant !== undefined) {
|
|
603
|
+
args.push('--ios-variant', input.iosVariant);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return {
|
|
607
|
+
id: input.id,
|
|
608
|
+
label: input.label,
|
|
609
|
+
command: input.packageManager,
|
|
610
|
+
args,
|
|
611
|
+
cwd: input.kitPath,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
function skippedAcceptanceStep(id, label, skipReason) {
|
|
615
|
+
return { id, label, skipReason };
|
|
616
|
+
}
|
|
617
|
+
function readPositiveInteger(value, label) {
|
|
618
|
+
const parsed = Number(value);
|
|
619
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
620
|
+
throw new Error(`${label} must be a positive integer.`);
|
|
621
|
+
}
|
|
622
|
+
return parsed;
|
|
623
|
+
}
|
|
225
624
|
const legalCommand = defineI18n({
|
|
226
625
|
name: 'legal',
|
|
227
626
|
description: 'Build static legal/support pages for store and release evidence.',
|
|
@@ -738,6 +1137,7 @@ function createGameApp(input) {
|
|
|
738
1137
|
' mpgd target build-all',
|
|
739
1138
|
`--targets-file ${path.join(appDir, 'mpgd.targets.json')}`,
|
|
740
1139
|
`--targets ${recommendedMatrixTargets}`,
|
|
1140
|
+
'--profile staging',
|
|
741
1141
|
'--ait-variant wrapper',
|
|
742
1142
|
'--kit-path <path-to-mpgd-kit>',
|
|
743
1143
|
].join(' '));
|
|
@@ -1010,7 +1410,7 @@ function readConfiguredPath(value) {
|
|
|
1010
1410
|
return normalized === undefined || normalized.length === 0 ? undefined : normalized;
|
|
1011
1411
|
}
|
|
1012
1412
|
function readSourceGitSha(gameRoot) {
|
|
1013
|
-
const result = spawnSync('git', ['-C', gameRoot, 'rev-parse', '
|
|
1413
|
+
const result = spawnSync('git', ['-C', gameRoot, 'rev-parse', 'HEAD'], {
|
|
1014
1414
|
encoding: 'utf8',
|
|
1015
1415
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1016
1416
|
});
|
|
@@ -1088,8 +1488,9 @@ function replaceTargetTokens(value, context) {
|
|
|
1088
1488
|
function runTargetCommand(input) {
|
|
1089
1489
|
const target = normalizeBuildTarget(input.target);
|
|
1090
1490
|
const env = withTargetVariantEnv(target, input.variant, input.env);
|
|
1491
|
+
const profile = input.profile ?? 'production';
|
|
1091
1492
|
const args = input.action === 'build'
|
|
1092
|
-
? ['build:target', target,
|
|
1493
|
+
? ['build:target', target, profile]
|
|
1093
1494
|
: ['smoke:target', target];
|
|
1094
1495
|
console.log(`[mpgd] ${input.action} ${target}`);
|
|
1095
1496
|
runPnpm(args, env);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface ProductionTargetReadinessInput {
|
|
2
|
+
readonly target: string;
|
|
3
|
+
readonly profile: string;
|
|
4
|
+
readonly targetsFile: string;
|
|
5
|
+
readonly gameRoot: string;
|
|
6
|
+
readonly gameServicesUrl?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function assertProductionTargetReadiness(input: ProductionTargetReadinessInput): void;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const ownerPathByTarget = {
|
|
5
|
+
ait: 'wrapperApp',
|
|
6
|
+
android: 'shellApp',
|
|
7
|
+
ios: 'shellApp',
|
|
8
|
+
};
|
|
9
|
+
export function assertProductionTargetReadiness(input) {
|
|
10
|
+
if (typeof input.profile !== 'string'
|
|
11
|
+
|| input.profile.length === 0
|
|
12
|
+
|| input.profile.trim() !== input.profile) {
|
|
13
|
+
throw new Error('Target build profile must be a non-empty string without surrounding whitespace.');
|
|
14
|
+
}
|
|
15
|
+
if (input.profile !== 'production') {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const ownerPathKey = ownerPathByTarget[input.target];
|
|
19
|
+
if (ownerPathKey === undefined) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const targetConfig = readTargets(input.targetsFile)[input.target];
|
|
23
|
+
if (!isRecord(targetConfig)) {
|
|
24
|
+
throw new Error(`Missing target configuration for ${input.target}.`);
|
|
25
|
+
}
|
|
26
|
+
const ownerPath = targetConfig[ownerPathKey];
|
|
27
|
+
const webDir = targetConfig.webDir;
|
|
28
|
+
if (typeof ownerPath !== 'string' || ownerPath.length === 0) {
|
|
29
|
+
throw new Error(`Production target ${input.target} is missing ${ownerPathKey}.`);
|
|
30
|
+
}
|
|
31
|
+
if (typeof webDir !== 'string' || webDir.length === 0) {
|
|
32
|
+
throw new Error(`Production target ${input.target} is missing webDir.`);
|
|
33
|
+
}
|
|
34
|
+
const resolvedOwnerPath = path.resolve(path.dirname(input.targetsFile), ownerPath);
|
|
35
|
+
const resolvedWebDir = path.resolve(path.dirname(input.targetsFile), webDir);
|
|
36
|
+
const canonicalGameRoot = readCanonicalPath(input.gameRoot, 'game root');
|
|
37
|
+
const canonicalOwnerPath = readCanonicalPath(resolvedOwnerPath, `${input.target} ${ownerPathKey}`);
|
|
38
|
+
if (!isDedicatedChildPath(canonicalGameRoot, canonicalOwnerPath)) {
|
|
39
|
+
throw new Error(`Production target ${input.target} must use a game-owned ${ownerPathKey} inside `
|
|
40
|
+
+ `${canonicalGameRoot}; received ${canonicalOwnerPath}. `
|
|
41
|
+
+ 'Use a non-production profile for kit reference wrapper/shell smoke builds.');
|
|
42
|
+
}
|
|
43
|
+
const canonicalWebDir = readCanonicalPathAllowingMissingTail(resolvedWebDir, `${input.target} webDir`);
|
|
44
|
+
if (!isDedicatedChildPath(canonicalOwnerPath, canonicalWebDir)) {
|
|
45
|
+
throw new Error(`Production target ${input.target} must keep webDir inside its game-owned `
|
|
46
|
+
+ `${ownerPathKey} ${canonicalOwnerPath}; received ${canonicalWebDir}.`);
|
|
47
|
+
}
|
|
48
|
+
if (input.target === 'android' || input.target === 'ios') {
|
|
49
|
+
const canonicalPlatformDir = readCanonicalPathAllowingMissingTail(path.join(resolvedOwnerPath, input.target), `${input.target} native platform directory`);
|
|
50
|
+
if (!isDedicatedChildPath(canonicalOwnerPath, canonicalPlatformDir)) {
|
|
51
|
+
throw new Error(`Production target ${input.target} must keep its native platform directory inside `
|
|
52
|
+
+ `${canonicalOwnerPath}; received ${canonicalPlatformDir}.`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
assertAuthoritativeGameServicesUrl(input.gameServicesUrl, input.target);
|
|
56
|
+
}
|
|
57
|
+
function readTargets(targetsFile) {
|
|
58
|
+
let parsed;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(readFileSync(targetsFile, 'utf8'));
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
throw new Error(`Failed to read prepared targets file ${targetsFile}: ${formatError(error)}`);
|
|
64
|
+
}
|
|
65
|
+
if (!isRecord(parsed) || !isRecord(parsed.targets)) {
|
|
66
|
+
throw new Error(`Prepared targets file ${targetsFile} must contain a targets object.`);
|
|
67
|
+
}
|
|
68
|
+
return parsed.targets;
|
|
69
|
+
}
|
|
70
|
+
// Equality is rejected: a wrapper or shell must be a dedicated child of the game root.
|
|
71
|
+
function isDedicatedChildPath(parent, candidate) {
|
|
72
|
+
const relative = path.relative(parent, candidate);
|
|
73
|
+
return relative.length > 0
|
|
74
|
+
&& relative !== '..'
|
|
75
|
+
&& !relative.startsWith(`..${path.sep}`)
|
|
76
|
+
&& !path.isAbsolute(relative);
|
|
77
|
+
}
|
|
78
|
+
function readCanonicalPath(candidate, label) {
|
|
79
|
+
try {
|
|
80
|
+
return realpathSync(candidate);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
throw new Error(`Production ${label} must exist: ${candidate} (${formatError(error)})`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function readCanonicalPathAllowingMissingTail(candidate, label) {
|
|
87
|
+
let existingPath = candidate;
|
|
88
|
+
const missingSegments = [];
|
|
89
|
+
while (!existsSync(existingPath)) {
|
|
90
|
+
const parent = path.dirname(existingPath);
|
|
91
|
+
if (parent === existingPath) {
|
|
92
|
+
throw new Error(`Production ${label} has no existing ancestor: ${candidate}`);
|
|
93
|
+
}
|
|
94
|
+
missingSegments.unshift(path.basename(existingPath));
|
|
95
|
+
existingPath = parent;
|
|
96
|
+
}
|
|
97
|
+
return path.join(readCanonicalPath(existingPath, label), ...missingSegments);
|
|
98
|
+
}
|
|
99
|
+
function assertAuthoritativeGameServicesUrl(value, target) {
|
|
100
|
+
if (value === undefined || value.length === 0) {
|
|
101
|
+
throw new Error(`Production target ${target} requires VITE_MPGD_GAME_SERVICES_URL for an `
|
|
102
|
+
+ 'authoritative remote backend.');
|
|
103
|
+
}
|
|
104
|
+
let url;
|
|
105
|
+
try {
|
|
106
|
+
url = new URL(value);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
throw new Error(`Production target ${target} has an invalid game-services URL.`);
|
|
110
|
+
}
|
|
111
|
+
if (url.protocol !== 'https:'
|
|
112
|
+
|| url.username.length > 0
|
|
113
|
+
|| url.password.length > 0
|
|
114
|
+
|| isNonPublicServiceHostname(url.hostname)) {
|
|
115
|
+
throw new Error(`Production target ${target} requires a public HTTPS game-services URL `
|
|
116
|
+
+ 'without embedded credentials.');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function isNonPublicServiceHostname(hostname) {
|
|
120
|
+
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/u, '');
|
|
121
|
+
if (normalized === 'localhost'
|
|
122
|
+
|| normalized.endsWith('.localhost')
|
|
123
|
+
|| normalized.endsWith('.local')) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
const ipVersion = isIP(normalized);
|
|
127
|
+
if (ipVersion === 4) {
|
|
128
|
+
return isNonPublicIpv4(normalized);
|
|
129
|
+
}
|
|
130
|
+
if (ipVersion === 6) {
|
|
131
|
+
return isNonPublicIpv6(normalized);
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
function isNonPublicIpv4(address) {
|
|
136
|
+
const [first = 0, second = 0, third = 0] = address.split('.').map(Number);
|
|
137
|
+
return first === 0
|
|
138
|
+
|| first === 10
|
|
139
|
+
|| first === 127
|
|
140
|
+
|| (first === 100 && second >= 64 && second <= 127)
|
|
141
|
+
|| (first === 169 && second === 254)
|
|
142
|
+
|| (first === 172 && second >= 16 && second <= 31)
|
|
143
|
+
|| (first === 192 && second === 0 && third === 0)
|
|
144
|
+
|| (first === 192 && second === 0 && third === 2)
|
|
145
|
+
|| (first === 192 && second === 168)
|
|
146
|
+
|| (first === 198 && (second === 18 || second === 19))
|
|
147
|
+
|| (first === 198 && second === 51 && third === 100)
|
|
148
|
+
|| (first === 203 && second === 0 && third === 113)
|
|
149
|
+
|| first >= 224;
|
|
150
|
+
}
|
|
151
|
+
function isNonPublicIpv6(address) {
|
|
152
|
+
const words = expandIpv6(address);
|
|
153
|
+
if (words === undefined) {
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
if (words.slice(0, 7).every((word) => word === 0)) {
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
if (words.slice(0, 5).every((word) => word === 0) && words[5] === 0xffff) {
|
|
160
|
+
return isNonPublicIpv4(`${(words[6] ?? 0) >> 8}.${(words[6] ?? 0) & 0xff}.`
|
|
161
|
+
+ `${(words[7] ?? 0) >> 8}.${(words[7] ?? 0) & 0xff}`);
|
|
162
|
+
}
|
|
163
|
+
if (words.slice(0, 6).every((word) => word === 0)) {
|
|
164
|
+
return isNonPublicIpv4(`${(words[6] ?? 0) >> 8}.${(words[6] ?? 0) & 0xff}.`
|
|
165
|
+
+ `${(words[7] ?? 0) >> 8}.${(words[7] ?? 0) & 0xff}`);
|
|
166
|
+
}
|
|
167
|
+
const first = words[0] ?? 0;
|
|
168
|
+
return (first & 0xfe00) === 0xfc00
|
|
169
|
+
|| (first & 0xffc0) === 0xfe80
|
|
170
|
+
|| (first & 0xff00) === 0xff00
|
|
171
|
+
|| (first === 0x2001 && words[1] === 0x0db8);
|
|
172
|
+
}
|
|
173
|
+
function expandIpv6(address) {
|
|
174
|
+
const sections = address.split('::');
|
|
175
|
+
if (sections.length > 2) {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
const head = ipv6Words(sections[0] ?? '');
|
|
179
|
+
const tail = ipv6Words(sections[1] ?? '');
|
|
180
|
+
if (head === undefined || tail === undefined) {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
const omitted = 8 - head.length - tail.length;
|
|
184
|
+
if ((sections.length === 1 && omitted !== 0) || (sections.length === 2 && omitted < 1)) {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
return [...head, ...Array.from({ length: omitted }, () => 0), ...tail];
|
|
188
|
+
}
|
|
189
|
+
function ipv6Words(section) {
|
|
190
|
+
if (section.length === 0) {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
const words = [];
|
|
194
|
+
for (const segment of section.split(':')) {
|
|
195
|
+
if (!/^[0-9a-f]{1,4}$/u.test(segment)) {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
words.push(Number.parseInt(segment, 16));
|
|
199
|
+
}
|
|
200
|
+
return words;
|
|
201
|
+
}
|
|
202
|
+
function isRecord(value) {
|
|
203
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
204
|
+
}
|
|
205
|
+
function formatError(error) {
|
|
206
|
+
return error instanceof Error ? error.message : String(error);
|
|
207
|
+
}
|