@mpgd/cli 0.48.0 → 0.50.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.
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { existsSync, linkSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
3
- import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
3
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
4
  import { deflateRawSync } from 'node:zlib';
5
5
  import { PHASER_PACK_DELIVERY_FORMAT, PHASER_PACK_DELIVERY_VERSION, phaserPackMediaTypeForPath, validatePhaserPackBuildConfig, validatePhaserPackDeliveryManifest, } from '@mpgd/phaser-assets/pack-format';
6
6
  import { createDeterministicZip } from './asset-pack-zip.js';
@@ -92,7 +92,7 @@ function readSourceFile(rootPath, entryPath) {
92
92
  }
93
93
  const relativeToRoot = relative(realpathSync(rootPath), realpathSync(current));
94
94
  // Exact '..' components only: names like '..dots' stay inside the root.
95
- if (relativeToRoot === '..' || relativeToRoot.startsWith('../') || isAbsolute(relativeToRoot)) {
95
+ if (relativeToRoot === '..' || relativeToRoot.startsWith(`..${sep}`) || isAbsolute(relativeToRoot)) {
96
96
  throw new Error(`Pack source path escapes the source root: ${entryPath}`);
97
97
  }
98
98
  const data = readFileSync(current);
@@ -185,7 +185,7 @@ export function buildAssetPacks(options) {
185
185
  if (isAbsolute(step)) {
186
186
  return false;
187
187
  }
188
- return step === '' || (step !== '..' && !step.startsWith('../'));
188
+ return step === '' || (step !== '..' && !step.startsWith(`..${sep}`));
189
189
  };
190
190
  if (overlaps(realOutPath, realRootPath) || overlaps(realRootPath, realOutPath)) {
191
191
  throw new Error(`Output directory must be outside the pack source root: ${outPath} vs ${rootPath}`);
@@ -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 upgrade --game <directory> [--apply]".');
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 = {}) {
@@ -2870,9 +2923,6 @@ function liteTemplateGameManifest(content) {
2870
2923
  function createGameApp(input) {
2871
2924
  const appDir = resolveAppDirectory(input.directory);
2872
2925
  const gameName = path.basename(appDir);
2873
- if (input.template === 'lite' && !input.workspace) {
2874
- throw new Error('The lite template requires --workspace until @mpgd/lite-shell is published.');
2875
- }
2876
2926
  assertValidGameName(gameName);
2877
2927
  const packageName = input.packageName ?? gameName;
2878
2928
  assertValidPackageName(packageName);
@@ -3042,6 +3092,9 @@ function createTemplateContext(input) {
3042
3092
  workspaceI18nBuildPrefix: input.workspace
3043
3093
  ? `pnpm --dir ${workspacePrefix} i18n:build && `
3044
3094
  : '',
3095
+ devvitOutputPluginImport: input.workspace
3096
+ ? toJavaScriptStringLiteral(`./${toTemplatePath(path.relative(path.join(input.appDir, 'apps/target-devvit'), path.resolve(kitPath ?? input.appDir, 'packages/cli/src/devvit-output-collisions')))}`)
3097
+ : "'@mpgd/cli/devvit'",
3045
3098
  defaultKitPath: kitPath === undefined ? defaultMpgdKitPath : workspacePrefix,
3046
3099
  recommendedMatrixTargets: input.microsoftStore
3047
3100
  ? recommendedMatrixTargets
@@ -3133,6 +3186,7 @@ function walkTemplateDir(templateDir, relativeDir, files) {
3133
3186
  }
3134
3187
  function renderTemplate(content, context) {
3135
3188
  let rendered = content
3189
+ .replaceAll("from '@mpgd/cli/devvit'", `from ${context.devvitOutputPluginImport}`)
3136
3190
  .replaceAll('__GAME_NAME__', context.gameName)
3137
3191
  .replaceAll('__DEVVIT_APP_NAME__', context.devvitAppName)
3138
3192
  .replaceAll('__GAME_TITLE_TS_LITERAL__', context.gameTitleTsLiteral)
@@ -1,5 +1,5 @@
1
1
  {
2
- "kitGitSha": "926ff936dc36d2112c042185974dc3c33102377d",
2
+ "kitGitSha": "188055521b51d40705be086439f123401d3d2a1d",
3
3
  "kitDirty": false,
4
- "packageVersion": "0.48.0"
4
+ "packageVersion": "0.50.0"
5
5
  }
@@ -16,7 +16,7 @@ import { createHash as createHash3 } from "node:crypto";
16
16
  import { createReadStream } from "node:fs";
17
17
  import { copyFile, lstat as lstat2, mkdir, mkdtemp, readdir, readFile, realpath as realpath2, rename, rm, writeFile } from "node:fs/promises";
18
18
  import { tmpdir } from "node:os";
19
- import { dirname as dirname2, isAbsolute as isAbsolute3, join as join3, relative as relative3, resolve as resolve3, sep as sep2 } from "node:path";
19
+ import { dirname as dirname2, isAbsolute as isAbsolute3, join as join3, relative as relative3, resolve as resolve3, sep as sep3 } from "node:path";
20
20
 
21
21
  // node_modules/.cache/mpgd-native-build/packages/phaser-assets/src/pack-format.js
22
22
  var PHASER_PACK_DELIVERY_FORMAT = "mpgd-asset-packs";
@@ -588,7 +588,7 @@ function resolveAssetPackLocations(policy, packs) {
588
588
  // node_modules/.cache/mpgd-native-build/packages/cli/src/asset-pack-build.js
589
589
  import { createHash } from "node:crypto";
590
590
  import { existsSync, linkSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
591
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
591
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
592
592
  import { deflateRawSync as deflateRawSync2 } from "node:zlib";
593
593
 
594
594
  // node_modules/.cache/mpgd-native-build/packages/cli/src/asset-pack-zip.js
@@ -749,7 +749,7 @@ function readSourceFile(rootPath, entryPath) {
749
749
  throw new Error(`Pack source path is not a regular file: ${entryPath}`);
750
750
  }
751
751
  const relativeToRoot = relative(realpathSync(rootPath), realpathSync(current));
752
- if (relativeToRoot === ".." || relativeToRoot.startsWith("../") || isAbsolute(relativeToRoot)) {
752
+ if (relativeToRoot === ".." || relativeToRoot.startsWith(`..${sep}`) || isAbsolute(relativeToRoot)) {
753
753
  throw new Error(`Pack source path escapes the source root: ${entryPath}`);
754
754
  }
755
755
  const data = readFileSync(current);
@@ -831,7 +831,7 @@ function buildAssetPacks(options) {
831
831
  if (isAbsolute(step)) {
832
832
  return false;
833
833
  }
834
- return step === "" || step !== ".." && !step.startsWith("../");
834
+ return step === "" || step !== ".." && !step.startsWith(`..${sep}`);
835
835
  };
836
836
  if (overlaps(realOutPath, realRootPath) || overlaps(realRootPath, realOutPath)) {
837
837
  throw new Error(`Output directory must be outside the pack source root: ${outPath} vs ${rootPath}`);
@@ -1032,7 +1032,7 @@ function buildAssetPacks(options) {
1032
1032
  import { createHash as createHash2 } from "node:crypto";
1033
1033
  import { constants } from "node:fs";
1034
1034
  import { lstat, open, opendir, realpath, stat } from "node:fs/promises";
1035
- import { isAbsolute as isAbsolute2, join as join2, relative as relative2, resolve as resolve2, sep } from "node:path";
1035
+ import { isAbsolute as isAbsolute2, join as join2, relative as relative2, resolve as resolve2, sep as sep2 } from "node:path";
1036
1036
 
1037
1037
  // node_modules/.cache/mpgd-native-build/packages/phaser-assets/src/archive-errors.js
1038
1038
  var ZipDecodeError = class extends Error {
@@ -1627,7 +1627,7 @@ var resolveArtifactPath = async (root, realRoot, artifactPath, deadline) => {
1627
1627
  throw new Error(`Artifact path is not a regular file: ${artifactPath}`);
1628
1628
  }
1629
1629
  const rel = relative2(realRoot, await deadline.race(realpath(current)));
1630
- if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute2(rel)) {
1630
+ if (rel === ".." || rel.startsWith(`..${sep2}`) || isAbsolute2(rel)) {
1631
1631
  throw new Error(`Artifact path escapes the root: ${artifactPath}`);
1632
1632
  }
1633
1633
  return { path: current, dev: stat2.dev, ino: stat2.ino };
@@ -2278,7 +2278,7 @@ async function fileDigest(path10) {
2278
2278
  }
2279
2279
  function inside(parent, child) {
2280
2280
  const step = relative3(parent, child);
2281
- return step === "" || !isAbsolute3(step) && step !== ".." && !step.startsWith(`..${sep2}`);
2281
+ return step === "" || !isAbsolute3(step) && step !== ".." && !step.startsWith(`..${sep3}`);
2282
2282
  }
2283
2283
  async function noSymlink(path10) {
2284
2284
  let current = resolve3(path10);
@@ -3646,7 +3646,7 @@ import sharp3 from "sharp";
3646
3646
 
3647
3647
  // node_modules/.cache/mpgd-native-build/tools/target/web-artifact.js
3648
3648
  import { cpSync, existsSync as existsSync5, lstatSync as lstatSync3, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync6, realpathSync as realpathSync5, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "node:fs";
3649
- import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute5, join as join6, relative as relative7, resolve as resolve7, sep as sep3 } from "node:path";
3649
+ import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute5, join as join6, relative as relative7, resolve as resolve7, sep as sep4 } from "node:path";
3650
3650
  var installableManifestNames = /* @__PURE__ */ new Set(["manifest.json", "manifest.webmanifest"]);
3651
3651
  var generatedWebArtifactEvidenceNames = [
3652
3652
  "mpgd-effective-target.json",
@@ -4197,7 +4197,7 @@ function assertExistingWebOutputsAreGeneratedArtifacts(outputs) {
4197
4197
  }
4198
4198
  function isPathWithin(root, candidate) {
4199
4199
  const path10 = relative7(root, candidate);
4200
- return path10 === "" || path10 !== ".." && !path10.startsWith(`..${sep3}`) && !isAbsolute5(path10);
4200
+ return path10 === "" || path10 !== ".." && !path10.startsWith(`..${sep4}`) && !isAbsolute5(path10);
4201
4201
  }
4202
4202
  function pathsOverlap(first, second) {
4203
4203
  return isPathWithin(first, second) || isPathWithin(second, first);
@@ -7039,7 +7039,7 @@ function isValidHostname(hostname) {
7039
7039
 
7040
7040
  // node_modules/.cache/mpgd-native-build/tools/target/minigame-package-budget.js
7041
7041
  import { lstatSync as lstatSync6, readdirSync as readdirSync3 } from "node:fs";
7042
- import { isAbsolute as isAbsolute6, relative as relative8, resolve as resolve10, sep as sep4 } from "node:path";
7042
+ import { isAbsolute as isAbsolute6, relative as relative8, resolve as resolve10, sep as sep5 } from "node:path";
7043
7043
  var miniGameMainPackageLimitBytes = 4 * 1024 * 1024;
7044
7044
  function assertMiniGamePackageBudget(input) {
7045
7045
  const artifactRoot = resolve10(input.artifactRoot);
@@ -7186,7 +7186,7 @@ function assertSubpackageRoots(subpackages) {
7186
7186
  function resolveArtifactPath2(root, path10) {
7187
7187
  const resolved = resolve10(root, path10);
7188
7188
  const relativePath = relative8(root, resolved);
7189
- if (relativePath.length === 0 || relativePath === ".." || relativePath.startsWith(`..${sep4}`) || isAbsolute6(relativePath)) {
7189
+ if (relativePath.length === 0 || relativePath === ".." || relativePath.startsWith(`..${sep5}`) || isAbsolute6(relativePath)) {
7190
7190
  throw new Error(`Mini-game path escapes its artifact root: ${path10}`);
7191
7191
  }
7192
7192
  try {
@@ -10371,7 +10371,7 @@ function requireNonEmptyString(input, label) {
10371
10371
  // node_modules/.cache/mpgd-native-build/packages/cli/src/microsoft-store-pwa-release.js
10372
10372
  import { createHash as createHash9 } from "node:crypto";
10373
10373
  import { closeSync as closeSync2, existsSync as existsSync11, openSync as openSync2, readdirSync as readdirSync4, readFileSync as readFileSync15, readSync, statSync as statSync2 } from "node:fs";
10374
- import { relative as relative10, resolve as resolve13, sep as sep5 } from "node:path";
10374
+ import { relative as relative10, resolve as resolve13, sep as sep6 } from "node:path";
10375
10375
  function assertMicrosoftStorePwaProvenance(input) {
10376
10376
  return {
10377
10377
  appVersion: requireNonEmptyString2(input.appVersion, "PWA app version"),
@@ -10553,7 +10553,7 @@ function listPrecacheEntries(artifactRoot) {
10553
10553
  }
10554
10554
  }
10555
10555
  }
10556
- return files.filter((path10) => !path10.endsWith(".map") && !path10.endsWith(`${sep5}pwa-release.json`) && !path10.endsWith(`${sep5}service-worker.js`)).map((path10) => ({
10556
+ return files.filter((path10) => !path10.endsWith(".map") && !path10.endsWith(`${sep6}pwa-release.json`) && !path10.endsWith(`${sep6}service-worker.js`)).map((path10) => ({
10557
10557
  url: toPrecacheUrl(artifactRoot, path10),
10558
10558
  source: readFileSync15(path10)
10559
10559
  })).sort((left, right) => compareCodeUnits(left.url, right.url));
@@ -10580,7 +10580,7 @@ function normalizePrecacheUrls(urls) {
10580
10580
  return normalized.sort(compareCodeUnits);
10581
10581
  }
10582
10582
  function toPrecacheUrl(root, path10) {
10583
- const portablePath = relative10(root, path10).split(sep5).join("/");
10583
+ const portablePath = relative10(root, path10).split(sep6).join("/");
10584
10584
  return assertMicrosoftStorePwaPrecacheUrl(`./${portablePath}`);
10585
10585
  }
10586
10586
  function requireGameSpecificPwaId(value) {
@@ -10657,7 +10657,7 @@ function readPwaId(input) {
10657
10657
  // node_modules/.cache/mpgd-native-build/tools/target/minigame-artifact.js
10658
10658
  import { createHash as createHash10 } from "node:crypto";
10659
10659
  import { copyFileSync as copyFileSync2, cpSync as cpSync2, existsSync as existsSync12, constants as fsConstants, lstatSync as lstatSync8, mkdirSync as mkdirSync7, mkdtempSync, readdirSync as readdirSync5, readFileSync as readFileSync16, realpathSync as realpathSync7, renameSync as renameSync3, rmSync as rmSync6, writeFileSync as writeFileSync10 } from "node:fs";
10660
- import { basename as basename4, dirname as dirname8, extname as extname2, isAbsolute as isAbsolute9, join as join11, relative as relative11, resolve as resolve15, sep as sep6 } from "node:path";
10660
+ import { basename as basename4, dirname as dirname8, extname as extname2, isAbsolute as isAbsolute9, join as join11, relative as relative11, resolve as resolve15, sep as sep7 } from "node:path";
10661
10661
  import { parse } from "acorn";
10662
10662
  var miniGameArtifactEvidenceFileName = "mpgd-minigame-artifact.json";
10663
10663
  var miniGameEffectiveTargetConfigFileName = "mpgd-effective-target-config.json";
@@ -10753,9 +10753,9 @@ function assertMiniGameArtifactOutputDirectory(artifactRoot, projectRoot) {
10753
10753
  const project = resolve15(projectRoot);
10754
10754
  const artifact = resolve15(artifactRoot);
10755
10755
  const projectRelativePath = relative11(project, artifact);
10756
- const portableProjectRelativePath = projectRelativePath.replaceAll(sep6, "/");
10756
+ const portableProjectRelativePath = projectRelativePath.replaceAll(sep7, "/");
10757
10757
  const segments = portableProjectRelativePath.split("/");
10758
- if (projectRelativePath.length === 0 || projectRelativePath === ".." || projectRelativePath.startsWith(`..${sep6}`) || sep6 === "/" && projectRelativePath.includes("\\") || isAbsolute9(projectRelativePath) || segments[0] !== "artifacts" || segments.length < 2) {
10758
+ if (projectRelativePath.length === 0 || projectRelativePath === ".." || projectRelativePath.startsWith(`..${sep7}`) || sep7 === "/" && projectRelativePath.includes("\\") || isAbsolute9(projectRelativePath) || segments[0] !== "artifacts" || segments.length < 2) {
10759
10759
  throw new Error(`Mini-game artifact output must be a dedicated artifacts/ child: ${artifactRoot}`);
10760
10760
  }
10761
10761
  assertMiniGameArtifactRelativePath(portableProjectRelativePath, "Mini-game artifact output");
@@ -10982,7 +10982,7 @@ function pathsOverlap2(first, second) {
10982
10982
  }
10983
10983
  function isPathWithin2(root, candidate) {
10984
10984
  const path10 = relative11(root, candidate);
10985
- return path10 === "" || path10 !== ".." && !path10.startsWith(`..${sep6}`) && !isAbsolute9(path10);
10985
+ return path10 === "" || path10 !== ".." && !path10.startsWith(`..${sep7}`) && !isAbsolute9(path10);
10986
10986
  }
10987
10987
  function portablePathComparisonKey(path10) {
10988
10988
  const portablePath = path10.replaceAll("\\", "/").split("/").map((segment) => {
@@ -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("pnpm", ["--dir", wrapperApp, "exec", "vite", "build", "--mode", profile], env);
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.48.0",
3
+ "version": "0.50.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,30 @@
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.0",
70
- "@mpgd/phaser-assets": "0.9.0",
71
- "@mpgd/platform": "0.23.0",
72
- "@mpgd/target-config": "0.23.0"
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.30.4",
82
+ "ttsc": "0.32.1",
79
83
  "typescript": "7.0.2",
80
- "@mpgd/adapter-ait": "0.17.0",
81
- "@mpgd/adapter-browser": "0.11.0",
82
- "@mpgd/adapter-capacitor": "0.10.0",
83
- "@mpgd/adapter-devvit": "0.12.0",
84
- "@mpgd/analytics": "0.4.9",
85
- "@mpgd/adapter-verse8": "0.7.0",
86
- "@mpgd/capacitor-play-billing": "0.3.0",
87
- "@mpgd/bridge": "0.13.0",
88
- "@mpgd/capacitor-storekit": "0.3.0",
89
- "@mpgd/game-services": "0.24.0",
90
- "@mpgd/game-runtime": "0.7.0",
91
- "@mpgd/i18n": "0.6.14"
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/capacitor-storekit": "0.3.1",
92
+ "@mpgd/bridge": "0.13.1",
93
+ "@mpgd/game-services": "0.24.1",
94
+ "@mpgd/game-runtime": "0.7.1",
95
+ "@mpgd/lite-shell": "0.1.0",
96
+ "@mpgd/i18n": "0.6.15"
92
97
  },
93
98
  "main": "./dist/index.js",
94
99
  "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.
@@ -1,6 +1,6 @@
1
1
  # __GAME_TITLE__
2
2
 
3
- Standalone Canvas2D lite game generated by `@mpgd/create-game --template lite --workspace`.
3
+ Canvas2D lite game generated by `@mpgd/create-game --template lite`.
4
4
  It uses `@mpgd/lite-shell`, `PlatformGateway`, and the shared mpgd game runtime.
5
5
 
6
6
  ## Start
@@ -13,7 +13,7 @@ pnpm build
13
13
  pnpm accept
14
14
  ```
15
15
 
16
- The lite-shell package is private until its initial npm registration. Create lite games with `--workspace` and a local kit checkout.
16
+ Standalone games install the public `@mpgd/lite-shell` package. Add `--workspace --kit-path <mpgd-kit>` to generate a game linked to a local Kit checkout.
17
17
  The CLI supports the canvas renderer; DOM and SVG renderers are planned.
18
18
 
19
19
  ## Ownership
@@ -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`.
@@ -21,9 +21,9 @@
21
21
  },
22
22
  "devDependencies": {
23
23
  "@apps-in-toss/cli": "3.6.0",
24
- "@ttsc/unplugin": "0.30.4",
24
+ "@ttsc/unplugin": "0.32.1",
25
25
  "@types/node": "^24.0.0",
26
- "ttsc": "0.30.4",
26
+ "ttsc": "0.32.1",
27
27
  "typescript": "7.0.2",
28
28
  "vite": "8.1.3"
29
29
  }
@@ -19,8 +19,8 @@
19
19
  },
20
20
  "devDependencies": {
21
21
  "@cloudflare/workers-types": "^5.20260707.1",
22
- "@ttsc/unplugin": "0.30.4",
23
- "ttsc": "0.30.4",
22
+ "@ttsc/unplugin": "0.32.1",
23
+ "ttsc": "0.32.1",
24
24
  "typescript": "7.0.2",
25
25
  "vite": "8.1.3",
26
26
  "wrangler": "^4.107.0"
@@ -26,10 +26,10 @@
26
26
  },
27
27
  "devDependencies": {
28
28
  "@devvit/start": "0.14.1",
29
- "@ttsc/unplugin": "0.30.4",
29
+ "@ttsc/unplugin": "0.32.1",
30
30
  "@types/node": "^24.0.0",
31
31
  "devvit": "0.14.1",
32
- "ttsc": "0.30.4",
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": "node ./tools/run-game-acceptance.mjs",
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.30.4",
84
+ "@ttsc/unplugin": "0.32.1",
85
85
  "@types/node": "^24.0.0",
86
- "ttsc": "0.30.4",
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)]);
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "types": [
5
+ "node"
6
+ ],
7
+ "noEmit": true
8
+ },
9
+ "include": [
10
+ "tools/**/*.ts"
11
+ ]
12
+ }