@mpgd/cli 0.48.0 → 0.49.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/devvit-output-collisions.d.ts +49 -0
- package/dist/devvit-output-collisions.js +120 -0
- package/dist/index.js +60 -3
- package/dist/native-build-info.json +2 -2
- package/dist/native-build-target.js +13 -1
- package/dist/tooling-migration.d.ts +24 -0
- package/dist/tooling-migration.js +497 -0
- package/package.json +22 -18
- package/templates/lite-overlay/AGENTS.md +5 -0
- package/templates/phaser-game/AGENTS.md +5 -0
- package/templates/phaser-game/apps/target-ait/package.json +2 -2
- package/templates/phaser-game/apps/target-cloudflare-pages/package.json +2 -2
- package/templates/phaser-game/apps/target-devvit/package.json +2 -2
- package/templates/phaser-game/apps/target-devvit/vite.config.ts +2 -0
- package/templates/phaser-game/package.json +7 -7
- package/templates/phaser-game/tools/{run-game-acceptance.mjs → run-game-acceptance.ts} +1 -8
- package/templates/phaser-game/tsconfig.tools.json +12 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One output path written by more than one source in a Devvit web build.
|
|
3
|
+
*
|
|
4
|
+
* `public` means a file from a public directory was silently replaced by a
|
|
5
|
+
* Vite-emitted file of the same name (Vite copies `publicDir` first and then
|
|
6
|
+
* writes the bundle over it). `case` means two output files differ only by
|
|
7
|
+
* letter case. The build plugin checks bundle names before writing so this
|
|
8
|
+
* also works on case-insensitive filesystems.
|
|
9
|
+
*/
|
|
10
|
+
export interface DevvitOutputCollision {
|
|
11
|
+
readonly kind: 'public' | 'case';
|
|
12
|
+
readonly path: string;
|
|
13
|
+
readonly sources: readonly string[];
|
|
14
|
+
}
|
|
15
|
+
/** Validate logical output names before the filesystem can collapse them. */
|
|
16
|
+
export declare function assertDevvitOutputNames(bundleNames: readonly string[], publicNames: readonly string[], outDir: string): void;
|
|
17
|
+
/** Shared Vite 7/8 guard for production and staging watch builds. */
|
|
18
|
+
export declare function devvitOutputCollisionPlugin(): {
|
|
19
|
+
name: string;
|
|
20
|
+
apply: "build";
|
|
21
|
+
enforce: "post";
|
|
22
|
+
configResolved(config: {
|
|
23
|
+
publicDir: string;
|
|
24
|
+
}): void;
|
|
25
|
+
generateBundle: {
|
|
26
|
+
order: "post";
|
|
27
|
+
handler(this: {
|
|
28
|
+
environment: {
|
|
29
|
+
config: {
|
|
30
|
+
build: {
|
|
31
|
+
copyPublicDir: boolean;
|
|
32
|
+
outDir: string;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
}, options: {
|
|
37
|
+
dir?: string | undefined;
|
|
38
|
+
}, bundle: Record<string, {
|
|
39
|
+
fileName: string;
|
|
40
|
+
}>): void;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Finds output files of a Devvit client build that were overwritten or that
|
|
45
|
+
* collide by case. `publicDirs` are the directories Vite copied verbatim.
|
|
46
|
+
*/
|
|
47
|
+
export declare function findDevvitOutputCollisions(outDir: string, publicDirs: readonly string[]): DevvitOutputCollision[];
|
|
48
|
+
/** Throws a single readable error listing every collision. */
|
|
49
|
+
export declare function assertNoDevvitOutputCollisions(outDir: string, publicDirs: readonly string[]): void;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, relative, sep } from 'node:path';
|
|
3
|
+
/** Validate logical output names before the filesystem can collapse them. */
|
|
4
|
+
export function assertDevvitOutputNames(bundleNames, publicNames, outDir) {
|
|
5
|
+
const collisions = [];
|
|
6
|
+
const names = new Map();
|
|
7
|
+
for (const name of [...publicNames, ...bundleNames]) {
|
|
8
|
+
const previous = names.get(name.toLowerCase());
|
|
9
|
+
if (previous !== undefined) {
|
|
10
|
+
collisions.push({
|
|
11
|
+
kind: previous === name ? 'public' : 'case',
|
|
12
|
+
path: name,
|
|
13
|
+
sources: [previous, name],
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
names.set(name.toLowerCase(), name);
|
|
17
|
+
}
|
|
18
|
+
throwCollisions(collisions, outDir);
|
|
19
|
+
}
|
|
20
|
+
/** Shared Vite 7/8 guard for production and staging watch builds. */
|
|
21
|
+
export function devvitOutputCollisionPlugin() {
|
|
22
|
+
let publicDir = '';
|
|
23
|
+
return {
|
|
24
|
+
name: 'mpgd-devvit-output-collisions',
|
|
25
|
+
apply: 'build',
|
|
26
|
+
enforce: 'post',
|
|
27
|
+
configResolved(config) {
|
|
28
|
+
publicDir = config.publicDir;
|
|
29
|
+
},
|
|
30
|
+
generateBundle: {
|
|
31
|
+
order: 'post',
|
|
32
|
+
handler(options, bundle) {
|
|
33
|
+
const publicNames = this.environment.config.build.copyPublicDir
|
|
34
|
+
&& publicDir !== '' && existsSync(publicDir) ? listFiles(publicDir) : [];
|
|
35
|
+
assertDevvitOutputNames(Object.values(bundle).map((output) => output.fileName), publicNames, options.dir ?? this.environment.config.build.outDir);
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function listFiles(root) {
|
|
41
|
+
const out = [];
|
|
42
|
+
const pending = [root];
|
|
43
|
+
while (pending.length > 0) {
|
|
44
|
+
const directory = pending.pop();
|
|
45
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
46
|
+
const absolute = join(directory, entry.name);
|
|
47
|
+
// Follow symlinks: a linked directory is traversed, a linked file listed.
|
|
48
|
+
const stats = entry.isSymbolicLink() ? statSync(absolute, { throwIfNoEntry: false }) : entry;
|
|
49
|
+
if (stats === undefined) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (stats.isDirectory()) {
|
|
53
|
+
pending.push(absolute);
|
|
54
|
+
}
|
|
55
|
+
else if (stats.isFile()) {
|
|
56
|
+
out.push(relative(root, absolute).split(sep).join('/'));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out.sort();
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Finds output files of a Devvit client build that were overwritten or that
|
|
64
|
+
* collide by case. `publicDirs` are the directories Vite copied verbatim.
|
|
65
|
+
*/
|
|
66
|
+
export function findDevvitOutputCollisions(outDir, publicDirs) {
|
|
67
|
+
if (!existsSync(outDir)) {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
const outputs = listFiles(outDir);
|
|
71
|
+
const collisions = [];
|
|
72
|
+
for (const publicDir of publicDirs) {
|
|
73
|
+
if (!existsSync(publicDir)) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
for (const file of listFiles(publicDir)) {
|
|
77
|
+
const output = join(outDir, file);
|
|
78
|
+
if (!existsSync(output)) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (!readFileSync(output).equals(readFileSync(join(publicDir, file)))) {
|
|
82
|
+
collisions.push({
|
|
83
|
+
kind: 'public',
|
|
84
|
+
path: file,
|
|
85
|
+
sources: [join(publicDir, file), 'vite bundle output'],
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const byLowerCase = new Map();
|
|
91
|
+
for (const file of outputs) {
|
|
92
|
+
const key = file.toLowerCase();
|
|
93
|
+
byLowerCase.set(key, [...(byLowerCase.get(key) ?? []), file]);
|
|
94
|
+
}
|
|
95
|
+
for (const files of byLowerCase.values()) {
|
|
96
|
+
if (files.length > 1) {
|
|
97
|
+
collisions.push({ kind: 'case', path: files[0], sources: files });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return collisions;
|
|
101
|
+
}
|
|
102
|
+
/** Throws a single readable error listing every collision. */
|
|
103
|
+
export function assertNoDevvitOutputCollisions(outDir, publicDirs) {
|
|
104
|
+
const collisions = findDevvitOutputCollisions(outDir, publicDirs);
|
|
105
|
+
throwCollisions(collisions, outDir);
|
|
106
|
+
}
|
|
107
|
+
function throwCollisions(collisions, outDir) {
|
|
108
|
+
if (collisions.length === 0) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const lines = collisions.map((collision) => collision.kind === 'public'
|
|
112
|
+
? ` - ${collision.path}: public and bundle files use the same output name`
|
|
113
|
+
: ` - ${collision.sources.join(', ')}: names differ only by case`);
|
|
114
|
+
throw new Error([
|
|
115
|
+
`Devvit build output has ${collisions.length} conflicting file name(s) in ${outDir}:`,
|
|
116
|
+
...lines,
|
|
117
|
+
'Rename the public file or the entry/chunk (Devvit client output uses unhashed [name].js names),'
|
|
118
|
+
+ ' then rebuild.',
|
|
119
|
+
].join('\n'));
|
|
120
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -25,6 +25,7 @@ import { createMicrosoftStorePackageAcceptanceRuntime, runMicrosoftStorePackageA
|
|
|
25
25
|
import { runMicrosoftStorePackageGeneration } from './microsoft-store-package-generation.js';
|
|
26
26
|
import { defaultMpgdKitPath, initializeMicrosoftStoreStarter, listMicrosoftStoreInitializerTemplateFiles, prepareBaseGameTemplateFile, resolveMicrosoftStoreAdapterDependencyVersion, } from './microsoft-store-starter.js';
|
|
27
27
|
import { applyKitUpgrade, planKitUpgrade } from './kit-upgrade.js';
|
|
28
|
+
import { applyToolingMigration, planToolingMigration } from './tooling-migration.js';
|
|
28
29
|
import { runMicrosoftStoreSubmissionPreflight } from './microsoft-store-submission.js';
|
|
29
30
|
import { defaultOfflinePlaytestArtifactDir, defaultOfflinePlaytestMaximumBytes, defaultOfflinePlaytestOutputDir, runOfflinePlaytestPackaging, } from './offline-playtest.js';
|
|
30
31
|
import { targetConfigExtensionsFileEnv, targetConfigMatrixFileEnv } from './target-config-env.js';
|
|
@@ -177,7 +178,7 @@ export async function runMpgdCli(args) {
|
|
|
177
178
|
// partial document. Every other command keeps the default rendering.
|
|
178
179
|
renderHeader: async (ctx) => {
|
|
179
180
|
const values = ctx.values;
|
|
180
|
-
if ((ctx.name === 'verify-delivery' || ctx.name === 'stage-target' || ctx.name === 'publish-s3' || ctx.name === 'upgrade') && values.json === true) {
|
|
181
|
+
if ((ctx.name === 'verify-delivery' || ctx.name === 'stage-target' || ctx.name === 'publish-s3' || ctx.name === 'upgrade' || ctx.name === 'migrate-tools') && values.json === true) {
|
|
181
182
|
return '';
|
|
182
183
|
}
|
|
183
184
|
const title = ctx.env.description || ctx.env.name || '';
|
|
@@ -188,7 +189,7 @@ export async function runMpgdCli(args) {
|
|
|
188
189
|
renderValidationErrors: async (ctx, error) => {
|
|
189
190
|
const messages = error.errors.map((entry) => String(entry.message)).join('\n');
|
|
190
191
|
const values = ctx.values;
|
|
191
|
-
if ((ctx.name === 'verify-delivery' || ctx.name === 'stage-target' || ctx.name === 'publish-s3' || ctx.name === 'upgrade') && values.json === true) {
|
|
192
|
+
if ((ctx.name === 'verify-delivery' || ctx.name === 'stage-target' || ctx.name === 'publish-s3' || ctx.name === 'upgrade' || ctx.name === 'migrate-tools') && values.json === true) {
|
|
192
193
|
process.stderr.write(`${messages}\n`);
|
|
193
194
|
return '';
|
|
194
195
|
}
|
|
@@ -2576,6 +2577,58 @@ const kitCommand = defineI18n({
|
|
|
2576
2577
|
}
|
|
2577
2578
|
},
|
|
2578
2579
|
}),
|
|
2580
|
+
'migrate-tools': defineI18n({
|
|
2581
|
+
name: 'migrate-tools',
|
|
2582
|
+
description: 'Plan or apply the TypeScript tools migration for an existing generated game.',
|
|
2583
|
+
resource: commandResource({
|
|
2584
|
+
base: {
|
|
2585
|
+
en: 'Plan or apply the TypeScript tools migration for an existing generated game.',
|
|
2586
|
+
ko: '기존 생성 게임의 TypeScript 도구 이전을 계획하거나 적용합니다.',
|
|
2587
|
+
},
|
|
2588
|
+
args: {
|
|
2589
|
+
game: { en: 'Existing generated game directory.', ko: '기존 생성 게임 디렉터리.' },
|
|
2590
|
+
apply: { en: 'Apply the reviewed local migration plan.', ko: '검토한 로컬 이전 계획을 적용합니다.' },
|
|
2591
|
+
json: { en: 'Print the complete plan as JSON.', ko: '전체 계획을 JSON으로 출력합니다.' },
|
|
2592
|
+
},
|
|
2593
|
+
}),
|
|
2594
|
+
args: {
|
|
2595
|
+
game: { type: 'string', required: false, default: '.', description: 'Game directory.' },
|
|
2596
|
+
apply: { type: 'boolean', required: false, description: 'Apply the local plan.' },
|
|
2597
|
+
json: { type: 'boolean', required: false, description: 'Print JSON.' },
|
|
2598
|
+
},
|
|
2599
|
+
run: (ctx) => {
|
|
2600
|
+
const plan = planToolingMigration(readOptionalString(ctx.values.game) ?? '.', gameTemplateDir);
|
|
2601
|
+
const result = ctx.values.apply === true && plan.blockers.length === 0
|
|
2602
|
+
? applyToolingMigration(plan) : undefined;
|
|
2603
|
+
if (ctx.values.json === true) {
|
|
2604
|
+
process.stdout.write(JSON.stringify({ plan, result: result ?? null }, null, 2) + '\n');
|
|
2605
|
+
}
|
|
2606
|
+
else {
|
|
2607
|
+
console.info('TypeScript tooling plan for ' + plan.gameRoot);
|
|
2608
|
+
for (const edit of plan.edits) {
|
|
2609
|
+
console.info(' ' + (edit.after === null ? 'Remove ' : 'Write ') + edit.file);
|
|
2610
|
+
}
|
|
2611
|
+
for (const note of plan.notes) {
|
|
2612
|
+
console.info('Note: ' + note);
|
|
2613
|
+
}
|
|
2614
|
+
for (const blocker of plan.blockers) {
|
|
2615
|
+
console.error('Blocked: ' + blocker);
|
|
2616
|
+
}
|
|
2617
|
+
if (result !== undefined) {
|
|
2618
|
+
console.info('Applied ' + result.length + ' file change(s).');
|
|
2619
|
+
}
|
|
2620
|
+
else if (!plan.edits.length && !plan.blockers.length) {
|
|
2621
|
+
console.info('TypeScript tooling is already current.');
|
|
2622
|
+
}
|
|
2623
|
+
else if (!plan.blockers.length) {
|
|
2624
|
+
console.info('Run again with --apply after reviewing the plan.');
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
if (plan.blockers.length) {
|
|
2628
|
+
process.exitCode = 1;
|
|
2629
|
+
}
|
|
2630
|
+
},
|
|
2631
|
+
}),
|
|
2579
2632
|
doctor: defineI18n({
|
|
2580
2633
|
name: 'doctor',
|
|
2581
2634
|
description: 'Print CLI, kit, template, and target-wrapper status.',
|
|
@@ -2594,7 +2647,7 @@ const kitCommand = defineI18n({
|
|
|
2594
2647
|
}),
|
|
2595
2648
|
},
|
|
2596
2649
|
run: () => {
|
|
2597
|
-
console.info('Use "mpgd kit doctor" or "mpgd kit
|
|
2650
|
+
console.info('Use "mpgd kit doctor", "mpgd kit upgrade --game <directory> [--apply]", or "mpgd kit migrate-tools --game <directory> [--apply]".');
|
|
2598
2651
|
},
|
|
2599
2652
|
});
|
|
2600
2653
|
function commandResource(description, args = {}) {
|
|
@@ -3042,6 +3095,9 @@ function createTemplateContext(input) {
|
|
|
3042
3095
|
workspaceI18nBuildPrefix: input.workspace
|
|
3043
3096
|
? `pnpm --dir ${workspacePrefix} i18n:build && `
|
|
3044
3097
|
: '',
|
|
3098
|
+
devvitOutputPluginImport: input.workspace
|
|
3099
|
+
? toJavaScriptStringLiteral(`./${toTemplatePath(path.relative(path.join(input.appDir, 'apps/target-devvit'), path.resolve(kitPath ?? input.appDir, 'packages/cli/src/devvit-output-collisions')))}`)
|
|
3100
|
+
: "'@mpgd/cli/devvit'",
|
|
3045
3101
|
defaultKitPath: kitPath === undefined ? defaultMpgdKitPath : workspacePrefix,
|
|
3046
3102
|
recommendedMatrixTargets: input.microsoftStore
|
|
3047
3103
|
? recommendedMatrixTargets
|
|
@@ -3133,6 +3189,7 @@ function walkTemplateDir(templateDir, relativeDir, files) {
|
|
|
3133
3189
|
}
|
|
3134
3190
|
function renderTemplate(content, context) {
|
|
3135
3191
|
let rendered = content
|
|
3192
|
+
.replaceAll("from '@mpgd/cli/devvit'", `from ${context.devvitOutputPluginImport}`)
|
|
3136
3193
|
.replaceAll('__GAME_NAME__', context.gameName)
|
|
3137
3194
|
.replaceAll('__DEVVIT_APP_NAME__', context.devvitAppName)
|
|
3138
3195
|
.replaceAll('__GAME_TITLE_TS_LITERAL__', context.gameTitleTsLiteral)
|
|
@@ -16525,6 +16525,12 @@ var releaseManifestEnvKeys = [
|
|
|
16525
16525
|
];
|
|
16526
16526
|
var platformTargets = loadPlatformTargetsConfig();
|
|
16527
16527
|
var configBaseDir = platformTargets.baseDir;
|
|
16528
|
+
var kitLinkCheck = resolve18("tools/workspace/relink-kit-workspace.mjs");
|
|
16529
|
+
if (existsSync24(kitLinkCheck)) {
|
|
16530
|
+
spawnSync5(process.execPath, [kitLinkCheck, "--warn-unless-owner", configBaseDir], {
|
|
16531
|
+
stdio: "inherit"
|
|
16532
|
+
});
|
|
16533
|
+
}
|
|
16528
16534
|
var config = platformTargets.config;
|
|
16529
16535
|
var target = config.targets[targetName];
|
|
16530
16536
|
if (target === void 0) {
|
|
@@ -16784,7 +16790,13 @@ try {
|
|
|
16784
16790
|
const wrapperAppConfigPath = requireString2(target.wrapperApp, `${targetName}.wrapperApp`);
|
|
16785
16791
|
const wrapperApp = targetPath(wrapperAppConfigPath);
|
|
16786
16792
|
stageWrapperIcon(generatedIcons, wrapperApp);
|
|
16787
|
-
run(
|
|
16793
|
+
run(process.execPath, [
|
|
16794
|
+
resolve18("tools/run-ttsx.mjs"),
|
|
16795
|
+
"--project",
|
|
16796
|
+
resolve18("tsconfig.tools.json"),
|
|
16797
|
+
resolve18("tools/target/build-devvit.ts"),
|
|
16798
|
+
profile
|
|
16799
|
+
], env, wrapperApp);
|
|
16788
16800
|
embedEffectiveTargetConfig(targetName, webDir, env);
|
|
16789
16801
|
await stageTargetAssetPacks(webDir);
|
|
16790
16802
|
stageWebIconEvidence(generatedIcons, webDir);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
declare const managedPaths: readonly ['package.json', 'tsconfig.tools.json', 'tools/run-game-acceptance.mjs', 'tools/run-game-acceptance.ts', 'pnpm-lock.yaml'];
|
|
2
|
+
type ManagedPath = typeof managedPaths[number];
|
|
3
|
+
declare const snapshotPaths: readonly ["package.json", "tsconfig.tools.json", "tools/run-game-acceptance.mjs", "tools/run-game-acceptance.ts", "pnpm-lock.yaml", "pnpm-workspace.yaml", "tsconfig.json"];
|
|
4
|
+
type SnapshotPath = typeof snapshotPaths[number];
|
|
5
|
+
export interface ToolingMigrationEdit {
|
|
6
|
+
readonly file: ManagedPath;
|
|
7
|
+
readonly before: string | null;
|
|
8
|
+
readonly after: string | null;
|
|
9
|
+
}
|
|
10
|
+
export interface ToolingMigrationPlan {
|
|
11
|
+
readonly gameRoot: string;
|
|
12
|
+
readonly edits: readonly ToolingMigrationEdit[];
|
|
13
|
+
readonly snapshots: Readonly<Record<SnapshotPath, string | null>>;
|
|
14
|
+
readonly childManifests: Readonly<Record<string, string>>;
|
|
15
|
+
readonly updateLockfile: boolean;
|
|
16
|
+
readonly blockers: readonly string[];
|
|
17
|
+
readonly notes: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
/** Plans only known generated-tool changes; custom scripts and source stay untouched. */
|
|
20
|
+
export declare function planToolingMigration(gameDirectory: string, templateRoot?: string): ToolingMigrationPlan;
|
|
21
|
+
/** Applies a fresh local plan, rolling back managed files and the game lockfile on failure. */
|
|
22
|
+
export declare function applyToolingMigration(plan: ToolingMigrationPlan, updateLockfile?: typeof refreshLockfile): readonly string[];
|
|
23
|
+
declare function refreshLockfile(gameRoot: string): void;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
4
|
+
import { hostname } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { parse } from 'acorn';
|
|
8
|
+
import { gte, minVersion, valid } from 'semver';
|
|
9
|
+
import { resolveNativeCommandLaunch } from './command-launcher.js';
|
|
10
|
+
const managedPaths = [
|
|
11
|
+
'package.json',
|
|
12
|
+
'tsconfig.tools.json',
|
|
13
|
+
'tools/run-game-acceptance.mjs',
|
|
14
|
+
'tools/run-game-acceptance.ts',
|
|
15
|
+
'pnpm-lock.yaml',
|
|
16
|
+
];
|
|
17
|
+
const snapshotPaths = [...managedPaths, 'pnpm-workspace.yaml', 'tsconfig.json'];
|
|
18
|
+
const defaultTemplateRoot = fileURLToPath(new URL('../templates/phaser-game/', import.meta.url));
|
|
19
|
+
/** Plans only known generated-tool changes; custom scripts and source stay untouched. */
|
|
20
|
+
export function planToolingMigration(gameDirectory, templateRoot = defaultTemplateRoot) {
|
|
21
|
+
const gameRoot = realpathSync(path.resolve(gameDirectory));
|
|
22
|
+
const snapshots = Object.fromEntries(snapshotPaths.map((file) => [file, readOwnedFile(gameRoot, file)]));
|
|
23
|
+
const blockers = [];
|
|
24
|
+
const childManifests = readChildManifests(gameRoot);
|
|
25
|
+
const notes = [];
|
|
26
|
+
const edits = [];
|
|
27
|
+
const add = (file, after) => {
|
|
28
|
+
if (snapshots[file] !== after) {
|
|
29
|
+
edits.push({ file, before: snapshots[file], after });
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const manifest = parseObject(snapshots['package.json'], 'package.json');
|
|
33
|
+
const template = parseObject(readFileSync(path.join(templateRoot, 'package.json'), 'utf8'), 'template package.json');
|
|
34
|
+
if (manifest.type !== 'module') {
|
|
35
|
+
blockers.push('Only generated ESM games (package.json type: module) can be migrated automatically.');
|
|
36
|
+
}
|
|
37
|
+
const scripts = objectField(manifest, 'scripts');
|
|
38
|
+
const templateScripts = objectField(template, 'scripts');
|
|
39
|
+
const oldCommands = {
|
|
40
|
+
accept: ['node ./tools/run-game-acceptance.mjs', 'node tools/run-game-acceptance.mjs'],
|
|
41
|
+
check: ['ttsc --noEmit -p tsconfig.json'],
|
|
42
|
+
lint: ['ttsc --noEmit -p tsconfig.json'],
|
|
43
|
+
format: ['ttsc format'],
|
|
44
|
+
fix: ['ttsc fix'],
|
|
45
|
+
};
|
|
46
|
+
for (const [name, old] of Object.entries(oldCommands)) {
|
|
47
|
+
const desired = templateScripts[name];
|
|
48
|
+
if (typeof desired !== 'string') {
|
|
49
|
+
throw new Error(`Template script ${name} is missing.`);
|
|
50
|
+
}
|
|
51
|
+
if (scripts[name] === undefined || scripts[name] === desired || old.includes(String(scripts[name]))) {
|
|
52
|
+
scripts[name] = desired;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
blockers.push(`Custom scripts.${name}: preserve it and migrate that command manually.`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
manifest.scripts = scripts;
|
|
59
|
+
const oldRunner = snapshots['tools/run-game-acceptance.mjs'];
|
|
60
|
+
const newRunner = snapshots['tools/run-game-acceptance.ts'];
|
|
61
|
+
if (oldRunner !== null && newRunner !== null) {
|
|
62
|
+
blockers.push('Both acceptance runners exist; resolve their contents before migrating.');
|
|
63
|
+
}
|
|
64
|
+
else if (oldRunner !== null) {
|
|
65
|
+
const templateRunner = readFileSync(path.join(templateRoot, 'tools/run-game-acceptance.ts'), 'utf8');
|
|
66
|
+
const rendered = migrateKnownRunner(oldRunner, templateRunner);
|
|
67
|
+
if (rendered === undefined) {
|
|
68
|
+
blockers.push('The JavaScript acceptance runner is customized; preserve it and migrate it manually.');
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
add('tools/run-game-acceptance.ts', rendered);
|
|
72
|
+
add('tools/run-game-acceptance.mjs', null);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else if (newRunner === null) {
|
|
76
|
+
blockers.push('No generated acceptance runner was found.');
|
|
77
|
+
}
|
|
78
|
+
else if (migrateKnownRunner(newRunner, readFileSync(path.join(templateRoot, 'tools/run-game-acceptance.ts'), 'utf8')) === undefined) {
|
|
79
|
+
blockers.push('The TypeScript acceptance runner is customized; preserve it and migrate it manually.');
|
|
80
|
+
}
|
|
81
|
+
const toolsTemplate = parseObject(readFileSync(path.join(templateRoot, 'tsconfig.tools.json'), 'utf8'), 'template tools project');
|
|
82
|
+
const desiredTools = {
|
|
83
|
+
...toolsTemplate,
|
|
84
|
+
compilerOptions: { ...objectField(toolsTemplate, 'compilerOptions'), strict: true },
|
|
85
|
+
};
|
|
86
|
+
const currentTools = snapshots['tsconfig.tools.json'];
|
|
87
|
+
if (currentTools === null) {
|
|
88
|
+
add('tsconfig.tools.json', stringify(desiredTools));
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
const current = parseObject(currentTools, 'tsconfig.tools.json');
|
|
92
|
+
if (stable(current) === stable(toolsTemplate)) {
|
|
93
|
+
add('tsconfig.tools.json', stringify(desiredTools, currentTools));
|
|
94
|
+
}
|
|
95
|
+
else if (stable(current) !== stable(desiredTools)) {
|
|
96
|
+
blockers.push('Custom tsconfig.tools.json: preserve its options and migrate it manually.');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const dev = objectField(manifest, 'devDependencies');
|
|
100
|
+
const templateDev = objectField(template, 'devDependencies');
|
|
101
|
+
for (const section of ['dependencies', 'optionalDependencies', 'peerDependencies']) {
|
|
102
|
+
for (const name of ['ttsc', 'typescript', '@types/node']) {
|
|
103
|
+
if (objectField(manifest, section)[name] !== undefined) {
|
|
104
|
+
blockers.push(`Custom ${section}.${name}: keep toolchain declarations in devDependencies before migrating.`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const compilerFloor = templateDev.ttsc;
|
|
109
|
+
if (typeof compilerFloor !== 'string' || !valid(compilerFloor)) {
|
|
110
|
+
throw new Error('Template ttsc must have an exact version.');
|
|
111
|
+
}
|
|
112
|
+
const currentCompiler = dev.ttsc;
|
|
113
|
+
if (!existsSync(path.join(gameRoot, 'tsconfig.json'))) {
|
|
114
|
+
blockers.push('Missing game tsconfig.json.');
|
|
115
|
+
}
|
|
116
|
+
let compilerVersion = compilerFloor;
|
|
117
|
+
if (typeof currentCompiler === 'string' && valid(currentCompiler)) {
|
|
118
|
+
if (gte(currentCompiler, compilerFloor)) {
|
|
119
|
+
compilerVersion = currentCompiler;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
else if (currentCompiler !== undefined) {
|
|
123
|
+
blockers.push('Custom ttsc version specifier: align the shared toolchain manually first.');
|
|
124
|
+
}
|
|
125
|
+
let updateLockfile = false;
|
|
126
|
+
for (const name of ['ttsc', 'typescript', '@types/node']) {
|
|
127
|
+
const desired = name === 'ttsc' ? compilerVersion : templateDev[name];
|
|
128
|
+
if (typeof desired !== 'string') {
|
|
129
|
+
throw new Error(`Template dependency ${name} is missing.`);
|
|
130
|
+
}
|
|
131
|
+
const existing = versionFloor(dev[name]);
|
|
132
|
+
const minimum = minVersion(desired);
|
|
133
|
+
if (dev[name] !== undefined && existing === null) {
|
|
134
|
+
blockers.push(`Custom ${name} declaration: update it manually first.`);
|
|
135
|
+
}
|
|
136
|
+
if ((name === 'ttsc' || existing === null || (minimum !== null && !gte(existing, minimum))) && dev[name] !== desired) {
|
|
137
|
+
dev[name] = desired;
|
|
138
|
+
updateLockfile = true;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
for (const section of ['dependencies', 'devDependencies', 'optionalDependencies']) {
|
|
142
|
+
const dependencies = section === 'devDependencies' ? dev : objectField(manifest, section);
|
|
143
|
+
for (const name of Object.keys(dependencies)) {
|
|
144
|
+
if (!name.startsWith('@ttsc/')) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (dependencies[name] !== compilerVersion) {
|
|
148
|
+
if (typeof dependencies[name] !== 'string' || !valid(dependencies[name]) || gte(dependencies[name], compilerVersion)) {
|
|
149
|
+
blockers.push(`Custom or newer ${section}.${name}: align it with ttsc manually first.`);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
dependencies[name] = compilerVersion;
|
|
153
|
+
updateLockfile = true;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (section === 'devDependencies' || manifest[section] !== undefined) {
|
|
158
|
+
manifest[section] = dependencies;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const [name, version] of Object.entries(objectField(manifest, 'peerDependencies'))) {
|
|
162
|
+
if (name.startsWith('@ttsc/') && version !== compilerVersion) {
|
|
163
|
+
blockers.push(`Custom peerDependencies.${name}: align the peer toolchain manually first.`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
for (const [file, source] of Object.entries(childManifests)) {
|
|
167
|
+
const child = parseObject(source, file);
|
|
168
|
+
for (const section of [
|
|
169
|
+
'dependencies',
|
|
170
|
+
'devDependencies',
|
|
171
|
+
'optionalDependencies',
|
|
172
|
+
'peerDependencies',
|
|
173
|
+
]) {
|
|
174
|
+
for (const [name, version] of Object.entries(objectField(child, section))) {
|
|
175
|
+
if (name === 'ttsc' || name.startsWith('@ttsc/')) {
|
|
176
|
+
if (version !== compilerVersion) {
|
|
177
|
+
blockers.push(`${file}: align ${section}.${name} with ttsc ${compilerVersion} manually first.`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
else if (name === 'typescript' || name === '@types/node') {
|
|
181
|
+
const minimum = versionFloor(templateDev[name]);
|
|
182
|
+
const current = versionFloor(version);
|
|
183
|
+
if (minimum === null || current === null || !gte(current, minimum)) {
|
|
184
|
+
blockers.push(`${file}: align ${section}.${name} with the template toolchain manually first.`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (hasAncestorWorkspace(gameRoot)) {
|
|
191
|
+
blockers.push('This game belongs to a parent pnpm workspace. Automatic migration cannot verify its shared toolchain and lockfile; migrate the tools manually after aligning the owning workspace.');
|
|
192
|
+
}
|
|
193
|
+
if (updateLockfile) {
|
|
194
|
+
notes.push('Applying updates the game lockfile with pnpm install --lockfile-only --ignore-scripts.');
|
|
195
|
+
}
|
|
196
|
+
notes.push('Requires Node 24.11 or newer. Run pnpm check and pnpm accept --help after applying.');
|
|
197
|
+
if (stable(manifest) !== stable(parseObject(snapshots['package.json'], 'package.json'))) {
|
|
198
|
+
add('package.json', stringify(manifest, snapshots['package.json'] ?? undefined));
|
|
199
|
+
}
|
|
200
|
+
return { gameRoot, edits, snapshots, childManifests, updateLockfile, blockers, notes };
|
|
201
|
+
}
|
|
202
|
+
/** Applies a fresh local plan, rolling back managed files and the game lockfile on failure. */
|
|
203
|
+
export function applyToolingMigration(plan, updateLockfile = refreshLockfile) {
|
|
204
|
+
if (plan.blockers.length) {
|
|
205
|
+
throw new Error(`Tooling migration blocked:\n${plan.blockers.join('\n')}`);
|
|
206
|
+
}
|
|
207
|
+
assertFreshPlan(plan);
|
|
208
|
+
for (const edit of plan.edits) {
|
|
209
|
+
if (!managedPaths.includes(edit.file) || edit.before !== plan.snapshots[edit.file]) {
|
|
210
|
+
throw new Error('Invalid tooling migration edit.');
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (!plan.edits.length) {
|
|
214
|
+
return [];
|
|
215
|
+
}
|
|
216
|
+
const lockPath = path.join(plan.gameRoot, '.mpgd-tooling-migration.lock');
|
|
217
|
+
const lock = JSON.stringify({
|
|
218
|
+
token: randomUUID(),
|
|
219
|
+
pid: process.pid,
|
|
220
|
+
host: hostname(),
|
|
221
|
+
startedAt: new Date().toISOString(),
|
|
222
|
+
});
|
|
223
|
+
try {
|
|
224
|
+
writeFileSync(lockPath, lock, { flag: 'wx', mode: 0o600 });
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
if (isObject(error) && error.code === 'EEXIST') {
|
|
228
|
+
throw new Error(`Migration lock exists (EEXIST): ${lockPath}. If an earlier run was interrupted, verify its process and pnpm child have exited, inspect partial changes, then remove only this lock and run a fresh preview.`, { cause: error });
|
|
229
|
+
}
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
let writesStarted = false;
|
|
233
|
+
let lockfileUpdateAttempted = false;
|
|
234
|
+
const written = [];
|
|
235
|
+
try {
|
|
236
|
+
// Another migration may have completed between the initial check and lock acquisition.
|
|
237
|
+
assertFreshPlan(plan);
|
|
238
|
+
writesStarted = true;
|
|
239
|
+
for (const edit of plan.edits) {
|
|
240
|
+
if (readOwnedFile(plan.gameRoot, edit.file) !== edit.before) {
|
|
241
|
+
throw new Error(`File changed during migration: ${edit.file}`);
|
|
242
|
+
}
|
|
243
|
+
writeOwnedFile(plan.gameRoot, edit.file, edit.after);
|
|
244
|
+
written.push(edit);
|
|
245
|
+
}
|
|
246
|
+
if (plan.updateLockfile) {
|
|
247
|
+
lockfileUpdateAttempted = true;
|
|
248
|
+
updateLockfile(plan.gameRoot);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
if (!writesStarted) {
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
const failures = [];
|
|
256
|
+
for (const edit of [...written].reverse()) {
|
|
257
|
+
try {
|
|
258
|
+
if (readOwnedFile(plan.gameRoot, edit.file) !== edit.after) {
|
|
259
|
+
throw new Error('A concurrent writer changed the migration output.');
|
|
260
|
+
}
|
|
261
|
+
writeOwnedFile(plan.gameRoot, edit.file, edit.before);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
failures.push(edit.file);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (lockfileUpdateAttempted) {
|
|
268
|
+
try {
|
|
269
|
+
// pnpm does not honor our migration lock. A changed lockfile might be
|
|
270
|
+
// another install's output; preserve it and require manual inspection.
|
|
271
|
+
if (readOwnedFile(plan.gameRoot, 'pnpm-lock.yaml') !== plan.snapshots['pnpm-lock.yaml']) {
|
|
272
|
+
failures.push('pnpm-lock.yaml');
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
failures.push('pnpm-lock.yaml');
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
let rollbackStatus = lockfileUpdateAttempted
|
|
280
|
+
? 'Managed files restored; lockfile unchanged.'
|
|
281
|
+
: 'Managed files restored; lockfile untouched.';
|
|
282
|
+
if (failures.length) {
|
|
283
|
+
rollbackStatus = `Rollback incomplete: ${failures.join(', ')}`;
|
|
284
|
+
}
|
|
285
|
+
throw new Error(`Tooling migration failed. ${rollbackStatus}`, { cause: error });
|
|
286
|
+
}
|
|
287
|
+
finally {
|
|
288
|
+
if (existsSync(lockPath) && readFileSync(lockPath, 'utf8') === lock) {
|
|
289
|
+
rmSync(lockPath);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return plan.edits.map((edit) => edit.file);
|
|
293
|
+
}
|
|
294
|
+
function assertFreshPlan(plan) {
|
|
295
|
+
if (realpathSync(plan.gameRoot) !== plan.gameRoot) {
|
|
296
|
+
throw new Error('Game root changed after planning.');
|
|
297
|
+
}
|
|
298
|
+
for (const file of snapshotPaths) {
|
|
299
|
+
if (readOwnedFile(plan.gameRoot, file) !== plan.snapshots[file]) {
|
|
300
|
+
throw new Error(`File changed after planning: ${file}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (stable(readChildManifests(plan.gameRoot)) !== stable(plan.childManifests)) {
|
|
304
|
+
throw new Error('Child package manifests changed after planning.');
|
|
305
|
+
}
|
|
306
|
+
if (hasAncestorWorkspace(plan.gameRoot)) {
|
|
307
|
+
throw new Error('Parent workspace changed after planning.');
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// Conservatively inspect game-owned nested packages, including custom workspace
|
|
311
|
+
// layouts. Never traverse dependency installs, generated builds or linked trees.
|
|
312
|
+
function readChildManifests(root) {
|
|
313
|
+
const manifests = {};
|
|
314
|
+
const ignored = new Set(['node_modules', 'dist', 'build', 'coverage']);
|
|
315
|
+
function visit(directory) {
|
|
316
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
317
|
+
if (entry.name.startsWith('.') || ignored.has(entry.name)) {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const full = path.join(directory, entry.name);
|
|
321
|
+
if (entry.isSymbolicLink()) {
|
|
322
|
+
if (entry.name === 'package.json' || existsSync(path.join(full, 'package.json'))) {
|
|
323
|
+
throw new Error(`Refusing linked child package: ${path.relative(root, full)}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
else if (entry.isDirectory()) {
|
|
327
|
+
visit(full);
|
|
328
|
+
}
|
|
329
|
+
else if (directory !== root && entry.name === 'package.json') {
|
|
330
|
+
manifests[path.relative(root, full)] = readFileSync(full, 'utf8');
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
visit(root);
|
|
335
|
+
return manifests;
|
|
336
|
+
}
|
|
337
|
+
function parseObject(source, label) {
|
|
338
|
+
if (source === null) {
|
|
339
|
+
throw new Error(`Missing ${label}.`);
|
|
340
|
+
}
|
|
341
|
+
const parsed = JSON.parse(source);
|
|
342
|
+
if (!isObject(parsed)) {
|
|
343
|
+
throw new Error(`${label} must be a JSON object.`);
|
|
344
|
+
}
|
|
345
|
+
return parsed;
|
|
346
|
+
}
|
|
347
|
+
function isObject(value) {
|
|
348
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
349
|
+
}
|
|
350
|
+
function objectField(value, key) {
|
|
351
|
+
if (value[key] === undefined) {
|
|
352
|
+
return {};
|
|
353
|
+
}
|
|
354
|
+
if (!isObject(value[key])) {
|
|
355
|
+
throw new Error(`${key} must be an object.`);
|
|
356
|
+
}
|
|
357
|
+
return value[key];
|
|
358
|
+
}
|
|
359
|
+
function stable(value) {
|
|
360
|
+
return JSON.stringify(value, (_key, entry) => isObject(entry)
|
|
361
|
+
? Object.fromEntries(Object.entries(entry).sort(([a], [b]) => a.localeCompare(b)))
|
|
362
|
+
: entry);
|
|
363
|
+
}
|
|
364
|
+
function stringify(value, original = '') {
|
|
365
|
+
const indent = /^([\t ]+)"/m.exec(original)?.[1] ?? ' ';
|
|
366
|
+
const text = JSON.stringify(value, null, indent) + '\n';
|
|
367
|
+
return original.includes('\r\n') ? text.replace(/\n/g, '\r\n') : text;
|
|
368
|
+
}
|
|
369
|
+
function migrateKnownRunner(source, template) {
|
|
370
|
+
try {
|
|
371
|
+
let comments = 0;
|
|
372
|
+
const ast = parse(source, {
|
|
373
|
+
ecmaVersion: 'latest',
|
|
374
|
+
sourceType: 'module',
|
|
375
|
+
onComment: () => {
|
|
376
|
+
comments++;
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
if (comments) {
|
|
380
|
+
return undefined;
|
|
381
|
+
} // Preserve user-authored comments rather than silently replacing them.
|
|
382
|
+
const defaults = [];
|
|
383
|
+
function walk(value) {
|
|
384
|
+
if (Array.isArray(value)) {
|
|
385
|
+
for (const entry of value) {
|
|
386
|
+
walk(entry);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
else if (isObject(value)) {
|
|
390
|
+
if (value.type === 'ConditionalExpression' && isObject(value.consequent) && typeof value.consequent.value === 'string') {
|
|
391
|
+
defaults.push(value.consequent.value);
|
|
392
|
+
}
|
|
393
|
+
for (const entry of Object.values(value)) {
|
|
394
|
+
walk(entry);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
walk(ast);
|
|
399
|
+
if (defaults.length !== 1) {
|
|
400
|
+
return undefined;
|
|
401
|
+
}
|
|
402
|
+
const rendered = template.replace("'__DEFAULT_KIT_PATH__'", JSON.stringify(defaults[0]));
|
|
403
|
+
const canonical = (value) => JSON.stringify(value, (key, item) => ['start', 'end', 'raw'].includes(key) ? undefined : item);
|
|
404
|
+
return canonical(ast) === canonical(parse(rendered, { ecmaVersion: 'latest', sourceType: 'module' }))
|
|
405
|
+
? rendered
|
|
406
|
+
: undefined;
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
return undefined;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
function readOwnedFile(root, file) {
|
|
413
|
+
const full = ownedPath(root, file);
|
|
414
|
+
return existsSync(full) ? readFileSync(full, 'utf8') : null;
|
|
415
|
+
}
|
|
416
|
+
function ownedPath(root, file) {
|
|
417
|
+
if (!snapshotPaths.includes(file)) {
|
|
418
|
+
throw new Error('Path is not owned by tooling migration.');
|
|
419
|
+
}
|
|
420
|
+
let current = root;
|
|
421
|
+
for (const part of file.split('/')) {
|
|
422
|
+
current = path.join(current, part);
|
|
423
|
+
try {
|
|
424
|
+
if (lstatSync(current).isSymbolicLink()) {
|
|
425
|
+
throw new Error(`Refusing linked migration path: ${file}`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
catch (error) {
|
|
429
|
+
if (!isObject(error) || error.code !== 'ENOENT') {
|
|
430
|
+
throw error;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return current;
|
|
435
|
+
}
|
|
436
|
+
function writeOwnedFile(root, file, source) {
|
|
437
|
+
const full = ownedPath(root, file);
|
|
438
|
+
if (source === null) {
|
|
439
|
+
rmSync(full, { force: true });
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
const temporary = `${full}.mpgd-${randomUUID()}.tmp`;
|
|
443
|
+
try {
|
|
444
|
+
writeFileSync(temporary, source, {
|
|
445
|
+
flag: 'wx',
|
|
446
|
+
mode: existsSync(full) ? lstatSync(full).mode & 0o777 : 0o644,
|
|
447
|
+
});
|
|
448
|
+
renameSync(temporary, full);
|
|
449
|
+
}
|
|
450
|
+
finally {
|
|
451
|
+
rmSync(temporary, { force: true });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
function hasAncestorWorkspace(root) {
|
|
455
|
+
if (existsSync(path.join(root, 'pnpm-workspace.yaml'))) {
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
for (let parent = path.dirname(root);; parent = path.dirname(parent)) {
|
|
459
|
+
if (existsSync(path.join(parent, 'pnpm-workspace.yaml'))) {
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
if (path.dirname(parent) === parent) {
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
function refreshLockfile(gameRoot) {
|
|
468
|
+
const environment = { ...process.env };
|
|
469
|
+
for (const key of Object.keys(environment)) {
|
|
470
|
+
if (key.toLowerCase() === 'npm_config_workspace_dir') {
|
|
471
|
+
delete environment[key];
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const launch = resolveNativeCommandLaunch({
|
|
475
|
+
command: 'pnpm',
|
|
476
|
+
args: ['install', '--lockfile-only', '--ignore-scripts', '--lockfile-dir', gameRoot],
|
|
477
|
+
environment,
|
|
478
|
+
});
|
|
479
|
+
const result = spawnSync(launch.command, [...launch.args], {
|
|
480
|
+
cwd: gameRoot,
|
|
481
|
+
env: environment,
|
|
482
|
+
encoding: 'utf8',
|
|
483
|
+
timeout: 120_000,
|
|
484
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
485
|
+
});
|
|
486
|
+
if (result.error || result.status !== 0) {
|
|
487
|
+
throw new Error(`Could not refresh game lockfile: ${result.error?.message ?? result.stderr}`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
function versionFloor(value) {
|
|
491
|
+
try {
|
|
492
|
+
return typeof value === 'string' ? minVersion(value) : null;
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.49.0",
|
|
4
4
|
"description": "Gunshi CLI for mpgd-kit starter generation and target workflow orchestration.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
".": {
|
|
35
35
|
"types": "./dist/index.d.ts",
|
|
36
36
|
"default": "./dist/index.js"
|
|
37
|
+
},
|
|
38
|
+
"./devvit": {
|
|
39
|
+
"types": "./dist/devvit-output-collisions.d.ts",
|
|
40
|
+
"default": "./dist/devvit-output-collisions.js"
|
|
37
41
|
}
|
|
38
42
|
},
|
|
39
43
|
"files": [
|
|
@@ -66,29 +70,29 @@
|
|
|
66
70
|
"parse5": "7.3.0",
|
|
67
71
|
"postcss": "8.5.28",
|
|
68
72
|
"postcss-value-parser": "4.2.0",
|
|
69
|
-
"@mpgd/catalog": "0.10.
|
|
70
|
-
"@mpgd/phaser-assets": "0.9.
|
|
71
|
-
"@mpgd/platform": "0.23.
|
|
72
|
-
"@mpgd/target-config": "0.23.
|
|
73
|
+
"@mpgd/catalog": "0.10.1",
|
|
74
|
+
"@mpgd/phaser-assets": "0.9.1",
|
|
75
|
+
"@mpgd/platform": "0.23.1",
|
|
76
|
+
"@mpgd/target-config": "0.23.1"
|
|
73
77
|
},
|
|
74
78
|
"devDependencies": {
|
|
75
79
|
"@types/node": "^24.0.0",
|
|
76
80
|
"@types/semver": "^7.8.0",
|
|
77
81
|
"@types/yauzl": "3.4.0",
|
|
78
|
-
"ttsc": "0.
|
|
82
|
+
"ttsc": "0.32.1",
|
|
79
83
|
"typescript": "7.0.2",
|
|
80
|
-
"@mpgd/adapter-ait": "0.17.
|
|
81
|
-
"@mpgd/adapter-browser": "0.11.
|
|
82
|
-
"@mpgd/adapter-capacitor": "0.10.
|
|
83
|
-
"@mpgd/adapter-devvit": "0.12.
|
|
84
|
-
"@mpgd/
|
|
85
|
-
"@mpgd/
|
|
86
|
-
"@mpgd/capacitor-play-billing": "0.3.
|
|
87
|
-
"@mpgd/bridge": "0.13.
|
|
88
|
-
"@mpgd/capacitor-storekit": "0.3.
|
|
89
|
-
"@mpgd/game-
|
|
90
|
-
"@mpgd/game-
|
|
91
|
-
"@mpgd/i18n": "0.6.
|
|
84
|
+
"@mpgd/adapter-ait": "0.17.1",
|
|
85
|
+
"@mpgd/adapter-browser": "0.11.1",
|
|
86
|
+
"@mpgd/adapter-capacitor": "0.10.1",
|
|
87
|
+
"@mpgd/adapter-devvit": "0.12.1",
|
|
88
|
+
"@mpgd/adapter-verse8": "0.7.1",
|
|
89
|
+
"@mpgd/analytics": "0.4.10",
|
|
90
|
+
"@mpgd/capacitor-play-billing": "0.3.1",
|
|
91
|
+
"@mpgd/bridge": "0.13.1",
|
|
92
|
+
"@mpgd/capacitor-storekit": "0.3.1",
|
|
93
|
+
"@mpgd/game-runtime": "0.7.1",
|
|
94
|
+
"@mpgd/game-services": "0.24.1",
|
|
95
|
+
"@mpgd/i18n": "0.6.15"
|
|
92
96
|
},
|
|
93
97
|
"main": "./dist/index.js",
|
|
94
98
|
"types": "./dist/index.d.ts",
|
|
@@ -21,6 +21,11 @@ This is a Canvas2D game generated with the mpgd lite template.
|
|
|
21
21
|
|
|
22
22
|
## Tooling and checks
|
|
23
23
|
|
|
24
|
+
- Author game code, tests, and development tools in TypeScript (`.ts`). Include every tool in
|
|
25
|
+
`tsconfig.tools.json` and run it with `ttsx --project tsconfig.tools.json`.
|
|
26
|
+
- Use `.mjs`, `.cjs`, or authored `.js` only when a concrete consumer cannot run TypeScript
|
|
27
|
+
(for example a browser fixture or a JavaScript-only config loader). Document that constraint
|
|
28
|
+
beside the file. Convenience or avoiding type errors is not an exception.
|
|
24
29
|
- Use `ttsx`, `ttsc`, and `@ttsc/lint`; do not add `tsx`, ESLint, or Prettier.
|
|
25
30
|
- Run focused checks, then `pnpm check` and `pnpm build`.
|
|
26
31
|
- Run `pnpm accept` for a target handoff; preserve its generated evidence.
|
|
@@ -27,6 +27,11 @@ This repository is a standalone Phaser game generated with `@mpgd/create-game`.
|
|
|
27
27
|
|
|
28
28
|
## Tooling and checks
|
|
29
29
|
|
|
30
|
+
- Author game code, tests, and development tools in TypeScript (`.ts`). Include every tool in
|
|
31
|
+
`tsconfig.tools.json` and run it with `ttsx --project tsconfig.tools.json`.
|
|
32
|
+
- Use `.mjs`, `.cjs`, or authored `.js` only when a concrete consumer cannot run TypeScript
|
|
33
|
+
(for example a browser fixture or a JavaScript-only config loader). Document that constraint
|
|
34
|
+
beside the file. Convenience or avoiding type errors is not an exception.
|
|
30
35
|
- Use `ttsx`, not `tsx`, for TypeScript scripts.
|
|
31
36
|
- Use `ttsc` and `@ttsc/lint`; do not add ESLint or Prettier for TypeScript.
|
|
32
37
|
- Run focused checks, then `pnpm check` and `pnpm build`.
|
|
@@ -26,10 +26,10 @@
|
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@devvit/start": "0.14.1",
|
|
29
|
-
"@ttsc/unplugin": "0.
|
|
29
|
+
"@ttsc/unplugin": "0.32.1",
|
|
30
30
|
"@types/node": "^24.0.0",
|
|
31
31
|
"devvit": "0.14.1",
|
|
32
|
-
"ttsc": "0.
|
|
32
|
+
"ttsc": "0.32.1",
|
|
33
33
|
"typescript": "7.0.2",
|
|
34
34
|
"vite": "7.3.6"
|
|
35
35
|
}
|
|
@@ -2,6 +2,7 @@ import { resolve } from 'node:path';
|
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
|
|
4
4
|
import { devvit } from '@devvit/start/vite';
|
|
5
|
+
import { devvitOutputCollisionPlugin } from '@mpgd/cli/devvit';
|
|
5
6
|
import { defineConfig, type PluginOption } from 'vite';
|
|
6
7
|
|
|
7
8
|
import { createGameViteSharedConfig } from '../../vite.shared';
|
|
@@ -24,6 +25,7 @@ export default defineConfig(({ mode }) => {
|
|
|
24
25
|
publicDir: resolve(gameRoot, 'public'),
|
|
25
26
|
plugins: [
|
|
26
27
|
...(shared.plugins ?? []),
|
|
28
|
+
devvitOutputCollisionPlugin(),
|
|
27
29
|
devvit({
|
|
28
30
|
client: {
|
|
29
31
|
build: {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"dev": "__WORKSPACE_I18N_BUILD_PREFIX__vite --host 0.0.0.0",
|
|
9
9
|
"dev:ait": "APP_TARGET=ait MPGD_CONFIG_TARGET=ait MPGD_PLATFORM_TARGETS_FILE=./mpgd.targets.json APP_VERSION=0.0.0-dev BUILD_ID=ait-sandbox sh -c '__WORKSPACE_I18N_BUILD_PREFIX__vite --host 0.0.0.0'",
|
|
10
10
|
"build": "__WORKSPACE_I18N_BUILD_PREFIX__vite build",
|
|
11
|
-
"accept": "
|
|
11
|
+
"accept": "ttsx --project tsconfig.tools.json tools/run-game-acceptance.ts --",
|
|
12
12
|
"icons:generate": "pnpm exec mpgd game icons generate . --kit-path ${MPGD_KIT_PATH:-__DEFAULT_KIT_PATH__}",
|
|
13
13
|
"icons:generate:devvit": "pnpm exec mpgd game icons generate . --targets reddit --profile development --kit-path ${MPGD_KIT_PATH:-__DEFAULT_KIT_PATH__}",
|
|
14
14
|
"icons:generate:devvit:production": "pnpm exec mpgd game icons generate . --targets reddit --profile production --kit-path ${MPGD_KIT_PATH:-__DEFAULT_KIT_PATH__}",
|
|
@@ -49,10 +49,10 @@
|
|
|
49
49
|
"pages:dev": "pnpm legal:build && pnpm --dir apps/target-cloudflare-pages run dev",
|
|
50
50
|
"pages:preview": "pnpm legal:build && pnpm --dir apps/target-cloudflare-pages run preview",
|
|
51
51
|
"pages:types": "pnpm --dir apps/target-cloudflare-pages run types",
|
|
52
|
-
"check": "ttsc --noEmit -p tsconfig.json",
|
|
53
|
-
"lint": "ttsc --noEmit -p tsconfig.json",
|
|
54
|
-
"format": "ttsc format",
|
|
55
|
-
"fix": "ttsc fix"
|
|
52
|
+
"check": "ttsc --noEmit -p tsconfig.json && ttsc --noEmit -p tsconfig.tools.json",
|
|
53
|
+
"lint": "ttsc --noEmit -p tsconfig.json && ttsc --noEmit -p tsconfig.tools.json",
|
|
54
|
+
"format": "ttsc format -p tsconfig.json && ttsc format -p tsconfig.tools.json",
|
|
55
|
+
"fix": "ttsc fix -p tsconfig.json && ttsc fix -p tsconfig.tools.json"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
58
|
"@ait-co/polyfill": "^0.1.19",
|
|
@@ -81,9 +81,9 @@
|
|
|
81
81
|
"@ait-co/console-cli": "^0.1.49",
|
|
82
82
|
"@cloudflare/workers-types": "^5.20260707.1",
|
|
83
83
|
"@mpgd/cli": "__MPGD_DEPENDENCY_VERSION_CLI__",
|
|
84
|
-
"@ttsc/unplugin": "0.
|
|
84
|
+
"@ttsc/unplugin": "0.32.1",
|
|
85
85
|
"@types/node": "^24.0.0",
|
|
86
|
-
"ttsc": "0.
|
|
86
|
+
"ttsc": "0.32.1",
|
|
87
87
|
"typescript": "7.0.2",
|
|
88
88
|
"vite": "8.1.3",
|
|
89
89
|
"wrangler": "^4.107.0"
|
|
@@ -12,11 +12,4 @@ const kitPath = path.resolve(
|
|
|
12
12
|
: configuredKitPath,
|
|
13
13
|
);
|
|
14
14
|
|
|
15
|
-
await runMpgdCli([
|
|
16
|
-
'game',
|
|
17
|
-
'accept',
|
|
18
|
-
gameRoot,
|
|
19
|
-
'--kit-path',
|
|
20
|
-
kitPath,
|
|
21
|
-
...process.argv.slice(2),
|
|
22
|
-
]);
|
|
15
|
+
await runMpgdCli(['game', 'accept', gameRoot, '--kit-path', kitPath, ...process.argv.slice(2)]);
|