@mpgd/cli 0.62.0 → 0.63.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,632 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { resolveProductPlatformId, resolveStoreListingLocalizations, } from '@mpgd/catalog';
5
+ import { canonicalLanguageTag, mapWithConcurrency, sameDecimal, stableSha256, StoreProductPlanStaleError, } from './store-products.js';
6
+ // App Store Connect Help, "In-App Purchase information": reference name 64,
7
+ // product ID 100 (letters, numbers, hyphens, periods, underscores), display
8
+ // name 2-30, description 45, review notes 4,000.
9
+ const maxReferenceName = 64;
10
+ /** Products planned at once; each is several asc subprocesses. */
11
+ const planConcurrency = 4;
12
+ const productIdPattern = /^[A-Za-z0-9._-]{1,100}$/u;
13
+ const minDisplayName = 2;
14
+ const maxDisplayName = 30;
15
+ const maxDescription = 45;
16
+ const maxReviewNote = 4000;
17
+ // Version states whose localizations App Store Connect still accepts edits for.
18
+ const inReviewVersionStates = new Set(['WAITING_FOR_REVIEW', 'IN_REVIEW']);
19
+ const approvedVersionStates = new Set(['ACCEPTED', 'APPROVED', 'REPLACED_WITH_NEW_VERSION']);
20
+ const editableVersionStates = new Set([
21
+ 'PREPARE_FOR_SUBMISSION',
22
+ 'READY_FOR_REVIEW',
23
+ 'REJECTED',
24
+ 'DEVELOPER_REJECTED',
25
+ ]);
26
+ export async function planAppStoreProducts(input, port) {
27
+ const catalogTarget = input.catalogTarget ?? 'ios';
28
+ const keepLivePrices = input.keepLivePrices === true;
29
+ const bundleId = await port.readAppBundleId(input.appId);
30
+ if (bundleId !== input.bundleId) {
31
+ throw new Error(`App Store Connect app ${input.appId} has bundle ID ${bundleId}, not ${input.bundleId} from the deployment profile.`);
32
+ }
33
+ const remote = await port.listIaps(input.appId);
34
+ const remoteByProductId = new Map(remote.map((iap) => [iap.productId, iap]));
35
+ let territories;
36
+ const allTerritories = () => {
37
+ territories ??= port.listTerritories();
38
+ return territories;
39
+ };
40
+ const skipped = [];
41
+ const manual = [];
42
+ const named = new Set();
43
+ const tasks = [];
44
+ for (const product of input.catalog.products) {
45
+ const productId = resolveProductPlatformId(product, catalogTarget);
46
+ if (productId === undefined) {
47
+ continue;
48
+ }
49
+ named.add(productId);
50
+ if (product.type === 'subscription') {
51
+ skipped.push({
52
+ logicalId: product.id,
53
+ productId,
54
+ reason: 'subscriptions are outside the sync',
55
+ });
56
+ continue;
57
+ }
58
+ const entry = input.listings.products[product.id];
59
+ if (entry === undefined) {
60
+ skipped.push({ logicalId: product.id, productId, reason: 'no store listing' });
61
+ continue;
62
+ }
63
+ if (!productIdPattern.test(productId)) {
64
+ throw new Error(`App Store product ID ${productId} (${product.id}) may hold up to 100 letters, digits, ".", "-" and "_".`);
65
+ }
66
+ tasks.push({
67
+ logicalId: product.id,
68
+ productId,
69
+ type: product.type === 'consumable' ? 'CONSUMABLE' : 'NON_CONSUMABLE',
70
+ entry,
71
+ remote: remoteByProductId.get(productId),
72
+ listingsDirectory: input.listingsDirectory,
73
+ keepLivePrices,
74
+ allTerritories,
75
+ port,
76
+ });
77
+ }
78
+ // Each product is a handful of asc subprocesses; a few run at once, and
79
+ // the results keep catalog order.
80
+ const planned = await mapWithConcurrency(tasks, planConcurrency, planOne);
81
+ const products = planned.map((entry) => entry.product);
82
+ for (const entry of planned) {
83
+ manual.push(...entry.manual);
84
+ }
85
+ const remoteOnly = remote
86
+ .map((iap) => iap.productId)
87
+ .filter((productId) => !named.has(productId))
88
+ .sort();
89
+ if (products.some((product) => product.action !== 'unchanged' || product.state !== 'APPROVED')) {
90
+ manual.push('Submit the in-app purchases for App Review in App Store Connect. The first consumable and the '
91
+ + 'first non-consumable must each go with a new app version (version page > In-App Purchases); '
92
+ + 'the sync never submits.');
93
+ }
94
+ return {
95
+ schemaVersion: 1,
96
+ store: 'app-store',
97
+ appId: input.appId,
98
+ bundleId,
99
+ catalogTarget,
100
+ keepLivePrices,
101
+ products,
102
+ skipped,
103
+ remoteOnly,
104
+ manual,
105
+ };
106
+ }
107
+ async function planOne(input) {
108
+ const { productId, remote, port } = input;
109
+ const appStore = input.entry.appStore ?? {};
110
+ const label = `App Store product ${productId} (${input.logicalId})`;
111
+ const notes = [];
112
+ const manual = [];
113
+ const changes = [];
114
+ // Without appStore.referenceName an existing in-app purchase keeps its
115
+ // name (one made by hand is not renamed); a new one is named after the
116
+ // logical product ID.
117
+ const referenceName = appStore.referenceName ?? remote?.name ?? input.logicalId;
118
+ if ([...referenceName].length > maxReferenceName) {
119
+ throw new Error(`${label}: the reference name is longer than ${maxReferenceName} characters.`);
120
+ }
121
+ const desiredLocalizations = Object.entries(resolveStoreListingLocalizations(input.entry, 'appStore'))
122
+ .map(([locale, text]) => {
123
+ const nameLength = [...text.title].length;
124
+ if (nameLength < minDisplayName || nameLength > maxDisplayName) {
125
+ throw new Error(`${label}: the ${locale} display name must be ${minDisplayName}-${maxDisplayName} characters.`);
126
+ }
127
+ if ([...text.description].length > maxDescription) {
128
+ throw new Error(`${label}: the ${locale} description is longer than ${maxDescription} characters.`);
129
+ }
130
+ return { locale: canonicalLanguageTag(locale), name: text.title, description: text.description };
131
+ })
132
+ .sort((left, right) => left.locale.localeCompare(right.locale));
133
+ if (appStore.reviewNote !== undefined && [...appStore.reviewNote].length > maxReviewNote) {
134
+ throw new Error(`${label}: the review note is longer than ${maxReviewNote} characters.`);
135
+ }
136
+ const screenshot = appStore.reviewScreenshot === undefined
137
+ ? undefined
138
+ : readScreenshot(input.listingsDirectory, appStore.reviewScreenshot, label);
139
+ if (remote !== undefined && remote.inAppPurchaseType !== input.type) {
140
+ throw new Error(`${label} is ${remote.inAppPurchaseType} on App Store Connect but ${input.type} in the catalog; `
141
+ + 'App Store Connect cannot change the type.');
142
+ }
143
+ const write = {};
144
+ // The in-app purchase itself.
145
+ if (remote === undefined) {
146
+ changes.push('create');
147
+ write.create = { type: input.type, referenceName, familySharable: appStore.familySharable === true };
148
+ }
149
+ else {
150
+ const update = {};
151
+ if (remote.name !== referenceName) {
152
+ changes.push('reference-name');
153
+ update.referenceName = referenceName;
154
+ }
155
+ if (appStore.familySharable === true && remote.familySharable !== true) {
156
+ changes.push('family-sharing');
157
+ update.familySharable = true;
158
+ notes.push('Family Sharing cannot be turned off again once it is on.');
159
+ }
160
+ else if (appStore.familySharable !== true && remote.familySharable === true) {
161
+ notes.push('Family Sharing is on in App Store Connect and cannot be turned off; the file does not ask for it.');
162
+ }
163
+ if (Object.keys(update).length > 0) {
164
+ write.update = update;
165
+ }
166
+ }
167
+ if ((appStore.reviewNote ?? '') !== (remote?.reviewNote ?? '') && appStore.reviewNote !== undefined) {
168
+ manual.push(`Set the review note of ${productId} in App Store Connect (asc 5.5.0 cannot write it).`);
169
+ notes.push('The review note differs; it is listed under manual steps.');
170
+ }
171
+ // The remote reads are independent: versions (then the localizations of
172
+ // the version that counts), the price schedule, availability and the
173
+ // review screenshot run at once.
174
+ const [versionRead, schedule, availability, remoteScreenshot] = remote === undefined
175
+ ? [undefined, undefined, undefined, undefined]
176
+ : await Promise.all([
177
+ readVersions(port, remote.id, label),
178
+ port.readPriceSchedule(remote.id),
179
+ port.readAvailability(remote.id),
180
+ port.readReviewScreenshot(remote.id),
181
+ ]);
182
+ const versions = versionRead?.versions ?? [];
183
+ const baseLocalizations = versionRead?.baseLocalizations ?? [];
184
+ // Localizations live on the in-app purchase version (API 4.4.1).
185
+ if (remote === undefined) {
186
+ if (desiredLocalizations.length > 0) {
187
+ changes.push('localizations');
188
+ write.localizations = { version: 'resolve', entries: desiredLocalizations };
189
+ }
190
+ }
191
+ else {
192
+ const target = versionRead?.target;
193
+ const base = target ?? versions[0];
194
+ const byLocale = new Map(baseLocalizations.map((localization) => [localization.locale.toLowerCase(), localization]));
195
+ const changed = desiredLocalizations.filter((desired) => {
196
+ const current = byLocale.get(desired.locale.toLowerCase());
197
+ return current === undefined || current.name !== desired.name
198
+ || (current.description ?? '') !== desired.description;
199
+ });
200
+ const kept = baseLocalizations.filter((localization) => !desiredLocalizations.some((desired) => desired.locale.toLowerCase() === localization.locale.toLowerCase()));
201
+ if (kept.length > 0) {
202
+ notes.push(`App Store Connect keeps localizations the file does not name: ${kept.map((localization) => localization.locale).join(', ')}.`);
203
+ }
204
+ if (changed.length > 0 && target === undefined && base !== undefined
205
+ && inReviewVersionStates.has(String(base.state))) {
206
+ notes.push(`Version ${String(base.version ?? '?')} is ${String(base.state)}; change the text after App Review answers.`);
207
+ }
208
+ else if (changed.length > 0) {
209
+ changes.push('localizations');
210
+ if (target !== undefined) {
211
+ write.localizations = { version: 'existing', versionId: target.id, entries: changed };
212
+ }
213
+ else {
214
+ changes.push('version');
215
+ write.localizations = { version: versions.length === 0 ? 'resolve' : 'create', entries: changed };
216
+ if (versions.length > 0) {
217
+ notes.push(`Version ${String(base?.version ?? '?')} is ${String(base?.state)}; a new draft version carries the `
218
+ + 'changed text and needs App Review.');
219
+ }
220
+ }
221
+ }
222
+ }
223
+ // On sale: the in-app purchase, or any of its versions, passed App Review.
224
+ const live = remote?.state === 'APPROVED'
225
+ || versions.some((version) => approvedVersionStates.has(String(version.state)));
226
+ // Prices: the base territory's customer price, plus manual territory prices.
227
+ // A new schedule replaces the old one: a territory the file does not name
228
+ // goes back to Apple's equalized price.
229
+ const priceChanges = [];
230
+ let livePriceChange = false;
231
+ if (appStore.price === undefined) {
232
+ if (remote === undefined || schedule === undefined) {
233
+ notes.push('No appStore.price: the in-app purchase stays unpriced and cannot be submitted.');
234
+ }
235
+ }
236
+ else {
237
+ const desired = new Map([
238
+ [appStore.price.baseTerritory, appStore.price.customerPrice],
239
+ ...Object.entries(appStore.price.territories ?? {}),
240
+ ]);
241
+ const current = new Map((schedule?.manualPrices ?? []).map((price) => [price.territory, price.customerPrice]));
242
+ if (schedule !== undefined && schedule.baseTerritory !== appStore.price.baseTerritory) {
243
+ priceChanges.push({
244
+ territory: 'base-territory',
245
+ ...(schedule.baseTerritory === undefined ? {} : { from: schedule.baseTerritory }),
246
+ to: appStore.price.baseTerritory,
247
+ });
248
+ }
249
+ for (const territory of [...new Set([...current.keys(), ...desired.keys()])].sort()) {
250
+ const from = current.get(territory);
251
+ const to = desired.get(territory);
252
+ if (from === undefined || to === undefined || !sameDecimal(from, to)) {
253
+ priceChanges.push({
254
+ territory,
255
+ ...(from === undefined ? {} : { from }),
256
+ to: to ?? 'equalized',
257
+ });
258
+ }
259
+ }
260
+ if (priceChanges.length > 0 && (schedule?.scheduledChanges ?? 0) > 0) {
261
+ notes.push(`App Store Connect has ${String(schedule?.scheduledChanges)} scheduled price change(s); a new schedule `
262
+ + 'would drop them, so the sync leaves the prices alone. Change them in App Store Connect.');
263
+ priceChanges.length = 0;
264
+ }
265
+ else if (priceChanges.length > 0) {
266
+ if (live && input.keepLivePrices) {
267
+ notes.push('A price change on this live in-app purchase is held (--keep-live-prices).');
268
+ priceChanges.length = 0;
269
+ }
270
+ else {
271
+ changes.push('prices');
272
+ livePriceChange = live;
273
+ // An existing in-app purchase has price points: resolve them now, so
274
+ // a price that is no App Store price point fails the plan and the
275
+ // digest covers the IDs. A new one has none until it is created.
276
+ const prices = await Promise.all([...desired].map(async ([territory, customerPrice]) => {
277
+ if (remote === undefined) {
278
+ return { territory, customerPrice };
279
+ }
280
+ try {
281
+ const pricePointId = await port.findPricePoint({ iapId: remote.id, territory, customerPrice });
282
+ return { territory, customerPrice, pricePointId };
283
+ }
284
+ catch (error) {
285
+ throw new Error(`${label}: ${customerPrice} is not an App Store price point in ${territory} (${error instanceof Error ? error.message : String(error)}).`, { cause: error });
286
+ }
287
+ }));
288
+ write.prices = { baseTerritory: appStore.price.baseTerritory, prices };
289
+ }
290
+ }
291
+ }
292
+ // Availability: set when there is none; changed only when the file names it.
293
+ const desiredAvailability = async () => ({
294
+ territories: [...(appStore.availability?.territories ?? await input.allTerritories())].sort(),
295
+ availableInNewTerritories: appStore.availability?.availableInNewTerritories ?? true,
296
+ });
297
+ if (appStore.availability?.territories !== undefined && appStore.price !== undefined) {
298
+ const sold = new Set(appStore.availability.territories);
299
+ const priced = [appStore.price.baseTerritory, ...Object.keys(appStore.price.territories ?? {})];
300
+ const unsold = priced.filter((territory) => !sold.has(territory));
301
+ if (unsold.length > 0) {
302
+ throw new Error(`${label}: priced territories ${unsold.join(', ')} are missing from appStore.availability.`);
303
+ }
304
+ }
305
+ if (availability === undefined && live && appStore.availability === undefined) {
306
+ // An approved in-app purchase without an availability record is not put
307
+ // on sale everywhere by default; the file has to name where.
308
+ notes.push('This approved in-app purchase has no availability record; name appStore.availability to set it. '
309
+ + 'The sync does not default an approved in-app purchase to every territory.');
310
+ }
311
+ else if (availability === undefined) {
312
+ changes.push('availability');
313
+ write.availability = await desiredAvailability();
314
+ }
315
+ else if (appStore.availability !== undefined) {
316
+ const wanted = await desiredAvailability();
317
+ if (wanted.availableInNewTerritories !== availability.availableInNewTerritories
318
+ || wanted.territories.join(',') !== [...availability.territories].sort().join(',')) {
319
+ changes.push('availability');
320
+ write.availability = wanted;
321
+ }
322
+ }
323
+ if (write.availability !== undefined) {
324
+ notes.push(`Availability: ${write.availability.territories.length} territories, new territories ${write.availability.availableInNewTerritories ? 'on' : 'off'}.`);
325
+ }
326
+ // Review screenshot: upload when missing, replace when the bytes differ.
327
+ if (screenshot !== undefined) {
328
+ // A failed or never-finished upload keeps its checksum; only a delivered one counts.
329
+ const delivered = remoteScreenshot?.deliveryState === undefined
330
+ || remoteScreenshot.deliveryState === 'COMPLETE'
331
+ || remoteScreenshot.deliveryState === 'UPLOAD_COMPLETE';
332
+ const same = delivered && remoteScreenshot?.sourceFileChecksum?.toLowerCase() === screenshot.md5;
333
+ if (remoteScreenshot !== undefined && !delivered) {
334
+ notes.push(`The review screenshot on App Store Connect is ${String(remoteScreenshot.deliveryState)}.`);
335
+ }
336
+ if (!same) {
337
+ changes.push('review-screenshot');
338
+ write.reviewScreenshot = {
339
+ file: screenshot.file,
340
+ path: screenshot.path,
341
+ md5: screenshot.md5,
342
+ ...(remoteScreenshot === undefined ? {} : { replaceId: remoteScreenshot.id }),
343
+ };
344
+ if (remoteScreenshot !== undefined) {
345
+ notes.push('Replaces the review screenshot: App Store Connect keeps one, so apply deletes the current one, '
346
+ + 'then uploads the file. If the upload fails, the in-app purchase has none until apply runs again.');
347
+ }
348
+ }
349
+ }
350
+ else if (remoteScreenshot === undefined) {
351
+ notes.push('No appStore.reviewScreenshot: App Review needs one before the first submission.');
352
+ }
353
+ const remoteSha256 = remote === undefined
354
+ ? null
355
+ : stableSha256({
356
+ remote,
357
+ versions,
358
+ baseLocalizations,
359
+ schedule,
360
+ availability,
361
+ remoteScreenshot,
362
+ });
363
+ const action = remote === undefined
364
+ ? 'create'
365
+ : changes.length === 0
366
+ ? 'unchanged'
367
+ : 'update';
368
+ const product = {
369
+ logicalId: input.logicalId,
370
+ productId,
371
+ action,
372
+ changes,
373
+ ...(remote === undefined ? {} : { iapId: remote.id }),
374
+ ...(remote?.state === undefined ? {} : { state: remote.state }),
375
+ live,
376
+ livePriceChange,
377
+ priceChanges,
378
+ notes,
379
+ remoteSha256,
380
+ // The digest names the screenshot by its listing-relative path, so a
381
+ // plan applied from another checkout (`apply --game`) still matches.
382
+ desiredSha256: stableSha256({
383
+ changes,
384
+ write: {
385
+ ...write,
386
+ ...(write.reviewScreenshot === undefined ? {} : {
387
+ reviewScreenshot: { ...write.reviewScreenshot, file: write.reviewScreenshot.path },
388
+ }),
389
+ },
390
+ }),
391
+ ...(changes.length === 0 ? {} : { write }),
392
+ };
393
+ return { product, manual };
394
+ }
395
+ /** Versions newest first, the one editable version, and the localizations that count. */
396
+ async function readVersions(port, iapId, label) {
397
+ const versions = [...await port.listVersions(iapId)]
398
+ .sort((left, right) => (right.version ?? 0) - (left.version ?? 0) || left.id.localeCompare(right.id));
399
+ const editable = versions.filter((version) => editableVersionStates.has(String(version.state)));
400
+ if (editable.length > 1) {
401
+ throw new Error(`${label} has ${editable.length} editable in-app purchase versions; finish or remove all but one in App Store Connect.`);
402
+ }
403
+ const target = editable[0];
404
+ const base = target ?? versions[0];
405
+ const baseLocalizations = base === undefined
406
+ ? []
407
+ : [...await port.listLocalizations(base.id)].sort((left, right) => left.locale.localeCompare(right.locale));
408
+ return { versions, target, baseLocalizations };
409
+ }
410
+ export class AppStoreProductApplyError extends Error {
411
+ productId;
412
+ stage;
413
+ done;
414
+ constructor(productId, stage, done, cause, detail) {
415
+ super(`App Store Connect ${stage} of ${productId} failed or its result is unknown (${cause instanceof Error ? cause.message : String(cause)}). Products already written: ${done.map((entry) => entry.productId).join(', ') || 'none'}. ${detail === undefined ? '' : `${detail} `}Run "mpgd store products plan" `
416
+ + 'again: it reads App Store Connect and lists what is left.', { cause });
417
+ this.productId = productId;
418
+ this.stage = stage;
419
+ this.done = done;
420
+ this.name = 'AppStoreProductApplyError';
421
+ }
422
+ }
423
+ /**
424
+ * Apply a reviewed plan, like the Google Play apply: `current` is a fresh
425
+ * plan, and every product apply would write must still match the reviewed
426
+ * remote state and planned write.
427
+ */
428
+ export async function applyAppStoreProductPlan(input) {
429
+ const { saved, current, port } = input;
430
+ if (!input.approved) {
431
+ throw new Error('Writing App Store Connect in-app purchases requires --approve.');
432
+ }
433
+ if (saved.appId !== current.appId || saved.catalogTarget !== current.catalogTarget
434
+ || saved.keepLivePrices !== current.keepLivePrices) {
435
+ throw new StoreProductPlanStaleError('The saved plan was made for another app or other options.');
436
+ }
437
+ const savedById = new Map(saved.products.map((product) => [product.productId, product]));
438
+ const pending = current.products.filter((product) => product.action !== 'unchanged');
439
+ for (const product of pending) {
440
+ const reviewed = savedById.get(product.productId);
441
+ if (reviewed === undefined || reviewed.action !== product.action
442
+ || reviewed.remoteSha256 !== product.remoteSha256
443
+ || reviewed.desiredSha256 !== product.desiredSha256) {
444
+ throw new StoreProductPlanStaleError(`App Store product ${product.productId} changed in App Store Connect or in the game files since the plan.`);
445
+ }
446
+ }
447
+ const priced = pending.filter((product) => product.livePriceChange);
448
+ if (priced.length > 0 && input.confirmPriceChange !== true) {
449
+ throw new Error(`The plan changes the price of approved in-app purchases (${priced.map((product) => product.productId).join(', ')}); confirm with --confirm-price-change, `
450
+ + 'or plan again with --keep-live-prices.');
451
+ }
452
+ const done = [];
453
+ for (const product of current.products) {
454
+ const write = product.write;
455
+ if (product.action === 'unchanged' || write === undefined) {
456
+ done.push({ productId: product.productId, status: 'unchanged' });
457
+ continue;
458
+ }
459
+ let stage = 'start';
460
+ let wrote = false;
461
+ const step = async (name, action, writes = true) => {
462
+ stage = name;
463
+ const result = await action();
464
+ wrote ||= writes;
465
+ return result;
466
+ };
467
+ try {
468
+ let iapId = product.iapId;
469
+ if (write.create !== undefined) {
470
+ const create = write.create;
471
+ iapId = await step('create', () => port.createIap({
472
+ appId: current.appId,
473
+ type: create.type,
474
+ referenceName: create.referenceName,
475
+ productId: product.productId,
476
+ familySharable: create.familySharable,
477
+ }));
478
+ }
479
+ if (iapId === undefined) {
480
+ throw new Error('the plan has no in-app purchase ID');
481
+ }
482
+ const id = iapId;
483
+ // Price points before any other write of this product: a new in-app
484
+ // purchase has none until it exists, so this is right after the create.
485
+ const pricePointIds = [];
486
+ if (write.prices !== undefined) {
487
+ for (const price of write.prices.prices) {
488
+ pricePointIds.push(price.pricePointId ?? await step('prices', () => port.findPricePoint({
489
+ iapId: id,
490
+ territory: price.territory,
491
+ customerPrice: price.customerPrice,
492
+ }), false));
493
+ }
494
+ }
495
+ if (write.update !== undefined) {
496
+ const update = write.update;
497
+ await step('update', () => port.updateIap({ iapId: id, ...update }));
498
+ }
499
+ if (write.localizations !== undefined) {
500
+ const localizations = write.localizations;
501
+ const versionId = await step('version', () => resolveVersion(port, id, localizations));
502
+ const existing = new Map((await step('localizations', () => port.listLocalizations(versionId), false))
503
+ .map((localization) => [localization.locale.toLowerCase(), localization]));
504
+ for (const entry of localizations.entries) {
505
+ const present = existing.get(entry.locale.toLowerCase());
506
+ if (present === undefined) {
507
+ await step('localizations', () => port.createLocalization({ versionId, ...entry }));
508
+ }
509
+ else if (present.name !== entry.name || (present.description ?? '') !== entry.description) {
510
+ await step('localizations', () => port.updateLocalization({
511
+ localizationId: present.id,
512
+ name: entry.name,
513
+ description: entry.description,
514
+ }));
515
+ }
516
+ }
517
+ }
518
+ if (write.prices !== undefined) {
519
+ const prices = write.prices;
520
+ await step('prices', () => port.createPriceSchedule({
521
+ iapId: id,
522
+ baseTerritory: prices.baseTerritory,
523
+ pricePointIds,
524
+ }));
525
+ }
526
+ if (write.availability !== undefined) {
527
+ const availability = write.availability;
528
+ await step('availability', () => port.setAvailability({ iapId: id, ...availability }));
529
+ }
530
+ if (write.reviewScreenshot !== undefined) {
531
+ const screenshot = write.reviewScreenshot;
532
+ if (md5File(screenshot.file) !== screenshot.md5) {
533
+ throw new StoreProductPlanStaleError(`Review screenshot ${screenshot.file} changed since the plan.`);
534
+ }
535
+ // App Store Connect keeps one review screenshot and refuses a second,
536
+ // so a replacement deletes first.
537
+ if (screenshot.replaceId !== undefined) {
538
+ const replaceId = screenshot.replaceId;
539
+ await step('review-screenshot delete', () => port.deleteReviewScreenshot(replaceId));
540
+ }
541
+ await step(screenshot.replaceId === undefined
542
+ ? 'review-screenshot upload'
543
+ : 'review-screenshot upload after delete', () => port.uploadReviewScreenshot({ iapId: id, file: screenshot.file }));
544
+ }
545
+ done.push({
546
+ productId: product.productId,
547
+ status: product.action === 'create' ? 'created' : 'updated',
548
+ iapId: id,
549
+ });
550
+ }
551
+ catch (error) {
552
+ if (error instanceof StoreProductPlanStaleError) {
553
+ throw error;
554
+ }
555
+ // Only products something was written for count as "already written".
556
+ const written = done.filter((entry) => entry.status !== 'unchanged');
557
+ if (wrote) {
558
+ written.push({
559
+ productId: product.productId,
560
+ status: product.action === 'create' ? 'created' : 'updated',
561
+ });
562
+ }
563
+ throw new AppStoreProductApplyError(product.productId, stage, written, error, stage === 'review-screenshot upload after delete'
564
+ ? 'The old review screenshot was deleted; run "mpgd store products plan" and apply again to upload the new one.'
565
+ : undefined);
566
+ }
567
+ }
568
+ return { appId: current.appId, products: done, manual: current.manual };
569
+ }
570
+ async function resolveVersion(port, iapId, localizations) {
571
+ if (localizations.version === 'existing' && localizations.versionId !== undefined) {
572
+ return localizations.versionId;
573
+ }
574
+ if (localizations.version === 'resolve') {
575
+ // App Store Connect may already have provisioned the first version.
576
+ const versions = await port.listVersions(iapId);
577
+ if (versions.length > 1) {
578
+ throw new Error(`in-app purchase ${iapId} has ${versions.length} versions; choose one in App Store Connect`);
579
+ }
580
+ if (versions[0] !== undefined) {
581
+ return versions[0].id;
582
+ }
583
+ }
584
+ return port.createVersion(iapId);
585
+ }
586
+ /** One line per product for the terminal, then the manual steps. */
587
+ export function formatAppStoreProductPlan(plan) {
588
+ const lines = [
589
+ `App Store Connect app ${plan.appId} (${plan.bundleId}, catalog target ${plan.catalogTarget})`,
590
+ ];
591
+ for (const product of plan.products) {
592
+ const flags = [
593
+ product.state,
594
+ product.livePriceChange ? 'PRICE CHANGE ON APPROVED PRODUCT' : undefined,
595
+ ].filter((flag) => flag !== undefined);
596
+ lines.push(` ${product.action.padEnd(9)} ${product.productId} (${product.logicalId})${product.changes.length === 0 ? '' : `: ${product.changes.join(', ')}`}${flags.length === 0 ? '' : ` [${flags.join('; ')}]`}`);
597
+ for (const change of product.priceChanges) {
598
+ lines.push(` price ${change.territory}: ${change.from ?? '-'} -> ${change.to ?? '-'}`);
599
+ }
600
+ for (const note of product.notes) {
601
+ lines.push(` note: ${note}`);
602
+ }
603
+ }
604
+ for (const entry of plan.skipped) {
605
+ lines.push(` skipped ${entry.productId ?? '-'} (${entry.logicalId}): ${entry.reason}`);
606
+ }
607
+ for (const productId of plan.remoteOnly) {
608
+ lines.push(` remote ${productId}: only on App Store Connect; not in the catalog (never deleted)`);
609
+ }
610
+ for (const step of plan.manual) {
611
+ lines.push(` manual ${step}`);
612
+ }
613
+ return lines.join('\n');
614
+ }
615
+ function readScreenshot(listingsDirectory, relative, label) {
616
+ const file = path.resolve(listingsDirectory, relative);
617
+ if (!existsSync(file) || !statSync(file).isFile()) {
618
+ throw new Error(`${label}: review screenshot ${file} is missing.`);
619
+ }
620
+ if (!/\.(?:png|jpe?g)$/iu.test(file)) {
621
+ throw new Error(`${label}: review screenshot ${file} must be a PNG or JPEG file.`);
622
+ }
623
+ // A symbolic link must not reach outside the game.
624
+ const real = path.relative(realpathSync(listingsDirectory), realpathSync(file));
625
+ if (real.startsWith('..') || path.isAbsolute(real)) {
626
+ throw new Error(`${label}: review screenshot ${relative} resolves outside the listing file's directory.`);
627
+ }
628
+ return { file, path: relative, md5: md5File(file) };
629
+ }
630
+ function md5File(file) {
631
+ return createHash('md5').update(readFileSync(file)).digest('hex');
632
+ }
@@ -0,0 +1,50 @@
1
+ import type { AscIapPort } from './app-store-iap-port.js';
2
+ import { type AscCredential } from './app-store-iap-asc.js';
3
+ import type { PlayProductCatalogPort } from './play-product-port.js';
4
+ import { type AppStoreProductApplyResult, type AppStoreProductPlan } from './store-products-app-store.js';
5
+ import { type PlayProductApplyResult, type PlayProductPlan } from './store-products-play.js';
6
+ /**
7
+ * `mpgd store products plan|apply`: the game's deployment profile names the
8
+ * app and the store credential (the same ones `deploy submit` uses), the
9
+ * game's catalog and store listing file name the products.
10
+ */
11
+ export type StoreProductStore = 'google-play' | 'app-store';
12
+ export interface StoreProductPlanOptions {
13
+ readonly game: string;
14
+ readonly profile: string;
15
+ readonly store: StoreProductStore;
16
+ readonly catalogFile?: string;
17
+ readonly listingsFile?: string;
18
+ readonly catalogTarget?: string;
19
+ readonly keepLivePrices?: boolean;
20
+ readonly signal?: AbortSignal;
21
+ }
22
+ /** The saved plan: the inputs apply re-reads, and the reviewed plan. */
23
+ export interface StoreProductPlanFile {
24
+ readonly schemaVersion: 1;
25
+ readonly kind: 'mpgd-store-products-plan';
26
+ readonly gameRoot: string;
27
+ readonly profile: string;
28
+ readonly catalogFile: string;
29
+ readonly listingsFile: string;
30
+ readonly plan: PlayProductPlan | AppStoreProductPlan;
31
+ }
32
+ /** Store clients; the tests replace them with fakes. */
33
+ export interface StoreProductPorts {
34
+ readonly googlePlay: (serviceAccountFile: string) => Promise<PlayProductCatalogPort>;
35
+ readonly appStore: (credential: AscCredential, signal?: AbortSignal) => Promise<{
36
+ readonly port: AscIapPort;
37
+ close(): void;
38
+ }>;
39
+ }
40
+ export declare function planStoreProducts(options: StoreProductPlanOptions, environment?: NodeJS.ProcessEnv, ports?: Partial<StoreProductPorts>): Promise<StoreProductPlanFile>;
41
+ export declare function saveStoreProductPlan(file: string, plan: StoreProductPlanFile): string;
42
+ export interface StoreProductApplyOptions {
43
+ readonly planFile: string;
44
+ /** Current game directory, when the game moved since the plan. */
45
+ readonly game?: string;
46
+ readonly approved: boolean;
47
+ readonly confirmPriceChange?: boolean;
48
+ readonly signal?: AbortSignal;
49
+ }
50
+ export declare function applyStoreProducts(options: StoreProductApplyOptions, environment?: NodeJS.ProcessEnv, ports?: Partial<StoreProductPorts>): Promise<PlayProductApplyResult | AppStoreProductApplyResult>;