@mpgd/cli 0.47.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/command-launcher.d.ts +13 -0
- package/dist/command-launcher.js +24 -0
- package/dist/devvit-output-collisions.d.ts +49 -0
- package/dist/devvit-output-collisions.js +120 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +186 -10
- package/dist/microsoft-store-submission.js +11 -6
- package/dist/native-build-info.json +2 -2
- package/dist/native-build-target.js +2134 -351
- package/dist/tooling-migration.d.ts +24 -0
- package/dist/tooling-migration.js +497 -0
- package/dist/windows-host/Core/BridgeCommand.cs +17 -0
- package/dist/windows-host/Core/Mpgd.MicrosoftStore.Host.csproj +9 -0
- package/dist/windows-host/Core/NativeBridgeContracts.cs +33 -0
- package/dist/windows-host/Core/NativeBridgeHost.cs +162 -0
- package/dist/windows-host/Core/NativeBridgeOrigin.cs +19 -0
- package/dist/windows-host/Core/NativeBridgeProtocol.cs +335 -0
- package/dist/windows-host/Core/NativeBridgeRequestGate.cs +76 -0
- package/dist/windows-host/Core/NativeBridgeResult.cs +65 -0
- package/dist/windows-host/README.md +118 -0
- package/dist/windows-host/WinUI/DispatcherQueueBridgeDispatcher.cs +39 -0
- package/dist/windows-host/WinUI/WebView2DocumentTransport.cs +46 -0
- package/dist/windows-host/WinUI/WebView2NativeBridge.cs +185 -0
- package/dist/windows-host/WinUI/WindowStoreContext.cs +20 -0
- package/dist/windows-native-tools.d.ts +4 -0
- package/dist/windows-native-tools.js +21 -0
- package/native-tools/windows-msix/WindowsMsixAcceptance.psm1 +333 -0
- package/package.json +30 -18
- package/templates/lite-overlay/.agents/skills/use-mpgd-kit/SKILL.md +22 -0
- package/templates/lite-overlay/AGENTS.md +32 -0
- package/templates/lite-overlay/README.md +37 -0
- package/templates/lite-overlay/agent/acceptance.md +27 -0
- package/templates/lite-overlay/agent/brief.md +17 -0
- package/templates/lite-overlay/apps/target-devvit/README.md +134 -0
- package/templates/lite-overlay/docs/MPGD_KIT_WORKFLOWS.md +37 -0
- package/templates/lite-overlay/src/lite/tapGame.ts +77 -0
- package/templates/lite-overlay/src/platform/devvitEntrypoint.ts +116 -0
- package/templates/lite-overlay/src/platform/devvitInlineMode.css +146 -0
- package/templates/lite-overlay/src/runtime/createGame.ts +57 -0
- package/templates/phaser-game/AGENTS.md +5 -0
- package/templates/phaser-game/README.md +5 -3
- package/templates/phaser-game/apps/target-ait/package.json +4 -4
- package/templates/phaser-game/apps/target-ait/src/main.ts +5 -5
- 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/src/server/bridge.ts +137 -18
- package/templates/phaser-game/apps/target-devvit/vite.config.ts +2 -0
- package/templates/phaser-game/package.json +8 -8
- package/templates/phaser-game/pnpm-workspace.yaml +2 -0
- package/templates/phaser-game/src/env.d.ts +2 -0
- package/templates/phaser-game/src/platform/buildGateways/ait.ts +1 -1
- package/templates/phaser-game/src/platform/buildGateways/aitSandbox.ts +9 -4
- package/templates/phaser-game/src/platform/buildGateways/microsoftStore.ts +2 -5
- package/templates/phaser-game/src/platform/buildGateways/reddit.ts +1 -1
- package/templates/phaser-game/src/platform/buildGateways/verse8.ts +1 -2
- package/templates/phaser-game/src/platform/verse8Agent8Client.ts +5 -6
- package/templates/phaser-game/tools/{run-game-acceptance.mjs → run-game-acceptance.ts} +1 -8
- package/templates/phaser-game/tsconfig.tools.json +12 -0
- package/templates/phaser-game/vite.shared.ts +40 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
namespace Mpgd.MicrosoftStore.Host;
|
|
2
|
+
|
|
3
|
+
public abstract record BridgeCommand(string RequestId, string Method);
|
|
4
|
+
|
|
5
|
+
public sealed record GetCatalogDetailsCommand(
|
|
6
|
+
string RequestId,
|
|
7
|
+
IReadOnlyList<string> ItemIds)
|
|
8
|
+
: BridgeCommand(RequestId, NativeBridgeProtocol.CatalogGetDetailsMethod);
|
|
9
|
+
|
|
10
|
+
public sealed record SignInCommand(string RequestId)
|
|
11
|
+
: BridgeCommand(RequestId, NativeBridgeProtocol.IdentitySignInMethod);
|
|
12
|
+
|
|
13
|
+
public sealed record ListPurchasesCommand(string RequestId)
|
|
14
|
+
: BridgeCommand(RequestId, NativeBridgeProtocol.PurchaseListMethod);
|
|
15
|
+
|
|
16
|
+
public sealed record RequestPurchaseCommand(string RequestId, string ItemId)
|
|
17
|
+
: BridgeCommand(RequestId, NativeBridgeProtocol.PurchaseRequestMethod);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<Project Sdk="Microsoft.NET.Sdk">
|
|
2
|
+
<PropertyGroup>
|
|
3
|
+
<TargetFramework>net8.0</TargetFramework>
|
|
4
|
+
<ImplicitUsings>enable</ImplicitUsings>
|
|
5
|
+
<Nullable>enable</Nullable>
|
|
6
|
+
<LangVersion>12</LangVersion>
|
|
7
|
+
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
|
8
|
+
</PropertyGroup>
|
|
9
|
+
</Project>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
namespace Mpgd.MicrosoftStore.Host;
|
|
2
|
+
|
|
3
|
+
// Only these web-facing results are serialized. SDK credentials and backend
|
|
4
|
+
// collections tickets stay inside the game-owned implementation.
|
|
5
|
+
public sealed record NativeProductPrice(string CurrencyCode, string Formatted);
|
|
6
|
+
public sealed record NativeProductDetail(string ItemId, string Title, string? Description, NativeProductPrice Price);
|
|
7
|
+
public sealed record NativeProductDetailsResult(IReadOnlyList<NativeProductDetail> Items);
|
|
8
|
+
public sealed record NativePlayerSession(long ExpiresAt, string PlayerId, string SessionToken);
|
|
9
|
+
public sealed record NativePurchase(string ItemId, string PurchaseToken);
|
|
10
|
+
public sealed record NativePurchasesResult(IReadOnlyList<NativePurchase> Items);
|
|
11
|
+
public sealed record NativePurchaseResult(string Status, string? PurchaseToken = null);
|
|
12
|
+
|
|
13
|
+
public interface INativeStoreOperations
|
|
14
|
+
{
|
|
15
|
+
Task<NativeProductDetailsResult> GetDetailsAsync(GetCatalogDetailsCommand command, CancellationToken cancellation);
|
|
16
|
+
Task<NativePlayerSession> SignInAsync(CancellationToken cancellation);
|
|
17
|
+
Task<NativePurchasesResult> ListPurchasesAsync(CancellationToken cancellation);
|
|
18
|
+
Task<NativePurchaseResult> RequestPurchaseAsync(RequestPurchaseCommand command, CancellationToken cancellation);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// <summary>Run the callback on the owning window's UI thread. Cancellation must
|
|
22
|
+
/// prevent callbacks that are still queued from starting.</summary>
|
|
23
|
+
public interface INativeBridgeDispatcher
|
|
24
|
+
{
|
|
25
|
+
Task RunAsync(Func<Task> callback, CancellationToken cancellation);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public enum NativeBridgeFailure { UserCancelled, IdentityUnavailable, StoreUnavailable }
|
|
29
|
+
|
|
30
|
+
public sealed class NativeBridgeOperationException(NativeBridgeFailure failure) : Exception
|
|
31
|
+
{
|
|
32
|
+
public NativeBridgeFailure Failure { get; } = failure;
|
|
33
|
+
}
|