@mpgd/cli 0.33.2 → 0.35.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.
@@ -0,0 +1,486 @@
1
+ import { execFile as execFileCallback, spawnSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, lstatSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { compare, intersects, minVersion, satisfies, valid, validRange } from 'semver';
7
+ const execFile = promisify(execFileCallback);
8
+ const dependencySections = ['dependencies', 'devDependencies', 'optionalDependencies'];
9
+ export async function planKitUpgrade(gameDirectory, lookup = lookupPublishedKitPackage, targetsFileName) {
10
+ const gameRoot = realpathSync(path.resolve(gameDirectory));
11
+ const gameManifest = path.join(gameRoot, 'package.json');
12
+ if (!existsSync(gameManifest)) {
13
+ throw new Error('Game package.json is missing: ' + gameManifest);
14
+ }
15
+ if (lstatSync(gameManifest).isSymbolicLink() || !inside(gameRoot, realpathSync(gameManifest))) {
16
+ throw new Error('Refusing to update a linked game package.json: ' + gameManifest);
17
+ }
18
+ const notes = [];
19
+ const blockers = [];
20
+ const { manifests, targetConfigDigest } = discoverGameManifests(gameRoot, notes, blockers, targetsFileName);
21
+ const manifestDigests = {};
22
+ const requested = new Set();
23
+ const parsedManifests = new Map();
24
+ for (const manifest of manifests) {
25
+ const source = readFileSync(manifest, 'utf8');
26
+ manifestDigests[manifest] = digest(source);
27
+ const parsed = parseJsonObject(source, manifest);
28
+ parsedManifests.set(manifest, parsed);
29
+ for (const section of dependencySections) {
30
+ for (const [name, value] of Object.entries(asObject(parsed[section]))) {
31
+ if (!name.startsWith('@mpgd/') || typeof value !== 'string') {
32
+ continue;
33
+ }
34
+ if (!isKitPackageName(name)) {
35
+ blockers.push('Invalid Kit package name in ' + manifest + ': ' + name);
36
+ continue;
37
+ }
38
+ if (parseVersionSpecifier(value) === undefined) {
39
+ if (validRange(value) !== null) {
40
+ blockers.push('Unsupported Kit version range for ' + name + ' in ' + manifest
41
+ + ': ' + value + '. Pin an exact version, ^version, or ~version before upgrading.');
42
+ }
43
+ else {
44
+ notes.push('Skipped non-SemVer dependency ' + name + ' in ' + manifest + ': ' + value);
45
+ }
46
+ }
47
+ else {
48
+ requested.add(name);
49
+ }
50
+ }
51
+ }
52
+ }
53
+ const packages = new Map();
54
+ const names = [...requested].sort();
55
+ for (let index = 0; index < names.length; index += 4) {
56
+ await Promise.all(names.slice(index, index + 4).map(async (name) => {
57
+ try {
58
+ const published = await lookup(name, gameRoot);
59
+ if (valid(published.version) === null) {
60
+ throw new Error('registry returned a non-SemVer latest version');
61
+ }
62
+ packages.set(name, published);
63
+ }
64
+ catch (error) {
65
+ blockers.push('Cannot resolve npm latest for ' + name + ': ' + formatError(error));
66
+ }
67
+ }));
68
+ }
69
+ const updates = [];
70
+ const peerSubjects = [];
71
+ for (const manifest of manifests) {
72
+ const parsed = parsedManifests.get(manifest);
73
+ if (parsed === undefined) {
74
+ continue;
75
+ }
76
+ for (const section of dependencySections) {
77
+ for (const [name, value] of Object.entries(asObject(parsed[section]))) {
78
+ if (!name.startsWith('@mpgd/') || typeof value !== 'string') {
79
+ continue;
80
+ }
81
+ const current = parseVersionSpecifier(value);
82
+ const published = packages.get(name);
83
+ if (current === undefined || published === undefined) {
84
+ continue;
85
+ }
86
+ if (compare(published.version, current.version) < 0) {
87
+ notes.push('Kept ' + name + ' in ' + manifest + ': current version is newer than npm latest.');
88
+ }
89
+ else {
90
+ const next = compare(published.version, current.version) > 0
91
+ ? current.prefix + published.version
92
+ : value;
93
+ const subject = {
94
+ manifest,
95
+ section,
96
+ packageName: name,
97
+ current: value,
98
+ next,
99
+ latestVersion: published.version,
100
+ };
101
+ peerSubjects.push(subject);
102
+ if (next !== value) {
103
+ updates.push(subject);
104
+ }
105
+ }
106
+ }
107
+ }
108
+ }
109
+ validatePeers(peerSubjects, updates, packages, parsedManifests, gameManifest, blockers, notes);
110
+ const { lockfileDigests, workspaceRoots } = discoverLockfiles(gameRoot, manifests, blockers, notes);
111
+ const cliUpdate = updates.find((update) => update.packageName === '@mpgd/cli');
112
+ if (process.env.MPGD_KIT_PATH !== undefined && cliUpdate !== undefined) {
113
+ const kitPackageFile = path.resolve(process.env.MPGD_KIT_PATH, 'packages/cli/package.json');
114
+ if (existsSync(kitPackageFile)) {
115
+ const kitPackage = parseJsonObject(readFileSync(kitPackageFile, 'utf8'), kitPackageFile);
116
+ if (kitPackage.version !== cliUpdate.latestVersion) {
117
+ notes.push('MPGD_KIT_PATH still has CLI ' + String(kitPackage.version)
118
+ + '; select the published ' + cliUpdate.latestVersion + ' Kit tag before a release.');
119
+ }
120
+ }
121
+ }
122
+ return {
123
+ gameRoot,
124
+ updates,
125
+ blockers,
126
+ notes: [...new Set(notes)],
127
+ manifestDigests,
128
+ targetConfigDigest,
129
+ lockfileDigests,
130
+ workspaceRoots,
131
+ };
132
+ }
133
+ export function applyKitUpgrade(plan, runLockfileUpdate = updatePnpmLockfile) {
134
+ if (plan.blockers.length > 0) {
135
+ throw new Error('Kit upgrade is blocked:\n' + plan.blockers.join('\n'));
136
+ }
137
+ if (plan.updates.length === 0) {
138
+ return { manifests: [], lockfiles: [] };
139
+ }
140
+ const { file: targetsFile, sha256: targetsDigest } = plan.targetConfigDigest;
141
+ if (targetsDigest === null) {
142
+ if (existsSync(targetsFile)) {
143
+ throw new Error('File changed after upgrade planning: ' + targetsFile);
144
+ }
145
+ }
146
+ else if (!existsSync(targetsFile) || digest(readFileSync(targetsFile, 'utf8')) !== targetsDigest) {
147
+ throw new Error('File changed after upgrade planning: ' + targetsFile);
148
+ }
149
+ const snapshots = new Map();
150
+ for (const [file, expected] of Object.entries({
151
+ ...plan.manifestDigests,
152
+ ...plan.lockfileDigests,
153
+ })) {
154
+ const current = readFileSync(file, 'utf8');
155
+ if (digest(current) !== expected) {
156
+ throw new Error('File changed after upgrade planning: ' + file);
157
+ }
158
+ snapshots.set(file, current);
159
+ }
160
+ const changedManifests = [...new Set(plan.updates.map((update) => update.manifest))];
161
+ try {
162
+ for (const manifest of changedManifests) {
163
+ const source = snapshots.get(manifest);
164
+ if (source === undefined) {
165
+ throw new Error('Missing manifest snapshot: ' + manifest);
166
+ }
167
+ const parsed = parseJsonObject(source, manifest);
168
+ for (const update of plan.updates.filter((item) => item.manifest === manifest)) {
169
+ const section = asObject(parsed[update.section]);
170
+ if (section[update.packageName] !== update.current) {
171
+ throw new Error('Dependency changed after planning: ' + update.packageName);
172
+ }
173
+ section[update.packageName] = update.next;
174
+ parsed[update.section] = section;
175
+ }
176
+ writeFileSync(manifest, stringifyLike(source, parsed));
177
+ }
178
+ for (const root of plan.workspaceRoots) {
179
+ runLockfileUpdate(root);
180
+ }
181
+ }
182
+ catch (error) {
183
+ const restoreFailures = [];
184
+ for (const [file, original] of snapshots) {
185
+ try {
186
+ writeFileSync(file, original);
187
+ }
188
+ catch (restoreError) {
189
+ restoreFailures.push(file + ': ' + formatError(restoreError));
190
+ }
191
+ }
192
+ const restoration = restoreFailures.length === 0
193
+ ? 'Original manifests and lockfiles were rolled back.'
194
+ : 'Rollback incomplete:\n' + restoreFailures.join('\n');
195
+ throw new Error('Kit upgrade failed during lockfile update: ' + formatError(error)
196
+ + '\n' + restoration);
197
+ }
198
+ return { manifests: changedManifests, lockfiles: Object.keys(plan.lockfileDigests) };
199
+ }
200
+ export async function lookupPublishedKitPackage(packageName, gameRoot) {
201
+ if (!isKitPackageName(packageName)) {
202
+ throw new Error('Invalid Kit package name: ' + packageName);
203
+ }
204
+ const executable = process.platform === 'win32' ? 'npm.cmd' : 'npm';
205
+ const { stdout } = await execFile(executable, [
206
+ 'view',
207
+ packageName + '@latest',
208
+ 'version',
209
+ 'peerDependencies',
210
+ 'peerDependenciesMeta',
211
+ '--json',
212
+ ], {
213
+ cwd: gameRoot,
214
+ encoding: 'utf8',
215
+ maxBuffer: 512 * 1024,
216
+ timeout: 20_000,
217
+ shell: process.platform === 'win32',
218
+ });
219
+ return parsePublishedKitPackageResponse(String(stdout), packageName);
220
+ }
221
+ export function parsePublishedKitPackageResponse(source, packageName) {
222
+ let parsed;
223
+ try {
224
+ parsed = JSON.parse(source);
225
+ }
226
+ catch (error) {
227
+ throw new Error('Invalid npm view JSON for ' + packageName + ': ' + formatError(error));
228
+ }
229
+ const response = typeof parsed === 'string' ? { version: parsed } : asObject(parsed);
230
+ if (typeof response.version !== 'string') {
231
+ throw new Error('Registry did not return a latest version for ' + packageName);
232
+ }
233
+ const peers = asObject(response.peerDependencies);
234
+ const peerMetadata = asObject(response.peerDependenciesMeta);
235
+ const declaredPeers = Object.entries(peers).filter((entry) => typeof entry[1] === 'string');
236
+ const optionalPeerDependencies = declaredPeers
237
+ .filter(([name]) => asObject(peerMetadata[name]).optional === true)
238
+ .map(([name]) => name);
239
+ return {
240
+ version: response.version,
241
+ peerDependencies: Object.fromEntries(declaredPeers),
242
+ ...(optionalPeerDependencies.length > 0 ? { optionalPeerDependencies } : {}),
243
+ };
244
+ }
245
+ function discoverGameManifests(gameRoot, notes, blockers, targetsFileName) {
246
+ const manifests = new Set([path.join(gameRoot, 'package.json')]);
247
+ const targetsFile = path.resolve(gameRoot, targetsFileName ?? 'mpgd.targets.json');
248
+ if (!inside(gameRoot, targetsFile)) {
249
+ throw new Error('Targets file must be inside the game directory: ' + targetsFile);
250
+ }
251
+ if (!existsSync(targetsFile)) {
252
+ if (targetsFileName === undefined) {
253
+ notes.push('No mpgd.targets.json found; only the game manifest will be updated.');
254
+ }
255
+ else {
256
+ blockers.push('Requested targets file is missing: ' + targetsFile);
257
+ }
258
+ return { manifests: [...manifests], targetConfigDigest: { file: targetsFile, sha256: null } };
259
+ }
260
+ if (lstatSync(targetsFile).isSymbolicLink() || !inside(gameRoot, realpathSync(targetsFile))) {
261
+ throw new Error('Refusing to read a linked targets file: ' + targetsFile);
262
+ }
263
+ const targetsSource = readFileSync(targetsFile, 'utf8');
264
+ const config = parseJsonObject(targetsSource, targetsFile);
265
+ const targetsDirectory = path.dirname(targetsFile);
266
+ for (const value of Object.values(asObject(config.targets))) {
267
+ const target = asObject(value);
268
+ for (const key of ['wrapperApp', 'shellApp']) {
269
+ const location = target[key];
270
+ if (typeof location !== 'string') {
271
+ continue;
272
+ }
273
+ if (location.includes('${MPGD_KIT_PATH}')) {
274
+ notes.push('Skipped external target ' + key + ': ' + location);
275
+ continue;
276
+ }
277
+ const resolvedLocation = location
278
+ .replaceAll('${MPGD_GAME_ROOT}', targetsDirectory)
279
+ .replaceAll('${MPGD_GAME_APP_ROOT}', targetsDirectory);
280
+ if (resolvedLocation.includes('$' + '{')) {
281
+ notes.push('Skipped target with unresolved path token: ' + location);
282
+ continue;
283
+ }
284
+ const directory = path.resolve(targetsDirectory, resolvedLocation);
285
+ if (!inside(gameRoot, directory)) {
286
+ notes.push('Skipped target outside the game directory: ' + location);
287
+ continue;
288
+ }
289
+ const manifest = path.join(directory, 'package.json');
290
+ if (!existsSync(manifest)) {
291
+ notes.push('Skipped target without package.json: ' + location);
292
+ continue;
293
+ }
294
+ if (lstatSync(manifest).isSymbolicLink() || !inside(gameRoot, realpathSync(manifest))) {
295
+ notes.push('Skipped linked target manifest: ' + location);
296
+ continue;
297
+ }
298
+ manifests.add(manifest);
299
+ }
300
+ }
301
+ return {
302
+ manifests: [...manifests].sort(),
303
+ targetConfigDigest: { file: targetsFile, sha256: digest(targetsSource) },
304
+ };
305
+ }
306
+ function discoverLockfiles(gameRoot, manifests, blockers, notes) {
307
+ const roots = new Set();
308
+ let boundary = gameRoot;
309
+ for (;;) {
310
+ if (existsSync(path.join(boundary, '.git'))) {
311
+ break;
312
+ }
313
+ const parent = path.dirname(boundary);
314
+ if (parent === boundary) {
315
+ boundary = gameRoot;
316
+ notes.push('No git boundary found; lockfile discovery is limited to the game directory.');
317
+ break;
318
+ }
319
+ boundary = parent;
320
+ }
321
+ for (const manifest of manifests) {
322
+ let directory = path.dirname(manifest);
323
+ while (inside(boundary, directory)) {
324
+ const lockfile = path.join(directory, 'pnpm-lock.yaml');
325
+ if (existsSync(lockfile)) {
326
+ if (lstatSync(lockfile).isSymbolicLink()) {
327
+ blockers.push('Refusing to rewrite a linked pnpm lockfile: ' + lockfile);
328
+ }
329
+ else {
330
+ roots.add(directory);
331
+ }
332
+ }
333
+ if (directory === boundary) {
334
+ break;
335
+ }
336
+ directory = path.dirname(directory);
337
+ }
338
+ }
339
+ if (roots.size === 0) {
340
+ blockers.push('No pnpm-lock.yaml found for the game and its target manifests.');
341
+ }
342
+ const workspaceRoots = [...roots].sort((left, right) => left.length - right.length);
343
+ return {
344
+ workspaceRoots,
345
+ lockfileDigests: Object.fromEntries(workspaceRoots.map((root) => {
346
+ const file = path.join(root, 'pnpm-lock.yaml');
347
+ return [file, digest(readFileSync(file, 'utf8'))];
348
+ })),
349
+ };
350
+ }
351
+ function validatePeers(subjects, updates, packages, manifests, gameManifest, blockers, notes) {
352
+ const replacements = new Map(updates.map((update) => [update.manifest + '\0' + update.packageName, update.next]));
353
+ for (const subject of subjects) {
354
+ const published = packages.get(subject.packageName);
355
+ const peers = published?.peerDependencies ?? {};
356
+ const optionalPeers = new Set(published?.optionalPeerDependencies ?? []);
357
+ for (const [peerName, range] of Object.entries(peers)) {
358
+ if (validRange(range) === null) {
359
+ blockers.push('Invalid published peer range for ' + subject.packageName + ': '
360
+ + peerName + ' ' + range);
361
+ continue;
362
+ }
363
+ const owner = findPeerOwner(manifests, subject.manifest, gameManifest, peerName);
364
+ if (owner === undefined) {
365
+ if (!optionalPeers.has(peerName)) {
366
+ notes.push(subject.packageName + ' requires peer ' + peerName + ' ' + range
367
+ + '; no direct version was declared. Check the resolved lockfile.');
368
+ }
369
+ continue;
370
+ }
371
+ const specifier = replacements.get(owner.manifest + '\0' + peerName) ?? owner.specifier;
372
+ const parsed = parseVersionSpecifier(specifier);
373
+ if (parsed !== undefined) {
374
+ if (!intersects(specifier, range)) {
375
+ blockers.push(subject.packageName + '@' + subject.latestVersion + ' requires '
376
+ + peerName + ' ' + range + ', incompatible with ' + specifier + '.');
377
+ }
378
+ else if (!satisfies(parsed.version, range)) {
379
+ notes.push(subject.packageName + ' peer ' + peerName + ' requires ' + range
380
+ + '; confirm the installed version selected by ' + specifier + '.');
381
+ }
382
+ }
383
+ else if (validRange(specifier) !== null && !intersects(specifier, range)) {
384
+ blockers.push(subject.packageName + '@' + subject.latestVersion + ' requires '
385
+ + peerName + ' ' + range + ', incompatible with ' + specifier + '.');
386
+ }
387
+ else if (validRange(specifier) !== null) {
388
+ const minimum = minVersion(specifier);
389
+ if (minimum !== null && !satisfies(minimum, range)) {
390
+ notes.push(subject.packageName + ' peer ' + peerName + ' requires ' + range
391
+ + '; verify the resolved version inside ' + specifier + '.');
392
+ }
393
+ }
394
+ else {
395
+ notes.push('Could not verify peer ' + peerName + ' for ' + subject.packageName
396
+ + ' from specifier ' + specifier + '.');
397
+ }
398
+ }
399
+ }
400
+ }
401
+ function findPeerOwner(manifests, packageManifest, gameManifest, peerName) {
402
+ for (const manifest of [packageManifest, gameManifest]) {
403
+ const specifier = dependencySpecifier(manifests.get(manifest), peerName);
404
+ if (specifier !== undefined) {
405
+ return { manifest, specifier };
406
+ }
407
+ }
408
+ const candidates = [...manifests].flatMap(([manifest, content]) => {
409
+ const specifier = dependencySpecifier(content, peerName);
410
+ return specifier === undefined ? [] : [{ manifest, specifier }];
411
+ });
412
+ return candidates.length === 1 ? candidates[0] : undefined;
413
+ }
414
+ function dependencySpecifier(manifest, name) {
415
+ if (manifest === undefined) {
416
+ return undefined;
417
+ }
418
+ for (const section of dependencySections) {
419
+ const value = asObject(manifest[section])[name];
420
+ if (typeof value === 'string') {
421
+ return value;
422
+ }
423
+ }
424
+ return undefined;
425
+ }
426
+ function parseVersionSpecifier(value) {
427
+ if (valid(value) !== null) {
428
+ return { prefix: '', version: value };
429
+ }
430
+ const prefix = value[0];
431
+ if ((prefix === '^' || prefix === '~') && valid(value.slice(1)) !== null) {
432
+ return { prefix, version: value.slice(1) };
433
+ }
434
+ return undefined;
435
+ }
436
+ function updatePnpmLockfile(workspaceRoot) {
437
+ const result = spawnSync(process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm', ['install', '--lockfile-only', '--ignore-scripts'], {
438
+ cwd: workspaceRoot,
439
+ encoding: 'utf8',
440
+ timeout: 180_000,
441
+ maxBuffer: 8 * 1024 * 1024,
442
+ shell: process.platform === 'win32',
443
+ });
444
+ if (result.error !== undefined || result.status !== 0) {
445
+ throw new Error('pnpm lockfile update failed in ' + workspaceRoot + ': '
446
+ + formatError(result.error ?? (result.stderr || result.stdout)));
447
+ }
448
+ }
449
+ function parseJsonObject(source, label) {
450
+ let parsed;
451
+ try {
452
+ parsed = JSON.parse(source);
453
+ }
454
+ catch (error) {
455
+ throw new Error('Invalid JSON in ' + label + ': ' + formatError(error));
456
+ }
457
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
458
+ throw new Error('Expected a JSON object in ' + label);
459
+ }
460
+ return parsed;
461
+ }
462
+ function asObject(value) {
463
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
464
+ ? value
465
+ : {};
466
+ }
467
+ function stringifyLike(original, value) {
468
+ const indent = original.match(/\r?\n([ \t]+)"/u)?.[1] ?? ' ';
469
+ const newline = original.includes('\r\n') ? '\r\n' : '\n';
470
+ return JSON.stringify(value, null, indent).replaceAll('\n', newline)
471
+ + (original.endsWith('\n') ? newline : '');
472
+ }
473
+ function inside(root, candidate) {
474
+ const relative = path.relative(root, candidate);
475
+ return relative === '' || (!relative.startsWith('..' + path.sep) && relative !== '..'
476
+ && !path.isAbsolute(relative));
477
+ }
478
+ function digest(source) {
479
+ return createHash('sha256').update(source).digest('hex');
480
+ }
481
+ function isKitPackageName(name) {
482
+ return /^@mpgd\/[a-z0-9][a-z0-9._-]*$/u.test(name);
483
+ }
484
+ function formatError(error) {
485
+ return error instanceof Error ? error.message : String(error);
486
+ }
@@ -0,0 +1,19 @@
1
+ /** Inspect only the effective App target, not unrelated Xcode extensions. */
2
+ export declare function assertNativeShellIdentity(platform: 'android' | 'ios', source: string, expectedAppId: string): void;
3
+ export declare function assertAndroidSettingsAppProject(source: string): void;
4
+ export declare function assertAndroidSettingsNoAppRemap(source: string): void;
5
+ export declare function stripGradleComments(source: string): string;
6
+ export declare function hasGradleIdentityMutation(source: string): boolean;
7
+ export declare function hasGradleBracketIdentityWrite(source: string): boolean;
8
+ export declare function hasAndroidDisplayNameResourceOverride(source: string): boolean;
9
+ export declare function hasAndroidResourceSourceSetOverride(source: string): boolean;
10
+ export declare function hasAndroidManifestSourceSetOverride(source: string): boolean;
11
+ export declare function hasGradleTaskAction(source: string): boolean;
12
+ export declare function hasGradlePropertySetter(source: string): boolean;
13
+ export declare function countGradleIdentityWrites(source: string, key: string): number;
14
+ export declare function maskGradleStrings(clean: string): string;
15
+ export declare function readIosReleaseInfoPlist(source: string): string;
16
+ export declare function assertIosReleaseProductName(source: string): void;
17
+ export declare function assertIosReleaseInfoPlistExpansion(source: string): void;
18
+ export declare function assertIosReleasePackagingSettings(source: string): void;
19
+ export declare function readIosAppTargetId(source: string): string;