@mpgd/cli 0.14.0 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +137 -8
- package/dist/microsoft-store-starter.d.ts +28 -0
- package/dist/microsoft-store-starter.js +655 -0
- package/dist/production-target-readiness.js +3 -1
- package/package.json +8 -8
- package/templates/phaser-game/.agents/skills/release-microsoft-store/SKILL.md +60 -0
- package/templates/phaser-game/.agents/skills/release-microsoft-store/agents/openai.yaml +4 -0
- package/templates/phaser-game/.agents/skills/use-mpgd-kit/SKILL.md +45 -0
- package/templates/phaser-game/.agents/skills/use-mpgd-kit/agents/openai.yaml +4 -0
- package/templates/phaser-game/AGENTS.md +34 -0
- package/templates/phaser-game/README.md +50 -25
- package/templates/phaser-game/agent/acceptance.md +8 -0
- package/templates/phaser-game/agent/brief.md +13 -1
- package/templates/phaser-game/agent/game-manifest.json +7 -0
- package/templates/phaser-game/apps/target-ait/granite.config.ts +35 -0
- package/templates/phaser-game/apps/target-ait/index.html +15 -0
- package/templates/phaser-game/apps/target-ait/package.json +33 -0
- package/templates/phaser-game/apps/target-ait/src/main.ts +34 -0
- package/templates/phaser-game/apps/target-ait/src/styles.css +33 -0
- package/templates/phaser-game/apps/target-ait/tsconfig.json +8 -0
- package/templates/phaser-game/apps/target-ait/vite.config.ts +53 -0
- package/templates/phaser-game/docs/MPGD_KIT_WORKFLOWS.md +128 -0
- package/templates/phaser-game/gitignore +6 -0
- package/templates/phaser-game/mpgd.targets.json +9 -2
- package/templates/phaser-game/package.json +10 -10
- package/templates/phaser-game/vite.shared.ts +13 -0
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmdirSync, rmSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
export const microsoftStoreBlockStart = '<!-- mpgd:microsoft-store:start -->';
|
|
5
|
+
export const microsoftStoreBlockEnd = '<!-- mpgd:microsoft-store:end -->';
|
|
6
|
+
export const defaultMpgdKitPath = '../mpgd-kit';
|
|
7
|
+
export const microsoftStoreDocumentationAnchors = {
|
|
8
|
+
'README.md': '- Verse8 is a game-owned iframe web target',
|
|
9
|
+
'agent/acceptance.md': '## Verse8 Agent8 Structured Server',
|
|
10
|
+
'agent/brief.md': undefined,
|
|
11
|
+
};
|
|
12
|
+
const microsoftStoreBootstrap = {
|
|
13
|
+
importAnchor: "import { installPlatform } from './platform/installPlatform';",
|
|
14
|
+
importLine: "import { installMicrosoftStorePwa } from './platform/microsoftStorePwa';",
|
|
15
|
+
localAnchor: " let locale: Locale = 'en';",
|
|
16
|
+
localLine: ' let disposeMicrosoftStorePwa: (() => void) | undefined;',
|
|
17
|
+
runtimeAnchor: ' const runtimeConfig = detectRuntime();',
|
|
18
|
+
runtimeLine: ' disposeMicrosoftStorePwa = installMicrosoftStorePwa(runtimeConfig);',
|
|
19
|
+
catchAnchor: ' renderBootstrapError(error, locale);',
|
|
20
|
+
catchLine: ' disposeMicrosoftStorePwa?.();',
|
|
21
|
+
};
|
|
22
|
+
const microsoftStoreOnlyTemplateFiles = new Set([
|
|
23
|
+
'.agents/skills/release-microsoft-store/SKILL.md',
|
|
24
|
+
'.agents/skills/release-microsoft-store/agents/openai.yaml',
|
|
25
|
+
'mpgd.microsoft-store.json',
|
|
26
|
+
'src/platform/microsoftStorePwa.ts',
|
|
27
|
+
]);
|
|
28
|
+
const microsoftStoreManagedTemplateFiles = new Set([
|
|
29
|
+
'.agents/skills/release-microsoft-store/SKILL.md',
|
|
30
|
+
'.agents/skills/release-microsoft-store/agents/openai.yaml',
|
|
31
|
+
'src/platform/microsoftStorePwa.ts',
|
|
32
|
+
]);
|
|
33
|
+
const microsoftStoreTarget = {
|
|
34
|
+
kind: 'web',
|
|
35
|
+
gameApp: '.',
|
|
36
|
+
adapter: 'browser',
|
|
37
|
+
icon: { profile: 'microsoft-pwa' },
|
|
38
|
+
output: 'artifacts/microsoft-store',
|
|
39
|
+
};
|
|
40
|
+
export function prepareBaseGameTemplateFile(input) {
|
|
41
|
+
const relativePath = toTemplatePath(input.relativePath);
|
|
42
|
+
if (microsoftStoreOnlyTemplateFiles.has(relativePath)) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
const withoutBlocks = removeMicrosoftStoreBlocks(input.content);
|
|
46
|
+
switch (relativePath) {
|
|
47
|
+
case 'package.json':
|
|
48
|
+
return updateJson(withoutBlocks, (value) => {
|
|
49
|
+
const scripts = requireJsonObject(value.scripts, 'template package.json scripts');
|
|
50
|
+
for (const script of Object.keys(microsoftStoreScripts(defaultMpgdKitPath))) {
|
|
51
|
+
delete scripts[script];
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
case 'mpgd.targets.json':
|
|
55
|
+
return updateJson(withoutBlocks, (value) => {
|
|
56
|
+
const targets = requireJsonObject(value.targets, 'template mpgd.targets.json targets');
|
|
57
|
+
delete targets['microsoft-store'];
|
|
58
|
+
});
|
|
59
|
+
case 'agent/game-manifest.json':
|
|
60
|
+
return updateJson(withoutBlocks, (value) => {
|
|
61
|
+
if (!Array.isArray(value.targets)) {
|
|
62
|
+
throw new Error('Template agent/game-manifest.json targets must be an array.');
|
|
63
|
+
}
|
|
64
|
+
value.targets = value.targets.filter((target) => target !== 'microsoft-store');
|
|
65
|
+
const workflow = requireJsonObject(value.agentWorkflow, 'template agent/game-manifest.json agentWorkflow');
|
|
66
|
+
const targetSkills = requireJsonObject(workflow.targetSkills, 'template agent/game-manifest.json agentWorkflow.targetSkills');
|
|
67
|
+
delete targetSkills['microsoft-store'];
|
|
68
|
+
});
|
|
69
|
+
case 'src/main.ts':
|
|
70
|
+
return removeMicrosoftStoreBootstrap(withoutBlocks);
|
|
71
|
+
default:
|
|
72
|
+
return withoutBlocks;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export function listMicrosoftStoreInitializerTemplateFiles() {
|
|
76
|
+
return [...microsoftStoreOnlyTemplateFiles].sort();
|
|
77
|
+
}
|
|
78
|
+
export function initializeMicrosoftStoreStarter(input, runtime = {}) {
|
|
79
|
+
const gameRoot = resolveGameRoot(input.gameRoot);
|
|
80
|
+
const templateRoot = realpathSync(input.templateRoot);
|
|
81
|
+
const writes = [];
|
|
82
|
+
const plan = (relativePath, content) => {
|
|
83
|
+
const file = resolveGameFile(gameRoot, relativePath);
|
|
84
|
+
const previous = readOptionalRegularFile(gameRoot, file, relativePath);
|
|
85
|
+
if (previous === content) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
writes.push({
|
|
89
|
+
file,
|
|
90
|
+
relativePath,
|
|
91
|
+
previous,
|
|
92
|
+
content,
|
|
93
|
+
mode: previous === undefined ? 0o644 : readRegularFileMode(file, relativePath),
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
const packageFile = resolveGameFile(gameRoot, 'package.json');
|
|
97
|
+
const packageSource = readRequiredRegularFile(gameRoot, packageFile, 'package.json');
|
|
98
|
+
const packageJson = parseJsonObject(packageSource, 'package.json');
|
|
99
|
+
const scripts = requireJsonObject(packageJson.scripts, 'package.json scripts');
|
|
100
|
+
const legacyScripts = legacyMicrosoftStoreScripts(input.defaultKitPath);
|
|
101
|
+
for (const [name, command] of Object.entries(microsoftStoreScripts(input.defaultKitPath))) {
|
|
102
|
+
const existing = scripts[name];
|
|
103
|
+
const legacyCommand = legacyScripts[name];
|
|
104
|
+
if (existing !== undefined && existing !== command && existing !== legacyCommand) {
|
|
105
|
+
throw new Error(`package.json script ${name} already exists with a different command.`);
|
|
106
|
+
}
|
|
107
|
+
scripts[name] = command;
|
|
108
|
+
}
|
|
109
|
+
plan('package.json', formatJson(packageJson));
|
|
110
|
+
const targetsFile = resolveGameFile(gameRoot, 'mpgd.targets.json');
|
|
111
|
+
const targetsSource = readRequiredRegularFile(gameRoot, targetsFile, 'mpgd.targets.json');
|
|
112
|
+
const targetsJson = parseJsonObject(targetsSource, 'mpgd.targets.json');
|
|
113
|
+
const targets = requireJsonObject(targetsJson.targets, 'mpgd.targets.json targets');
|
|
114
|
+
const existingTarget = targets['microsoft-store'];
|
|
115
|
+
if (existingTarget === undefined) {
|
|
116
|
+
targets['microsoft-store'] = microsoftStoreTarget;
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
assertCompatibleMicrosoftStoreTarget(existingTarget);
|
|
120
|
+
}
|
|
121
|
+
plan('mpgd.targets.json', formatJson(targetsJson));
|
|
122
|
+
const mainFile = resolveGameFile(gameRoot, 'src/main.ts');
|
|
123
|
+
const mainSource = readRequiredRegularFile(gameRoot, mainFile, 'src/main.ts');
|
|
124
|
+
plan('src/main.ts', addMicrosoftStoreBootstrap(mainSource));
|
|
125
|
+
planGitignore(gameRoot, plan);
|
|
126
|
+
planAgentManifest(gameRoot, plan);
|
|
127
|
+
planManagedDocumentation(gameRoot, templateRoot, plan);
|
|
128
|
+
for (const relativePath of [
|
|
129
|
+
'AGENTS.md',
|
|
130
|
+
'.agents/skills/use-mpgd-kit/SKILL.md',
|
|
131
|
+
'.agents/skills/use-mpgd-kit/agents/openai.yaml',
|
|
132
|
+
'.agents/skills/release-microsoft-store/SKILL.md',
|
|
133
|
+
'.agents/skills/release-microsoft-store/agents/openai.yaml',
|
|
134
|
+
'docs/MPGD_KIT_WORKFLOWS.md',
|
|
135
|
+
'mpgd.microsoft-store.json',
|
|
136
|
+
'src/platform/microsoftStorePwa.ts',
|
|
137
|
+
]) {
|
|
138
|
+
const destination = resolveGameFile(gameRoot, relativePath);
|
|
139
|
+
if (existsSync(destination)) {
|
|
140
|
+
readRequiredRegularFile(gameRoot, destination, relativePath);
|
|
141
|
+
if (microsoftStoreManagedTemplateFiles.has(relativePath)) {
|
|
142
|
+
plan(relativePath, readRequiredTemplateFile(templateRoot, relativePath));
|
|
143
|
+
}
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
plan(relativePath, readRequiredTemplateFile(templateRoot, relativePath));
|
|
147
|
+
}
|
|
148
|
+
if (!input.dryRun) {
|
|
149
|
+
applyPlannedWrites(gameRoot, writes, runtime);
|
|
150
|
+
}
|
|
151
|
+
return { changedFiles: writes.map((write) => write.relativePath).sort() };
|
|
152
|
+
}
|
|
153
|
+
function microsoftStoreScripts(defaultKitPath) {
|
|
154
|
+
const safeKitPath = requireSafeShellParameterDefaultPath(defaultKitPath);
|
|
155
|
+
const kitPath = `"\${MPGD_KIT_PATH:-${safeKitPath}}"`;
|
|
156
|
+
return createMicrosoftStoreScripts(kitPath);
|
|
157
|
+
}
|
|
158
|
+
function legacyMicrosoftStoreScripts(defaultKitPath) {
|
|
159
|
+
// The previous generated format intentionally omitted shell quotes around the kit path.
|
|
160
|
+
// Keep this variant only for detecting and migrating those exact managed script values.
|
|
161
|
+
const safeKitPath = requireSafeShellParameterDefaultPath(defaultKitPath);
|
|
162
|
+
const kitPath = `\${MPGD_KIT_PATH:-${safeKitPath}}`;
|
|
163
|
+
return createMicrosoftStoreScripts(kitPath);
|
|
164
|
+
}
|
|
165
|
+
function createMicrosoftStoreScripts(kitPath) {
|
|
166
|
+
return {
|
|
167
|
+
'build:microsoft-store': `pnpm exec mpgd target build microsoft-store production --targets-file ./mpgd.targets.json --kit-path ${kitPath}`,
|
|
168
|
+
'smoke:microsoft-store': `pnpm exec mpgd target smoke microsoft-store --targets-file ./mpgd.targets.json --kit-path ${kitPath}`,
|
|
169
|
+
'preflight:microsoft-store': `pnpm exec mpgd target preflight microsoft-store --targets-file ./mpgd.targets.json --kit-path ${kitPath}`,
|
|
170
|
+
'package:microsoft-store': 'pnpm exec mpgd target generate-package microsoft-store --targets-file ./mpgd.targets.json',
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function requireSafeShellParameterDefaultPath(value) {
|
|
174
|
+
const normalized = toTemplatePath(value.trim());
|
|
175
|
+
if (!/^[\p{L}\p{N} ._/:@+(),-]+$/u.test(normalized)) {
|
|
176
|
+
throw new Error('The default kit path contains characters that are unsafe in a shell parameter default.');
|
|
177
|
+
}
|
|
178
|
+
return normalized;
|
|
179
|
+
}
|
|
180
|
+
function assertCompatibleMicrosoftStoreTarget(value) {
|
|
181
|
+
const target = requireJsonObject(value, 'microsoft-store target');
|
|
182
|
+
const icon = isJsonObject(target.icon) ? target.icon : undefined;
|
|
183
|
+
if (target.kind !== 'web'
|
|
184
|
+
|| target.adapter !== 'browser'
|
|
185
|
+
|| target.gameApp !== microsoftStoreTarget.gameApp
|
|
186
|
+
|| target.output !== microsoftStoreTarget.output
|
|
187
|
+
|| icon?.profile !== microsoftStoreTarget.icon.profile) {
|
|
188
|
+
throw new Error('Existing microsoft-store target must use the game root, Store artifact directory, browser adapter, and microsoft-pwa icon profile.');
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function addMicrosoftStoreBootstrap(source) {
|
|
192
|
+
let output = source;
|
|
193
|
+
if (!output.includes(microsoftStoreBootstrap.importLine)) {
|
|
194
|
+
output = insertAfter(output, microsoftStoreBootstrap.importAnchor, microsoftStoreBootstrap.importLine, 'src/main.ts platform import');
|
|
195
|
+
}
|
|
196
|
+
if (!output.includes(microsoftStoreBootstrap.localLine.trim())) {
|
|
197
|
+
output = insertAfter(output, microsoftStoreBootstrap.localAnchor, microsoftStoreBootstrap.localLine, 'src/main.ts bootstrap locals');
|
|
198
|
+
}
|
|
199
|
+
if (!output.includes(microsoftStoreBootstrap.runtimeLine.trim())) {
|
|
200
|
+
output = insertAfter(output, microsoftStoreBootstrap.runtimeAnchor, microsoftStoreBootstrap.runtimeLine, 'src/main.ts runtime config');
|
|
201
|
+
}
|
|
202
|
+
if (!output.includes(microsoftStoreBootstrap.catchLine)) {
|
|
203
|
+
output = insertBefore(output, microsoftStoreBootstrap.catchAnchor, microsoftStoreBootstrap.catchLine, 'src/main.ts bootstrap catch');
|
|
204
|
+
}
|
|
205
|
+
return output;
|
|
206
|
+
}
|
|
207
|
+
function removeMicrosoftStoreBootstrap(source) {
|
|
208
|
+
const lines = [
|
|
209
|
+
microsoftStoreBootstrap.importLine,
|
|
210
|
+
microsoftStoreBootstrap.localLine,
|
|
211
|
+
microsoftStoreBootstrap.runtimeLine,
|
|
212
|
+
microsoftStoreBootstrap.catchLine,
|
|
213
|
+
];
|
|
214
|
+
let output = source;
|
|
215
|
+
for (const line of lines) {
|
|
216
|
+
const occurrences = output.split(`${line}\n`).length - 1;
|
|
217
|
+
if (occurrences !== 1) {
|
|
218
|
+
throw new Error(`Template src/main.ts must contain exactly one line: ${line}`);
|
|
219
|
+
}
|
|
220
|
+
output = output.replace(`${line}\n`, '');
|
|
221
|
+
}
|
|
222
|
+
return output;
|
|
223
|
+
}
|
|
224
|
+
function insertAfter(source, anchor, line, label) {
|
|
225
|
+
const occurrences = source.split(anchor).length - 1;
|
|
226
|
+
if (occurrences !== 1) {
|
|
227
|
+
throw new Error(`${label} must contain exactly one canonical insertion anchor.`);
|
|
228
|
+
}
|
|
229
|
+
return source.replace(anchor, `${anchor}\n${line}`);
|
|
230
|
+
}
|
|
231
|
+
function insertBefore(source, anchor, line, label) {
|
|
232
|
+
const occurrences = source.split(anchor).length - 1;
|
|
233
|
+
if (occurrences !== 1) {
|
|
234
|
+
throw new Error(`${label} must contain exactly one canonical insertion anchor.`);
|
|
235
|
+
}
|
|
236
|
+
return source.replace(anchor, `${line}\n${anchor}`);
|
|
237
|
+
}
|
|
238
|
+
function planGitignore(gameRoot, plan) {
|
|
239
|
+
const relativePath = '.gitignore';
|
|
240
|
+
const file = resolveGameFile(gameRoot, relativePath);
|
|
241
|
+
const source = existsSync(file) ? readRequiredRegularFile(gameRoot, file, relativePath) : '';
|
|
242
|
+
const lines = source
|
|
243
|
+
.split(/\r?\n/u)
|
|
244
|
+
.filter((line, index, entries) => line.length > 0 || index < entries.length - 1);
|
|
245
|
+
for (const required of ['release-input/', 'release-output/']) {
|
|
246
|
+
if (!lines.includes(required)) {
|
|
247
|
+
lines.push(required);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
plan(relativePath, `${lines.join('\n')}\n`);
|
|
251
|
+
}
|
|
252
|
+
function planAgentManifest(gameRoot, plan) {
|
|
253
|
+
const relativePath = 'agent/game-manifest.json';
|
|
254
|
+
const file = resolveGameFile(gameRoot, relativePath);
|
|
255
|
+
if (!existsSync(file)) {
|
|
256
|
+
console.warn(`Warning: ${relativePath} was not found; the Microsoft Store agent workflow was not registered.`);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const manifest = parseJsonObject(readRequiredRegularFile(gameRoot, file, relativePath), relativePath);
|
|
260
|
+
if (!Array.isArray(manifest.targets)) {
|
|
261
|
+
throw new Error(`${relativePath} targets must be an array.`);
|
|
262
|
+
}
|
|
263
|
+
if (!manifest.targets.includes('microsoft-store')) {
|
|
264
|
+
const webIndex = manifest.targets.indexOf('web-preview');
|
|
265
|
+
const insertAt = webIndex < 0 ? manifest.targets.length : webIndex + 1;
|
|
266
|
+
manifest.targets.splice(insertAt, 0, 'microsoft-store');
|
|
267
|
+
}
|
|
268
|
+
const workflow = manifest.agentWorkflow === undefined
|
|
269
|
+
? {}
|
|
270
|
+
: requireJsonObject(manifest.agentWorkflow, `${relativePath} agentWorkflow`);
|
|
271
|
+
manifest.agentWorkflow = workflow;
|
|
272
|
+
workflow.guide ??= 'docs/MPGD_KIT_WORKFLOWS.md';
|
|
273
|
+
workflow.routerSkill ??= '.agents/skills/use-mpgd-kit/SKILL.md';
|
|
274
|
+
const targetSkills = workflow.targetSkills === undefined
|
|
275
|
+
? {}
|
|
276
|
+
: requireJsonObject(workflow.targetSkills, `${relativePath} agentWorkflow.targetSkills`);
|
|
277
|
+
workflow.targetSkills = targetSkills;
|
|
278
|
+
const skillPath = '.agents/skills/release-microsoft-store/SKILL.md';
|
|
279
|
+
const existingSkill = targetSkills['microsoft-store'];
|
|
280
|
+
if (existingSkill !== undefined && existingSkill !== skillPath) {
|
|
281
|
+
throw new Error(`${relativePath} already assigns a different Microsoft Store skill.`);
|
|
282
|
+
}
|
|
283
|
+
targetSkills['microsoft-store'] = skillPath;
|
|
284
|
+
plan(relativePath, formatJson(manifest));
|
|
285
|
+
}
|
|
286
|
+
function planManagedDocumentation(gameRoot, templateRoot, plan) {
|
|
287
|
+
for (const relativePath of ['README.md', 'agent/brief.md', 'agent/acceptance.md']) {
|
|
288
|
+
const file = resolveGameFile(gameRoot, relativePath);
|
|
289
|
+
if (!existsSync(file)) {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const source = readRequiredRegularFile(gameRoot, file, relativePath);
|
|
293
|
+
const template = readRequiredTemplateFile(templateRoot, relativePath);
|
|
294
|
+
const managedBlock = extractMicrosoftStoreBlock(template).trim();
|
|
295
|
+
plan(relativePath, upsertManagedDocumentationBlock(source, managedBlock, relativePath));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
function upsertManagedDocumentationBlock(source, managedBlock, relativePath) {
|
|
299
|
+
const startCount = source.split(microsoftStoreBlockStart).length - 1;
|
|
300
|
+
const endCount = source.split(microsoftStoreBlockEnd).length - 1;
|
|
301
|
+
const start = source.indexOf(microsoftStoreBlockStart);
|
|
302
|
+
const end = source.indexOf(microsoftStoreBlockEnd);
|
|
303
|
+
if (startCount > 0 || endCount > 0) {
|
|
304
|
+
if (startCount !== 1 || endCount !== 1 || end < start) {
|
|
305
|
+
throw new Error(`${relativePath} has malformed Microsoft Store managed block markers.`);
|
|
306
|
+
}
|
|
307
|
+
return `${source.slice(0, start)}${managedBlock}${source.slice(end + microsoftStoreBlockEnd.length)}`;
|
|
308
|
+
}
|
|
309
|
+
const legacyBlock = removeMicrosoftStoreBlockMarkers(managedBlock).trim();
|
|
310
|
+
if (legacyBlock.length === 0) {
|
|
311
|
+
throw new Error(`${relativePath} Microsoft Store managed block must not be empty.`);
|
|
312
|
+
}
|
|
313
|
+
if (source.includes(legacyBlock)) {
|
|
314
|
+
return source.replace(legacyBlock, managedBlock);
|
|
315
|
+
}
|
|
316
|
+
const anchor = managedDocumentationAnchor(relativePath);
|
|
317
|
+
if (anchor === undefined) {
|
|
318
|
+
return `${source.trimEnd()}\n\n${managedBlock}\n`;
|
|
319
|
+
}
|
|
320
|
+
const anchorCount = source.split(anchor).length - 1;
|
|
321
|
+
if (anchorCount > 1) {
|
|
322
|
+
throw new Error(`${relativePath} has more than one Microsoft Store insertion anchor.`);
|
|
323
|
+
}
|
|
324
|
+
return anchorCount === 0
|
|
325
|
+
? `${source.trimEnd()}\n\n${managedBlock}\n`
|
|
326
|
+
: source.replace(anchor, `${managedBlock}\n${anchor}`);
|
|
327
|
+
}
|
|
328
|
+
function managedDocumentationAnchor(relativePath) {
|
|
329
|
+
return microsoftStoreDocumentationAnchors[relativePath];
|
|
330
|
+
}
|
|
331
|
+
function extractMicrosoftStoreBlock(source) {
|
|
332
|
+
const start = source.indexOf(microsoftStoreBlockStart);
|
|
333
|
+
const end = source.indexOf(microsoftStoreBlockEnd);
|
|
334
|
+
if (start < 0 || end < start) {
|
|
335
|
+
throw new Error('Microsoft Store documentation block markers are missing or out of order.');
|
|
336
|
+
}
|
|
337
|
+
return source.slice(start, end + microsoftStoreBlockEnd.length);
|
|
338
|
+
}
|
|
339
|
+
function removeMicrosoftStoreBlocks(source) {
|
|
340
|
+
let output = source;
|
|
341
|
+
while (output.includes(microsoftStoreBlockStart)) {
|
|
342
|
+
const start = output.indexOf(microsoftStoreBlockStart);
|
|
343
|
+
const end = output.indexOf(microsoftStoreBlockEnd, start);
|
|
344
|
+
if (end < 0) {
|
|
345
|
+
throw new Error('Microsoft Store template block is missing its closing marker.');
|
|
346
|
+
}
|
|
347
|
+
output = `${output.slice(0, start)}${output.slice(end + microsoftStoreBlockEnd.length)}`;
|
|
348
|
+
}
|
|
349
|
+
return output.replace(/\n{3,}/gu, '\n\n');
|
|
350
|
+
}
|
|
351
|
+
function removeMicrosoftStoreBlockMarkers(source) {
|
|
352
|
+
return source
|
|
353
|
+
.replaceAll(microsoftStoreBlockStart, '')
|
|
354
|
+
.replaceAll(microsoftStoreBlockEnd, '');
|
|
355
|
+
}
|
|
356
|
+
function updateJson(source, update) {
|
|
357
|
+
const value = parseJsonObject(source, 'template JSON');
|
|
358
|
+
update(value);
|
|
359
|
+
return formatJson(value);
|
|
360
|
+
}
|
|
361
|
+
function parseJsonObject(source, label) {
|
|
362
|
+
let value;
|
|
363
|
+
try {
|
|
364
|
+
value = JSON.parse(source);
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
throw new Error(`${label} must contain valid JSON: ${formatError(error)}`);
|
|
368
|
+
}
|
|
369
|
+
return requireJsonObject(value, label);
|
|
370
|
+
}
|
|
371
|
+
function requireJsonObject(value, label) {
|
|
372
|
+
if (!isJsonObject(value)) {
|
|
373
|
+
throw new Error(`${label} must be a JSON object.`);
|
|
374
|
+
}
|
|
375
|
+
return value;
|
|
376
|
+
}
|
|
377
|
+
function isJsonObject(value) {
|
|
378
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
379
|
+
}
|
|
380
|
+
function formatJson(value) {
|
|
381
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
382
|
+
}
|
|
383
|
+
function resolveGameRoot(value) {
|
|
384
|
+
const resolved = path.resolve(value);
|
|
385
|
+
if (!existsSync(resolved) || !lstatSync(resolved).isDirectory()) {
|
|
386
|
+
throw new Error(`Game root must be an existing directory: ${resolved}`);
|
|
387
|
+
}
|
|
388
|
+
return realpathSync(resolved);
|
|
389
|
+
}
|
|
390
|
+
function resolveGameFile(gameRoot, relativePath) {
|
|
391
|
+
const file = path.resolve(gameRoot, relativePath);
|
|
392
|
+
if (!isInside(gameRoot, file)) {
|
|
393
|
+
throw new Error(`Starter path escapes the game root: ${relativePath}`);
|
|
394
|
+
}
|
|
395
|
+
return file;
|
|
396
|
+
}
|
|
397
|
+
function readOptionalRegularFile(gameRoot, file, relativePath) {
|
|
398
|
+
const stat = lstatIfExists(file);
|
|
399
|
+
if (stat === undefined) {
|
|
400
|
+
return undefined;
|
|
401
|
+
}
|
|
402
|
+
return readRequiredRegularFile(gameRoot, file, relativePath);
|
|
403
|
+
}
|
|
404
|
+
function lstatIfExists(file) {
|
|
405
|
+
try {
|
|
406
|
+
return lstatSync(file);
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
if (isMissingPathError(error)) {
|
|
410
|
+
return undefined;
|
|
411
|
+
}
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
function isMissingPathError(error) {
|
|
416
|
+
return hasErrorCode(error, 'ENOENT');
|
|
417
|
+
}
|
|
418
|
+
function readRequiredRegularFile(gameRoot, file, relativePath) {
|
|
419
|
+
const stat = lstatSync(file);
|
|
420
|
+
if (!stat.isFile()) {
|
|
421
|
+
throw new Error(`Starter path must be a regular file: ${relativePath}`);
|
|
422
|
+
}
|
|
423
|
+
if (!isInside(gameRoot, realpathSync(file))) {
|
|
424
|
+
throw new Error(`Starter path resolves outside the game root: ${relativePath}`);
|
|
425
|
+
}
|
|
426
|
+
return readFileSync(file, 'utf8');
|
|
427
|
+
}
|
|
428
|
+
function readRegularFileMode(file, relativePath) {
|
|
429
|
+
const stat = lstatIfExists(file);
|
|
430
|
+
if (stat === undefined) {
|
|
431
|
+
throw new Error(`Starter file changed while initialization was being planned: ${relativePath}`);
|
|
432
|
+
}
|
|
433
|
+
if (!stat.isFile()) {
|
|
434
|
+
throw new Error(`Starter path must remain a regular file: ${relativePath}`);
|
|
435
|
+
}
|
|
436
|
+
return Number(stat.mode) & 0o777;
|
|
437
|
+
}
|
|
438
|
+
function readRequiredTemplateFile(templateRoot, relativePath) {
|
|
439
|
+
const file = path.resolve(templateRoot, relativePath);
|
|
440
|
+
if (!isInside(templateRoot, file)) {
|
|
441
|
+
throw new Error(`Starter template path escapes the template root: ${relativePath}`);
|
|
442
|
+
}
|
|
443
|
+
try {
|
|
444
|
+
const stat = lstatSync(file);
|
|
445
|
+
if (!stat.isFile()) {
|
|
446
|
+
throw new Error(`Starter template path must be a regular file: ${relativePath}`);
|
|
447
|
+
}
|
|
448
|
+
if (!isInside(templateRoot, realpathSync(file))) {
|
|
449
|
+
throw new Error(`Starter template path resolves outside the template root: ${relativePath}`);
|
|
450
|
+
}
|
|
451
|
+
return readFileSync(file, 'utf8');
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
if (isMissingPathError(error)) {
|
|
455
|
+
throw new Error(`Starter template file is missing: ${relativePath}`);
|
|
456
|
+
}
|
|
457
|
+
throw error;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
function applyPlannedWrites(gameRoot, writes, runtime) {
|
|
461
|
+
const applied = [];
|
|
462
|
+
try {
|
|
463
|
+
for (const planned of writes) {
|
|
464
|
+
const current = readOptionalRegularFile(gameRoot, planned.file, planned.relativePath);
|
|
465
|
+
if (current !== planned.previous) {
|
|
466
|
+
throw new Error(`Starter file changed while initialization was running: ${planned.relativePath}`);
|
|
467
|
+
}
|
|
468
|
+
const parent = path.dirname(planned.file);
|
|
469
|
+
assertSafeWritableParent(gameRoot, parent, planned.relativePath);
|
|
470
|
+
const createdDirectories = [];
|
|
471
|
+
const suffix = `.mpgd-init-${randomUUID()}`;
|
|
472
|
+
const temporaryFile = `${planned.file}${suffix}.tmp`;
|
|
473
|
+
const backupFile = planned.previous === undefined
|
|
474
|
+
? undefined
|
|
475
|
+
: `${planned.file}${suffix}.bak`;
|
|
476
|
+
let backupMoved = false;
|
|
477
|
+
try {
|
|
478
|
+
createMissingDirectories(gameRoot, parent, planned.relativePath, createdDirectories);
|
|
479
|
+
writeFileSync(temporaryFile, planned.content, {
|
|
480
|
+
encoding: 'utf8',
|
|
481
|
+
flag: 'wx',
|
|
482
|
+
mode: planned.mode,
|
|
483
|
+
});
|
|
484
|
+
chmodSync(temporaryFile, planned.mode);
|
|
485
|
+
runtime.beforeCommit?.(planned.relativePath);
|
|
486
|
+
if (backupFile !== undefined) {
|
|
487
|
+
renameSync(planned.file, backupFile);
|
|
488
|
+
backupMoved = true;
|
|
489
|
+
}
|
|
490
|
+
renameSync(temporaryFile, planned.file);
|
|
491
|
+
}
|
|
492
|
+
catch (error) {
|
|
493
|
+
const recoveryErrors = [];
|
|
494
|
+
if (backupMoved && backupFile !== undefined && existsSync(backupFile)) {
|
|
495
|
+
try {
|
|
496
|
+
renameSync(backupFile, planned.file);
|
|
497
|
+
}
|
|
498
|
+
catch (recoveryError) {
|
|
499
|
+
recoveryErrors.push(recoveryError);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
try {
|
|
503
|
+
rmSync(temporaryFile, { force: true });
|
|
504
|
+
}
|
|
505
|
+
catch (recoveryError) {
|
|
506
|
+
recoveryErrors.push(recoveryError);
|
|
507
|
+
}
|
|
508
|
+
recoveryErrors.push(...removeCreatedDirectories(createdDirectories).map(wrapDirectoryCleanupFailure));
|
|
509
|
+
if (recoveryErrors.length > 0) {
|
|
510
|
+
throw new Error(`Starter write failed and recovery was incomplete for ${planned.relativePath}: ${recoveryErrors.map(formatError).join('; ')}`, { cause: error });
|
|
511
|
+
}
|
|
512
|
+
throw error;
|
|
513
|
+
}
|
|
514
|
+
applied.push({ planned, backupFile, temporaryFile, createdDirectories });
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
const rollbackErrors = [];
|
|
519
|
+
for (const entry of [...applied].reverse()) {
|
|
520
|
+
try {
|
|
521
|
+
runtime.beforeRollbackEntry?.(entry.planned.relativePath);
|
|
522
|
+
rmSync(entry.planned.file, { force: true });
|
|
523
|
+
if (entry.backupFile !== undefined && existsSync(entry.backupFile)) {
|
|
524
|
+
renameSync(entry.backupFile, entry.planned.file);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
catch (rollbackError) {
|
|
528
|
+
rollbackErrors.push({
|
|
529
|
+
relativePath: entry.planned.relativePath,
|
|
530
|
+
error: rollbackError,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
try {
|
|
534
|
+
rmSync(entry.temporaryFile, { force: true });
|
|
535
|
+
}
|
|
536
|
+
catch (rollbackError) {
|
|
537
|
+
rollbackErrors.push({
|
|
538
|
+
relativePath: entry.planned.relativePath,
|
|
539
|
+
error: rollbackError,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
// Restore every file before removing shared parent directories. This keeps cleanup
|
|
544
|
+
// independent of write order and ensures an unexpected leftover is reported.
|
|
545
|
+
for (const entry of [...applied].reverse()) {
|
|
546
|
+
for (const failure of removeCreatedDirectories(entry.createdDirectories)) {
|
|
547
|
+
rollbackErrors.push({
|
|
548
|
+
relativePath: entry.planned.relativePath,
|
|
549
|
+
error: wrapDirectoryCleanupFailure(failure),
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (rollbackErrors.length > 0) {
|
|
554
|
+
throw new Error(`Starter initialization failed and rollback was incomplete: ${rollbackErrors.map((failure) => `${failure.relativePath}: ${formatError(failure.error)}`).join('; ')}`, { cause: error });
|
|
555
|
+
}
|
|
556
|
+
throw error;
|
|
557
|
+
}
|
|
558
|
+
for (const entry of applied) {
|
|
559
|
+
if (entry.backupFile !== undefined) {
|
|
560
|
+
try {
|
|
561
|
+
rmSync(entry.backupFile, { force: true });
|
|
562
|
+
}
|
|
563
|
+
catch (cleanupError) {
|
|
564
|
+
// The committed file is authoritative; a backup cleanup failure must not roll it back.
|
|
565
|
+
console.warn(`Warning: failed to clean up starter backup ${entry.backupFile}: ${formatError(cleanupError)}`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
function listMissingDirectories(parent) {
|
|
571
|
+
const missing = [];
|
|
572
|
+
let current = parent;
|
|
573
|
+
while (lstatIfExists(current) === undefined) {
|
|
574
|
+
missing.push(current);
|
|
575
|
+
const next = path.dirname(current);
|
|
576
|
+
if (next === current) {
|
|
577
|
+
break;
|
|
578
|
+
}
|
|
579
|
+
current = next;
|
|
580
|
+
}
|
|
581
|
+
return missing;
|
|
582
|
+
}
|
|
583
|
+
function createMissingDirectories(gameRoot, parent, relativePath, createdDirectories) {
|
|
584
|
+
for (const directory of [...listMissingDirectories(parent)].reverse()) {
|
|
585
|
+
let created = false;
|
|
586
|
+
try {
|
|
587
|
+
mkdirSync(directory);
|
|
588
|
+
created = true;
|
|
589
|
+
}
|
|
590
|
+
catch (error) {
|
|
591
|
+
if (!hasErrorCode(error, 'EEXIST')) {
|
|
592
|
+
throw error;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
const stat = lstatSync(directory);
|
|
596
|
+
if (!stat.isDirectory() || !isInside(gameRoot, realpathSync(directory))) {
|
|
597
|
+
throw new Error(`Starter directory resolves outside the game root: ${relativePath}`);
|
|
598
|
+
}
|
|
599
|
+
if (created) {
|
|
600
|
+
createdDirectories.unshift(directory);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
function removeCreatedDirectories(directories) {
|
|
605
|
+
const failures = [];
|
|
606
|
+
for (const directory of directories) {
|
|
607
|
+
try {
|
|
608
|
+
rmdirSync(directory);
|
|
609
|
+
}
|
|
610
|
+
catch (error) {
|
|
611
|
+
if (isMissingPathError(error)) {
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
failures.push({ directory, error });
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return failures;
|
|
618
|
+
}
|
|
619
|
+
function wrapDirectoryCleanupFailure(failure) {
|
|
620
|
+
return new Error(`${failure.directory}: ${formatError(failure.error)}`, {
|
|
621
|
+
cause: failure.error,
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
function hasErrorCode(error, code) {
|
|
625
|
+
return error instanceof Error
|
|
626
|
+
&& 'code' in error
|
|
627
|
+
&& error.code === code;
|
|
628
|
+
}
|
|
629
|
+
function assertSafeWritableParent(gameRoot, parent, relativePath) {
|
|
630
|
+
let existing = parent;
|
|
631
|
+
while (lstatIfExists(existing) === undefined) {
|
|
632
|
+
const next = path.dirname(existing);
|
|
633
|
+
if (next === existing) {
|
|
634
|
+
throw new Error(`Starter path has no existing parent: ${relativePath}`);
|
|
635
|
+
}
|
|
636
|
+
existing = next;
|
|
637
|
+
}
|
|
638
|
+
const stat = lstatSync(existing);
|
|
639
|
+
if (!stat.isDirectory() || !isInside(gameRoot, realpathSync(existing))) {
|
|
640
|
+
throw new Error(`Starter directory resolves outside the game root: ${relativePath}`);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
function isInside(root, candidate) {
|
|
644
|
+
const relative = path.relative(root, candidate);
|
|
645
|
+
return (relative === ''
|
|
646
|
+
|| (!relative.startsWith(`..${path.sep}`)
|
|
647
|
+
&& relative !== '..'
|
|
648
|
+
&& !path.isAbsolute(relative)));
|
|
649
|
+
}
|
|
650
|
+
function toTemplatePath(value) {
|
|
651
|
+
return value.split(path.sep).join('/');
|
|
652
|
+
}
|
|
653
|
+
function formatError(error) {
|
|
654
|
+
return error instanceof Error ? error.message : String(error);
|
|
655
|
+
}
|
|
@@ -52,7 +52,9 @@ export function assertProductionTargetReadiness(input) {
|
|
|
52
52
|
+ `${canonicalOwnerPath}; received ${canonicalPlatformDir}.`);
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
|
-
|
|
55
|
+
if (targetConfig.authoritativeGameServices !== false) {
|
|
56
|
+
assertAuthoritativeGameServicesUrl(input.gameServicesUrl, input.target);
|
|
57
|
+
}
|
|
56
58
|
}
|
|
57
59
|
function readTargets(targetsFile) {
|
|
58
60
|
let parsed;
|