@mpgd/cli 0.34.0 → 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.
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ import { cloudflarePagesDeploymentProfiles, hostedPwaDeploymentHosts, verifyHost
17
17
  import { createMicrosoftStorePackageAcceptanceRuntime, runMicrosoftStorePackageAcceptance, } from './microsoft-store-package-acceptance.js';
18
18
  import { runMicrosoftStorePackageGeneration } from './microsoft-store-package-generation.js';
19
19
  import { defaultMpgdKitPath, initializeMicrosoftStoreStarter, listMicrosoftStoreInitializerTemplateFiles, prepareBaseGameTemplateFile, resolveMicrosoftStoreAdapterDependencyVersion, } from './microsoft-store-starter.js';
20
+ import { applyKitUpgrade, planKitUpgrade } from './kit-upgrade.js';
20
21
  import { runMicrosoftStoreSubmissionPreflight } from './microsoft-store-submission.js';
21
22
  import { defaultOfflinePlaytestArtifactDir, defaultOfflinePlaytestMaximumBytes, defaultOfflinePlaytestOutputDir, runOfflinePlaytestPackaging, } from './offline-playtest.js';
22
23
  import { targetConfigExtensionsFileEnv } from './target-config-env.js';
@@ -135,7 +136,7 @@ export async function runMpgdCli(args) {
135
136
  // partial document. Every other command keeps the default rendering.
136
137
  renderHeader: async (ctx) => {
137
138
  const values = ctx.values;
138
- if (ctx.name === 'verify-delivery' && values.json === true) {
139
+ if ((ctx.name === 'verify-delivery' || ctx.name === 'upgrade') && values.json === true) {
139
140
  return '';
140
141
  }
141
142
  const title = ctx.env.description || ctx.env.name || '';
@@ -146,7 +147,7 @@ export async function runMpgdCli(args) {
146
147
  renderValidationErrors: async (ctx, error) => {
147
148
  const messages = error.errors.map((entry) => String(entry.message)).join('\n');
148
149
  const values = ctx.values;
149
- if (ctx.name === 'verify-delivery' && values.json === true) {
150
+ if ((ctx.name === 'verify-delivery' || ctx.name === 'upgrade') && values.json === true) {
150
151
  process.stderr.write(`${messages}\n`);
151
152
  return '';
152
153
  }
@@ -2042,6 +2043,76 @@ const kitCommand = defineI18n({
2042
2043
  ko: '현재 mpgd-kit 체크아웃을 점검합니다.',
2043
2044
  }),
2044
2045
  subCommands: {
2046
+ upgrade: defineI18n({
2047
+ name: 'upgrade',
2048
+ description: 'Plan or apply the latest published Kit package versions for a game.',
2049
+ resource: commandResource({
2050
+ base: {
2051
+ en: 'Plan or apply the latest published Kit package versions for a game.',
2052
+ ko: '게임에 배포된 최신 Kit 패키지 버전 적용을 계획하거나 실행합니다.',
2053
+ },
2054
+ args: {
2055
+ game: {
2056
+ en: 'Game directory containing package.json and optional mpgd.targets.json.',
2057
+ ko: 'package.json과 선택적 mpgd.targets.json이 있는 게임 디렉터리.',
2058
+ },
2059
+ 'targets-file': {
2060
+ en: 'Optional game-owned target configuration file.',
2061
+ ko: '선택적 게임 소유 타깃 구성 파일.',
2062
+ },
2063
+ apply: {
2064
+ en: 'Write the planned manifests and pnpm lockfiles.',
2065
+ ko: '계획된 의존성 선언과 pnpm 락파일을 갱신합니다.',
2066
+ },
2067
+ json: {
2068
+ en: 'Write one machine-readable JSON document to stdout.',
2069
+ ko: '표준 출력에 기계 판독용 JSON 문서 하나를 출력합니다.',
2070
+ },
2071
+ },
2072
+ }),
2073
+ args: {
2074
+ game: { type: 'string', required: false, default: '.', description: 'Game directory.' },
2075
+ 'targets-file': { type: 'string', required: false, description: 'Target config file.' },
2076
+ apply: { type: 'boolean', required: false, description: 'Apply the upgrade plan.' },
2077
+ json: { type: 'boolean', required: false, description: 'Print JSON.' },
2078
+ },
2079
+ run: async (ctx) => {
2080
+ const game = readOptionalString(ctx.values.game) ?? '.';
2081
+ const plan = await planKitUpgrade(game, undefined, readOptionalString(ctx.values['targets-file']));
2082
+ const result = ctx.values.apply === true && plan.blockers.length === 0
2083
+ ? applyKitUpgrade(plan)
2084
+ : undefined;
2085
+ if (ctx.values.json === true) {
2086
+ process.stdout.write(JSON.stringify({ plan, result: result ?? null }, null, 2) + '\n');
2087
+ }
2088
+ else {
2089
+ console.info('Kit upgrade plan for ' + plan.gameRoot);
2090
+ for (const update of plan.updates) {
2091
+ console.info(' ' + path.relative(plan.gameRoot, update.manifest)
2092
+ + ': ' + update.packageName + ' ' + update.current + ' -> ' + update.next);
2093
+ }
2094
+ if (plan.updates.length === 0) {
2095
+ console.info(' Already current for published npm latest tags.');
2096
+ }
2097
+ for (const note of plan.notes) {
2098
+ console.info('Note: ' + note);
2099
+ }
2100
+ for (const blocker of plan.blockers) {
2101
+ console.error('Blocked: ' + blocker);
2102
+ }
2103
+ if (result !== undefined) {
2104
+ console.info('Updated ' + result.manifests.length + ' manifest(s) and '
2105
+ + result.lockfiles.length + ' lockfile(s). Run target checks before release.');
2106
+ }
2107
+ else if (plan.updates.length > 0 && plan.blockers.length === 0) {
2108
+ console.info('Run again with --apply to update manifests and pnpm lockfiles.');
2109
+ }
2110
+ }
2111
+ if (plan.blockers.length > 0) {
2112
+ process.exitCode = 1;
2113
+ }
2114
+ },
2115
+ }),
2045
2116
  doctor: defineI18n({
2046
2117
  name: 'doctor',
2047
2118
  description: 'Print CLI, kit, template, and target-wrapper status.',
@@ -2060,7 +2131,7 @@ const kitCommand = defineI18n({
2060
2131
  }),
2061
2132
  },
2062
2133
  run: () => {
2063
- console.info('Use "mpgd kit doctor".');
2134
+ console.info('Use "mpgd kit doctor" or "mpgd kit upgrade --game <directory> [--apply]".');
2064
2135
  },
2065
2136
  });
2066
2137
  function commandResource(description, args = {}) {
@@ -0,0 +1,39 @@
1
+ declare const dependencySections: readonly ['dependencies', 'devDependencies', 'optionalDependencies'];
2
+ type DependencySection = (typeof dependencySections)[number];
3
+ export interface PublishedKitPackage {
4
+ readonly version: string;
5
+ readonly peerDependencies?: Readonly<Record<string, string>>;
6
+ readonly optionalPeerDependencies?: readonly string[];
7
+ }
8
+ export type LatestKitPackageLookup = (name: string, gameRoot: string) => Promise<PublishedKitPackage>;
9
+ export interface KitUpgradeUpdate {
10
+ readonly manifest: string;
11
+ readonly section: DependencySection;
12
+ readonly packageName: string;
13
+ readonly current: string;
14
+ readonly next: string;
15
+ readonly latestVersion: string;
16
+ }
17
+ export interface KitUpgradePlan {
18
+ readonly gameRoot: string;
19
+ readonly updates: readonly KitUpgradeUpdate[];
20
+ readonly blockers: readonly string[];
21
+ readonly notes: readonly string[];
22
+ readonly manifestDigests: Readonly<Record<string, string>>;
23
+ readonly targetConfigDigest: {
24
+ readonly file: string;
25
+ readonly sha256: string | null;
26
+ };
27
+ readonly lockfileDigests: Readonly<Record<string, string>>;
28
+ readonly workspaceRoots: readonly string[];
29
+ }
30
+ export interface KitUpgradeApplyResult {
31
+ readonly manifests: readonly string[];
32
+ readonly lockfiles: readonly string[];
33
+ }
34
+ export type KitUpgradeLockfileRunner = (workspaceRoot: string) => void;
35
+ export declare function planKitUpgrade(gameDirectory: string, lookup?: LatestKitPackageLookup, targetsFileName?: string): Promise<KitUpgradePlan>;
36
+ export declare function applyKitUpgrade(plan: KitUpgradePlan, runLockfileUpdate?: KitUpgradeLockfileRunner): KitUpgradeApplyResult;
37
+ export declare function lookupPublishedKitPackage(packageName: string, gameRoot: string): Promise<PublishedKitPackage>;
38
+ export declare function parsePublishedKitPackageResponse(source: string, packageName: string): PublishedKitPackage;
39
+ export {};
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/cli",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "Gunshi CLI for mpgd-kit starter generation and target workflow orchestration.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -47,25 +47,27 @@
47
47
  "entities": "^8.0.0",
48
48
  "esbuild": "^0.28.1",
49
49
  "gunshi": "^0.35.1",
50
+ "semver": "^7.8.5",
50
51
  "undici": "^7.28.0",
51
52
  "@mpgd/phaser-assets": "0.7.0"
52
53
  },
53
54
  "devDependencies": {
54
55
  "@types/node": "^24.0.0",
56
+ "@types/semver": "^7.8.0",
55
57
  "ttsc": "0.30.4",
56
58
  "typescript": "7.0.2",
57
59
  "@mpgd/adapter-ait": "0.12.5",
60
+ "@mpgd/adapter-capacitor": "0.5.0",
58
61
  "@mpgd/adapter-browser": "0.7.6",
59
62
  "@mpgd/adapter-devvit": "0.9.11",
60
- "@mpgd/adapter-capacitor": "0.5.0",
61
63
  "@mpgd/adapter-verse8": "0.3.9",
64
+ "@mpgd/analytics": "0.3.15",
62
65
  "@mpgd/bridge": "0.10.0",
63
66
  "@mpgd/catalog": "0.7.5",
67
+ "@mpgd/game-services": "0.16.0",
64
68
  "@mpgd/i18n": "0.6.4",
65
69
  "@mpgd/platform": "0.13.0",
66
- "@mpgd/target-config": "0.16.0",
67
- "@mpgd/analytics": "0.3.15",
68
- "@mpgd/game-services": "0.16.0"
70
+ "@mpgd/target-config": "0.16.0"
69
71
  },
70
72
  "main": "./dist/index.js",
71
73
  "types": "./dist/index.d.ts",