@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,23 @@
1
+ import type { AscIapPort } from './app-store-iap-port.js';
2
+ /**
3
+ * The in-app purchase port on the pinned `asc` 5.5.0 CLI, with the same
4
+ * binary pin, isolated environment and API key as the TestFlight
5
+ * submission. Each method is one `asc ... --output json` command; the JSON
6
+ * is App Store Connect's own resource document, which `asc` prints as it
7
+ * received it (`internal/asc/types/resources.go` at the 5.5.0 tag).
8
+ */
9
+ /** Runs one asc command with `--output json` appended and returns the parsed JSON. */
10
+ export type AscCommandRunner = (args: readonly string[], timeoutMs: number) => Promise<unknown>;
11
+ export interface AscCredential {
12
+ readonly ascBinary: string;
13
+ readonly apiKeyId: string;
14
+ readonly apiIssuerId: string;
15
+ readonly apiPrivateKeyBase64: string;
16
+ }
17
+ /** Copy and verify the pinned binary into a private directory; `close` removes it. */
18
+ export declare function openAscIapSession(credential: AscCredential, signal?: AbortSignal): Promise<{
19
+ readonly port: AscIapPort;
20
+ close(): void;
21
+ }>;
22
+ /** The port over any runner; the tests drive it with recorded asc output. */
23
+ export declare function createAscIapPort(run: AscCommandRunner, today?: () => string): AscIapPort;
@@ -0,0 +1,326 @@
1
+ import { chmodSync, copyFileSync, mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { ReleaseProcessError, runReleaseProcess } from './deploy-process.js';
5
+ import { sameDecimal } from './store-products.js';
6
+ import { isolatedAscEnvironment, verifyPinnedAscBinary } from './testflight-submission.js';
7
+ const lookupTimeoutMs = 60_000;
8
+ const listTimeoutMs = 3 * 60_000;
9
+ const uploadTimeoutMs = 10 * 60_000;
10
+ const maxJsonBytes = 8 * 1024 * 1024;
11
+ /** asc exit code for an App Store Connect 404 (`cmd/exit_codes.go`, 5.5.0). */
12
+ const ascExitNotFound = 4;
13
+ /** Copy and verify the pinned binary into a private directory; `close` removes it. */
14
+ export async function openAscIapSession(credential, signal) {
15
+ if (!path.isAbsolute(credential.ascBinary)) {
16
+ throw new Error('asc binary must be an absolute file path.');
17
+ }
18
+ const tempRoot = mkdtempSync(path.join(tmpdir(), 'mpgd-asc-iap-'));
19
+ try {
20
+ const binary = path.join(tempRoot, 'asc');
21
+ copyFileSync(credential.ascBinary, binary);
22
+ chmodSync(binary, 0o700);
23
+ await verifyPinnedAscBinary(binary);
24
+ const environment = isolatedAscEnvironment(credential, tempRoot);
25
+ const run = async (args, timeoutMs) => {
26
+ const result = await runReleaseProcess({
27
+ command: binary,
28
+ args: [...args, '--output', 'json'],
29
+ cwd: tempRoot,
30
+ environment,
31
+ timeoutMs,
32
+ maxOutputBytes: maxJsonBytes,
33
+ captureMachineStdout: true,
34
+ secretValues: [credential.apiKeyId, credential.apiIssuerId, credential.apiPrivateKeyBase64],
35
+ signal,
36
+ });
37
+ if (result.truncated || result.machineStdout === undefined) {
38
+ throw new Error('asc JSON output was truncated or unavailable.');
39
+ }
40
+ try {
41
+ return JSON.parse(result.machineStdout);
42
+ }
43
+ catch {
44
+ throw new Error('asc returned invalid JSON.');
45
+ }
46
+ };
47
+ return {
48
+ port: createAscIapPort(run),
49
+ close: () => rmSync(tempRoot, { recursive: true, force: true }),
50
+ };
51
+ }
52
+ catch (error) {
53
+ rmSync(tempRoot, { recursive: true, force: true });
54
+ throw error;
55
+ }
56
+ }
57
+ /** The port over any runner; the tests drive it with recorded asc output. */
58
+ export function createAscIapPort(run, today = () => new Date().toISOString().slice(0, 10)) {
59
+ const optional = async (args) => {
60
+ try {
61
+ return await run(args, lookupTimeoutMs);
62
+ }
63
+ catch (error) {
64
+ if (error instanceof ReleaseProcessError && error.exitCode === ascExitNotFound) {
65
+ return undefined;
66
+ }
67
+ throw error;
68
+ }
69
+ };
70
+ return {
71
+ async readAppBundleId(appId) {
72
+ const app = single(await run(['apps', 'view', '--id', appId], lookupTimeoutMs), 'apps view');
73
+ const bundleId = app.attributes.bundleId;
74
+ if (app.id !== appId || typeof bundleId !== 'string') {
75
+ throw new Error('asc apps view returned no bundle ID for the app.');
76
+ }
77
+ return bundleId;
78
+ },
79
+ async listIaps(appId) {
80
+ return list(await run(['iap', 'list', '--app', appId, '--paginate'], listTimeoutMs), 'iap list')
81
+ .map((resource) => ({
82
+ id: resource.id,
83
+ productId: requiredString(resource.attributes.productId, 'iap list productId'),
84
+ name: requiredString(resource.attributes.name, 'iap list name'),
85
+ inAppPurchaseType: requiredString(resource.attributes.inAppPurchaseType, 'iap list type'),
86
+ ...optionalString('state', resource.attributes.state),
87
+ ...optionalString('reviewNote', resource.attributes.reviewNote),
88
+ ...(resource.attributes.familySharable === true ? { familySharable: true } : {}),
89
+ }));
90
+ },
91
+ async listVersions(iapId) {
92
+ return list(await run(['iap', 'versions', 'list', '--iap-id', iapId, '--paginate'], listTimeoutMs), 'iap versions list').map((resource) => ({
93
+ id: resource.id,
94
+ ...(typeof resource.attributes.version === 'number' ? { version: resource.attributes.version } : {}),
95
+ ...optionalString('state', resource.attributes.state),
96
+ }));
97
+ },
98
+ async listLocalizations(versionId) {
99
+ return list(await run(['iap', 'versions', 'localizations', 'list', '--version-id', versionId, '--paginate'], listTimeoutMs), 'iap versions localizations list').map((resource) => ({
100
+ id: resource.id,
101
+ locale: requiredString(resource.attributes.locale, 'localization locale'),
102
+ name: requiredString(resource.attributes.name, 'localization name'),
103
+ ...optionalString('description', resource.attributes.description),
104
+ }));
105
+ },
106
+ async readPriceSchedule(iapId) {
107
+ const response = await optional(['iap', 'pricing', 'schedules', 'view', '--iap-id', iapId, '--include', 'baseTerritory']);
108
+ // No schedule: asc exits 4 (not found), or App Store Connect answers with no resource.
109
+ if (response === undefined || isEmptyResource(response)) {
110
+ return undefined;
111
+ }
112
+ const schedule = single(response, 'iap pricing schedules view');
113
+ const baseTerritory = relationshipId(schedule.relationships, 'baseTerritory');
114
+ const resolved = await run(['iap', 'pricing', 'schedules', 'manual-prices', '--schedule-id', schedule.id, '--resolved'], listTimeoutMs);
115
+ if (!isRecord(resolved) || !Array.isArray(resolved.prices)) {
116
+ throw new Error('asc iap pricing schedules manual-prices returned no prices.');
117
+ }
118
+ const manualPrices = resolved.prices.flatMap((row) => isRecord(row) && row.manual === true && typeof row.territory === 'string'
119
+ && typeof row.customerPrice === 'string'
120
+ ? [{ territory: row.territory, customerPrice: row.customerPrice }]
121
+ : []);
122
+ if (baseTerritory === undefined && manualPrices.length === 0) {
123
+ return undefined;
124
+ }
125
+ // `--resolved` lists only prices in effect; the raw list also holds future ones.
126
+ const all = list(await run(['iap', 'pricing', 'schedules', 'manual-prices', '--schedule-id', schedule.id, '--paginate'], listTimeoutMs), 'iap pricing schedules manual-prices');
127
+ const now = today();
128
+ // A future start or a future end (a temporary price expiring) is a
129
+ // transition a new open-ended schedule would remove.
130
+ const upcoming = (value) => typeof value === 'string' && value.slice(0, 10) > now;
131
+ const scheduledChanges = all.filter((price) => upcoming(price.attributes.startDate) || upcoming(price.attributes.endDate)).length;
132
+ return {
133
+ id: schedule.id,
134
+ ...(baseTerritory === undefined ? {} : { baseTerritory }),
135
+ manualPrices,
136
+ scheduledChanges,
137
+ };
138
+ },
139
+ async readAvailability(iapId) {
140
+ let response;
141
+ try {
142
+ response = await run(['iap', 'pricing', 'availability', 'view', '--iap-id', iapId], lookupTimeoutMs);
143
+ }
144
+ catch (error) {
145
+ // asc 5.5.0 reports an unset availability as an error naming it (availability.go).
146
+ if (error instanceof ReleaseProcessError
147
+ && (error.exitCode === ascExitNotFound || error.output.includes('has no availability configured'))) {
148
+ return undefined;
149
+ }
150
+ throw error;
151
+ }
152
+ if (isEmptyResource(response)) {
153
+ return undefined;
154
+ }
155
+ const availability = single(response, 'iap pricing availability view');
156
+ const territories = list(await run(['iap', 'pricing', 'availabilities', 'available-territories', '--id', availability.id, '--paginate'], listTimeoutMs), 'iap pricing availabilities available-territories').map((territory) => territory.id);
157
+ return {
158
+ id: availability.id,
159
+ availableInNewTerritories: availability.attributes.availableInNewTerritories === true,
160
+ territories,
161
+ };
162
+ },
163
+ async readReviewScreenshot(iapId) {
164
+ const response = await optional(['iap', 'review-screenshots', 'view', '--iap-id', iapId]);
165
+ if (!isRecord(response) || !isRecord(response.data) || typeof response.data.id !== 'string'
166
+ || response.data.id === '') {
167
+ return undefined;
168
+ }
169
+ const attributes = isRecord(response.data.attributes) ? response.data.attributes : {};
170
+ return {
171
+ id: response.data.id,
172
+ ...optionalString('fileName', attributes.fileName),
173
+ ...(typeof attributes.fileSize === 'number' ? { fileSize: attributes.fileSize } : {}),
174
+ ...optionalString('sourceFileChecksum', attributes.sourceFileChecksum),
175
+ ...optionalString('deliveryState', isRecord(attributes.assetDeliveryState) ? attributes.assetDeliveryState.state : undefined),
176
+ };
177
+ },
178
+ async listTerritories() {
179
+ const territories = list(await run(['pricing', 'territories', 'list', '--paginate'], listTimeoutMs), 'pricing territories list').map((resource) => resource.id);
180
+ if (territories.length === 0) {
181
+ throw new Error('asc pricing territories list returned no territories.');
182
+ }
183
+ return territories;
184
+ },
185
+ async createIap(input) {
186
+ const created = single(await run([
187
+ 'iap', 'create',
188
+ '--app', input.appId,
189
+ '--type', input.type,
190
+ '--ref-name', input.referenceName,
191
+ '--product-id', input.productId,
192
+ ...(input.familySharable ? ['--family-sharable'] : []),
193
+ ], lookupTimeoutMs), 'iap create');
194
+ return created.id;
195
+ },
196
+ async updateIap(input) {
197
+ await run([
198
+ 'iap', 'update',
199
+ '--id', input.iapId,
200
+ ...(input.referenceName === undefined ? [] : ['--ref-name', input.referenceName]),
201
+ ...(input.familySharable === true ? ['--family-sharable'] : []),
202
+ ], lookupTimeoutMs);
203
+ },
204
+ async createVersion(iapId) {
205
+ return single(await run(['iap', 'versions', 'create', '--iap-id', iapId], lookupTimeoutMs), 'iap versions create').id;
206
+ },
207
+ async createLocalization(input) {
208
+ await run([
209
+ 'iap', 'versions', 'localizations', 'create',
210
+ '--version-id', input.versionId,
211
+ '--locale', input.locale,
212
+ '--name', input.name,
213
+ '--description', input.description,
214
+ ], lookupTimeoutMs);
215
+ },
216
+ async updateLocalization(input) {
217
+ await run([
218
+ 'iap', 'versions', 'localizations', 'update',
219
+ '--localization-id', input.localizationId,
220
+ '--name', input.name,
221
+ '--description', input.description,
222
+ ], lookupTimeoutMs);
223
+ },
224
+ async findPricePoint(input) {
225
+ const points = list(await run([
226
+ 'iap', 'pricing', 'price-points', 'list',
227
+ '--iap-id', input.iapId,
228
+ '--territory', input.territory,
229
+ '--price', input.customerPrice,
230
+ '--paginate',
231
+ ], listTimeoutMs), 'iap pricing price-points list').filter((point) => typeof point.attributes.customerPrice === 'string'
232
+ && sameDecimal(point.attributes.customerPrice, input.customerPrice));
233
+ const [point] = points;
234
+ if (points.length !== 1 || point === undefined) {
235
+ throw new Error(`App Store Connect has ${points.length} price points of ${input.customerPrice} in ${input.territory}; `
236
+ + 'use a price that is an App Store price point there.');
237
+ }
238
+ return point.id;
239
+ },
240
+ async createPriceSchedule(input) {
241
+ await run([
242
+ 'iap', 'pricing', 'schedules', 'create',
243
+ '--iap-id', input.iapId,
244
+ '--base-territory', input.baseTerritory,
245
+ '--prices', input.pricePointIds.join(','),
246
+ ], lookupTimeoutMs);
247
+ },
248
+ async setAvailability(input) {
249
+ await run([
250
+ 'iap', 'pricing', 'availability', 'set',
251
+ '--iap-id', input.iapId,
252
+ '--territories', input.territories.join(','),
253
+ ...(input.availableInNewTerritories ? ['--available-in-new-territories'] : []),
254
+ ], lookupTimeoutMs);
255
+ },
256
+ async deleteReviewScreenshot(screenshotId) {
257
+ await run(['iap', 'review-screenshots', 'delete', '--screenshot-id', screenshotId, '--confirm'], lookupTimeoutMs);
258
+ },
259
+ async uploadReviewScreenshot(input) {
260
+ await run(['iap', 'review-screenshots', 'create', '--iap-id', input.iapId, '--file', input.file], uploadTimeoutMs);
261
+ },
262
+ };
263
+ }
264
+ function single(value, command) {
265
+ const resource = isRecord(value) ? toResource(value.data) : undefined;
266
+ if (resource === undefined) {
267
+ throw new Error(`asc ${command} returned no resource.`);
268
+ }
269
+ return resource;
270
+ }
271
+ function list(value, command) {
272
+ const resources = isRecord(value) && Array.isArray(value.data)
273
+ ? value.data.map(toResource)
274
+ : undefined;
275
+ if (resources === undefined || resources.some((resource) => resource === undefined)) {
276
+ throw new Error(`asc ${command} returned no resource list.`);
277
+ }
278
+ return resources;
279
+ }
280
+ function toResource(value) {
281
+ if (!isRecord(value) || typeof value.id !== 'string' || value.id === '') {
282
+ return undefined;
283
+ }
284
+ return {
285
+ id: value.id,
286
+ attributes: isRecord(value.attributes) ? value.attributes : {},
287
+ ...(value.relationships === undefined ? {} : { relationships: value.relationships }),
288
+ };
289
+ }
290
+ /**
291
+ * App Store Connect's `data: null`, or asc's rendering of it: asc 5.5.0
292
+ * decodes into a non-pointer resource and prints `{ "type": "", "id": "" }`.
293
+ */
294
+ function isEmptyResource(value) {
295
+ if (!isRecord(value)) {
296
+ return false;
297
+ }
298
+ return value.data === null
299
+ || (isRecord(value.data) && (value.data.id === undefined || value.data.id === ''));
300
+ }
301
+ function relationshipId(relationships, name) {
302
+ if (!isRecord(relationships)) {
303
+ return undefined;
304
+ }
305
+ const relationship = relationships[name];
306
+ if (!isRecord(relationship) || !isRecord(relationship.data)) {
307
+ return undefined;
308
+ }
309
+ return typeof relationship.data.id === 'string' ? relationship.data.id : undefined;
310
+ }
311
+ function requiredString(value, label) {
312
+ if (typeof value !== 'string') {
313
+ throw new Error(`asc output has no ${label}.`);
314
+ }
315
+ return value;
316
+ }
317
+ function optionalString(key, value) {
318
+ const result = {};
319
+ if (typeof value === 'string' && value !== '') {
320
+ result[key] = value;
321
+ }
322
+ return result;
323
+ }
324
+ function isRecord(value) {
325
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
326
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * App Store Connect in-app purchase port. The kit drives App Store Connect
3
+ * through the pinned `asc` CLI (docs/NATIVE_DEPLOY_TOOLING_DECISION.md);
4
+ * `app-store-iap-asc.ts` implements this port with it, and the planner and
5
+ * tests see only these shapes.
6
+ *
7
+ * Resources (App Store Connect API 4.4.1):
8
+ * - In-app purchases v2: https://developer.apple.com/documentation/appstoreconnectapi/in-app-purchases
9
+ * - Versions and their localizations: https://developer.apple.com/documentation/appstoreconnectapi/in-app-purchase-versions
10
+ * - Price schedules: https://developer.apple.com/documentation/appstoreconnectapi/post-v1-inapppurchasepriceschedules
11
+ * - Availability: https://developer.apple.com/documentation/appstoreconnectapi/post-v1-inapppurchaseavailabilities
12
+ * - Review screenshot: https://developer.apple.com/documentation/appstoreconnectapi/post-v1-inapppurchaseappstorereviewscreenshots
13
+ */
14
+ export type AscIapType = 'CONSUMABLE' | 'NON_CONSUMABLE';
15
+ export interface AscIap {
16
+ readonly id: string;
17
+ readonly productId: string;
18
+ /** Reference name. */
19
+ readonly name: string;
20
+ readonly inAppPurchaseType: string;
21
+ /** `InAppPurchaseState`, for example `MISSING_METADATA` or `APPROVED`. */
22
+ readonly state?: string | undefined;
23
+ readonly familySharable?: boolean | undefined;
24
+ readonly reviewNote?: string | undefined;
25
+ }
26
+ export interface AscIapVersion {
27
+ readonly id: string;
28
+ readonly version?: number | undefined;
29
+ /** `PREPARE_FOR_SUBMISSION`, `READY_FOR_REVIEW`, `WAITING_FOR_REVIEW`, `APPROVED`, ... */
30
+ readonly state?: string | undefined;
31
+ }
32
+ export interface AscIapLocalization {
33
+ readonly id: string;
34
+ readonly locale: string;
35
+ readonly name: string;
36
+ readonly description?: string | undefined;
37
+ }
38
+ /** The current price schedule: base territory and the manual prices in effect. */
39
+ export interface AscIapPriceSchedule {
40
+ readonly id: string;
41
+ readonly baseTerritory?: string | undefined;
42
+ /** Manual (not equalized) prices in effect now, per alpha-3 territory. */
43
+ readonly manualPrices: readonly {
44
+ readonly territory: string;
45
+ readonly customerPrice: string;
46
+ }[];
47
+ /** Manual prices that start in the future; a new schedule would drop them. */
48
+ readonly scheduledChanges: number;
49
+ }
50
+ export interface AscIapAvailability {
51
+ readonly id: string;
52
+ readonly availableInNewTerritories: boolean;
53
+ /** Alpha-3 territories the in-app purchase is sold in. */
54
+ readonly territories: readonly string[];
55
+ }
56
+ export interface AscIapReviewScreenshot {
57
+ readonly id: string;
58
+ readonly fileName?: string | undefined;
59
+ readonly fileSize?: number | undefined;
60
+ /** MD5 of the uploaded file, as App Store Connect records it. */
61
+ readonly sourceFileChecksum?: string | undefined;
62
+ /** `assetDeliveryState.state`: `AWAITING_UPLOAD`, `UPLOAD_COMPLETE`, `COMPLETE` or `FAILED`. */
63
+ readonly deliveryState?: string | undefined;
64
+ }
65
+ export interface AscIapPort {
66
+ /** The app record's bundle ID (`apps view`). */
67
+ readAppBundleId(appId: string): Promise<string>;
68
+ listIaps(appId: string): Promise<AscIap[]>;
69
+ listVersions(iapId: string): Promise<AscIapVersion[]>;
70
+ listLocalizations(versionId: string): Promise<AscIapLocalization[]>;
71
+ /** `undefined` when the in-app purchase has no price schedule yet. */
72
+ readPriceSchedule(iapId: string): Promise<AscIapPriceSchedule | undefined>;
73
+ /** `undefined` when no availability was set. */
74
+ readAvailability(iapId: string): Promise<AscIapAvailability | undefined>;
75
+ /** `undefined` when no review screenshot was uploaded. */
76
+ readReviewScreenshot(iapId: string): Promise<AscIapReviewScreenshot | undefined>;
77
+ /** Every App Store territory (alpha-3). */
78
+ listTerritories(): Promise<string[]>;
79
+ createIap(input: {
80
+ readonly appId: string;
81
+ readonly type: AscIapType;
82
+ readonly referenceName: string;
83
+ readonly productId: string;
84
+ readonly familySharable: boolean;
85
+ }): Promise<string>;
86
+ updateIap(input: {
87
+ readonly iapId: string;
88
+ readonly referenceName?: string;
89
+ readonly familySharable?: true;
90
+ }): Promise<void>;
91
+ createVersion(iapId: string): Promise<string>;
92
+ createLocalization(input: {
93
+ readonly versionId: string;
94
+ readonly locale: string;
95
+ readonly name: string;
96
+ readonly description: string;
97
+ }): Promise<void>;
98
+ updateLocalization(input: {
99
+ readonly localizationId: string;
100
+ readonly name: string;
101
+ readonly description: string;
102
+ }): Promise<void>;
103
+ /** The price point of `customerPrice` in `territory`; throws unless exactly one matches. */
104
+ findPricePoint(input: {
105
+ readonly iapId: string;
106
+ readonly territory: string;
107
+ readonly customerPrice: string;
108
+ }): Promise<string>;
109
+ /** A new schedule effective now: the base territory's and the manual prices. */
110
+ createPriceSchedule(input: {
111
+ readonly iapId: string;
112
+ readonly baseTerritory: string;
113
+ readonly pricePointIds: readonly string[];
114
+ }): Promise<void>;
115
+ setAvailability(input: {
116
+ readonly iapId: string;
117
+ readonly territories: readonly string[];
118
+ readonly availableInNewTerritories: boolean;
119
+ }): Promise<void>;
120
+ deleteReviewScreenshot(screenshotId: string): Promise<void>;
121
+ uploadReviewScreenshot(input: {
122
+ readonly iapId: string;
123
+ readonly file: string;
124
+ }): Promise<void>;
125
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * App Store Connect in-app purchase port. The kit drives App Store Connect
3
+ * through the pinned `asc` CLI (docs/NATIVE_DEPLOY_TOOLING_DECISION.md);
4
+ * `app-store-iap-asc.ts` implements this port with it, and the planner and
5
+ * tests see only these shapes.
6
+ *
7
+ * Resources (App Store Connect API 4.4.1):
8
+ * - In-app purchases v2: https://developer.apple.com/documentation/appstoreconnectapi/in-app-purchases
9
+ * - Versions and their localizations: https://developer.apple.com/documentation/appstoreconnectapi/in-app-purchase-versions
10
+ * - Price schedules: https://developer.apple.com/documentation/appstoreconnectapi/post-v1-inapppurchasepriceschedules
11
+ * - Availability: https://developer.apple.com/documentation/appstoreconnectapi/post-v1-inapppurchaseavailabilities
12
+ * - Review screenshot: https://developer.apple.com/documentation/appstoreconnectapi/post-v1-inapppurchaseappstorereviewscreenshots
13
+ */
14
+ export {};
package/dist/index.d.ts CHANGED
@@ -9,6 +9,10 @@ export { runNativeDeployment, readStoreCredential, type RunNativeDeploymentInput
9
9
  export { planRecordedAndroidSubmission, submitRecordedNativeTarget, type NativeStoreCredential, type SubmitRecordedNativeTargetInput, } from './native-deploy-submission.js';
10
10
  export { PlaySubmissionUncertainError, planVerifiedAndroidBundleSubmission, submitVerifiedAndroidBundle, type PlayInternalSubmissionInput, type PlayInternalSubmissionResult, type PlaySubmissionPlan, } from './play-internal-submission.js';
11
11
  export { createPlayReleaseRequest, defaultPlayReleaseRequest, maximumPlayReleaseNoteCharacters, parsePlayReleaseNotes, type CreatePlayReleaseRequestInput, type PlayNewReleaseStatus, type PlayReleaseNote, type PlayReleaseRequest, type PlayReleaseStatus, } from './play-release-options.js';
12
+ export { applyStoreProducts, planStoreProducts, saveStoreProductPlan, type StoreProductApplyOptions, type StoreProductPlanFile, type StoreProductPlanOptions, type StoreProductPorts, type StoreProductStore, } from './store-products-command.js';
13
+ export { applyPlayProductPlan, formatPlayProductPlan, planPlayProducts, PlayProductApplyError, type PlannedPlayProduct, type PlayProductApplyResult, type PlayProductChange, type PlayProductPlan, type PlayProductPlanInput, } from './store-products-play.js';
14
+ export { AppStoreProductApplyError, applyAppStoreProductPlan, formatAppStoreProductPlan, planAppStoreProducts, type AppStoreProductApplyResult, type AppStoreProductChange, type AppStoreProductPlan, type AppStoreProductPlanInput, type PlannedAppStoreProduct, } from './store-products-app-store.js';
15
+ export { StoreProductPlanStaleError } from './store-products.js';
12
16
  export { PlayPromoteUncertainError, promotePlayRelease, type PlayPromoteInput, type PlayPromoteResult, } from './play-release-promote.js';
13
17
  export { PlayRolloutUncertainError, changePlayRollout, type PlayRolloutAction, type PlayRolloutInput, type PlayRolloutResult, } from './play-release-rollout.js';
14
18
  export { IosSubmissionUncertainError, submitVerifiedIosBuild, type IosTestFlightStatus, type IosTestFlightSubmissionInput, type IosTestFlightSubmissionResult, } from './testflight-submission.js';
package/dist/index.js CHANGED
@@ -36,6 +36,9 @@ import { readStoreCredential, runNativeDeployment } from './native-deploy-run.js
36
36
  import { isNativeDeployApproved, planRecordedAndroidSubmission, submitRecordedNativeTarget, } from './native-deploy-submission.js';
37
37
  import { createPlayReleaseRequest, parsePlayUserFraction, readPlayReleaseNotesFile, } from './play-release-options.js';
38
38
  import { promotePlayRelease } from './play-release-promote.js';
39
+ import { applyStoreProducts, planStoreProducts, saveStoreProductPlan, } from './store-products-command.js';
40
+ import { formatAppStoreProductPlan } from './store-products-app-store.js';
41
+ import { formatPlayProductPlan } from './store-products-play.js';
39
42
  import { changePlayRollout } from './play-release-rollout.js';
40
43
  import { readNativeReleaseStatus as readNativeReleaseStatusForCli } from './release-state.js';
41
44
  import { uploadRecordedCrashSymbols } from './crash-symbols-upload.js';
@@ -50,6 +53,10 @@ export { runNativeDeployment, readStoreCredential, } from './native-deploy-run.j
50
53
  export { planRecordedAndroidSubmission, submitRecordedNativeTarget, } from './native-deploy-submission.js';
51
54
  export { PlaySubmissionUncertainError, planVerifiedAndroidBundleSubmission, submitVerifiedAndroidBundle, } from './play-internal-submission.js';
52
55
  export { createPlayReleaseRequest, defaultPlayReleaseRequest, maximumPlayReleaseNoteCharacters, parsePlayReleaseNotes, } from './play-release-options.js';
56
+ export { applyStoreProducts, planStoreProducts, saveStoreProductPlan, } from './store-products-command.js';
57
+ export { applyPlayProductPlan, formatPlayProductPlan, planPlayProducts, PlayProductApplyError, } from './store-products-play.js';
58
+ export { AppStoreProductApplyError, applyAppStoreProductPlan, formatAppStoreProductPlan, planAppStoreProducts, } from './store-products-app-store.js';
59
+ export { StoreProductPlanStaleError } from './store-products.js';
53
60
  export { PlayPromoteUncertainError, promotePlayRelease, } from './play-release-promote.js';
54
61
  export { PlayRolloutUncertainError, changePlayRollout, } from './play-release-rollout.js';
55
62
  export { IosSubmissionUncertainError, submitVerifiedIosBuild, } from './testflight-submission.js';
@@ -210,6 +217,7 @@ export async function runMpgdCli(args) {
210
217
  assets: assetsCommand,
211
218
  game: gameCommand,
212
219
  deploy: deployCommand,
220
+ store: storeCommand,
213
221
  legal: legalCommand,
214
222
  target: targetCommand,
215
223
  kit: kitCommand,
@@ -262,7 +270,7 @@ const entryCommand = defineI18n({
262
270
  ko: 'mpgd-kit 스타터와 타깃 워크플로우를 관리합니다.',
263
271
  }),
264
272
  run: () => {
265
- console.info('Use a sub-command: assets, game, deploy, legal, target, kit.');
273
+ console.info('Use a sub-command: assets, game, deploy, store, legal, target, kit.');
266
274
  console.info('Run "pnpm mpgd --help" for available commands.');
267
275
  },
268
276
  });
@@ -743,6 +751,92 @@ const deployCommand = defineI18n({
743
751
  }),
744
752
  },
745
753
  });
754
+ const storeCommand = defineI18n({
755
+ name: 'store',
756
+ description: 'Sync store-side configuration from game files.',
757
+ resource: commandResource({
758
+ en: 'Sync store-side configuration from game files.',
759
+ ko: '게임 파일에서 스토어 쪽 설정을 동기화합니다.',
760
+ }),
761
+ subCommands: {
762
+ products: defineI18n({
763
+ name: 'products',
764
+ description: 'Plan and apply one-time products from the catalog and store listing file.',
765
+ resource: commandResource({
766
+ en: 'Plan and apply one-time products from the catalog and store listing file.',
767
+ ko: '카탈로그와 스토어 리스팅 파일로 일회성 상품을 계획하고 적용합니다.',
768
+ }),
769
+ subCommands: {
770
+ plan: defineI18n({
771
+ name: 'plan',
772
+ description: 'Read the store products and print the changes; never writes to the store.',
773
+ resource: commandResource({
774
+ en: 'Read the store products and print the changes; never writes to the store.',
775
+ ko: '스토어 상품을 읽고 변경 사항을 출력합니다. 스토어에 쓰지 않습니다.',
776
+ }),
777
+ args: {
778
+ game: { type: 'string', required: false, description: 'Game project directory.' },
779
+ profile: { type: 'string', required: false, description: 'Deployment profile naming the app and credential; defaults to beta.' },
780
+ store: { type: 'string', required: true, description: 'google-play or app-store.' },
781
+ catalog: { type: 'string', required: false, description: 'Product catalog path, relative to the current directory; defaults to mpgd.catalog.json in the game.' },
782
+ listings: { type: 'string', required: false, description: 'Store listing file path, relative to the current directory; defaults to mpgd.store-listings.json in the game.' },
783
+ 'catalog-target': { type: 'string', required: false, description: 'Catalog platformProductIds key of the store; defaults to android (Google Play) or ios (App Store).' },
784
+ 'keep-live-prices': { type: 'boolean', required: false, description: 'Leave the prices of products players can already buy unchanged.' },
785
+ out: { type: 'string', required: false, description: 'New plan JSON path for apply; existing files are not overwritten.' },
786
+ },
787
+ run: async (ctx) => {
788
+ const catalog = readOptionalString(ctx.values.catalog);
789
+ const listings = readOptionalString(ctx.values.listings);
790
+ const catalogTarget = readOptionalString(ctx.values['catalog-target']);
791
+ const result = await planStoreProducts({
792
+ game: readOptionalString(ctx.values.game) ?? '.',
793
+ profile: readOptionalString(ctx.values.profile) ?? 'beta',
794
+ store: readRequiredCliOption(ctx.values.store, '--store'),
795
+ ...(catalog === undefined ? {} : { catalogFile: catalog }),
796
+ ...(listings === undefined ? {} : { listingsFile: listings }),
797
+ ...(catalogTarget === undefined ? {} : { catalogTarget }),
798
+ ...(ctx.values['keep-live-prices'] === true ? { keepLivePrices: true } : {}),
799
+ });
800
+ console.info(result.plan.store === 'google-play'
801
+ ? formatPlayProductPlan(result.plan)
802
+ : formatAppStoreProductPlan(result.plan));
803
+ const out = readOptionalString(ctx.values.out);
804
+ if (out !== undefined) {
805
+ console.info(`Wrote store product plan: ${saveStoreProductPlan(out, result)}`);
806
+ }
807
+ },
808
+ }),
809
+ apply: defineI18n({
810
+ name: 'apply',
811
+ description: 'Write the reviewed plan; refuses when the store or the files changed since.',
812
+ resource: commandResource({
813
+ en: 'Write the reviewed plan; refuses when the store or the files changed since.',
814
+ ko: '검토한 계획을 적용합니다. 이후 스토어나 파일이 바뀌었으면 거부합니다.',
815
+ }),
816
+ args: {
817
+ plan: { type: 'string', required: true, description: 'Plan JSON written by "store products plan --out".' },
818
+ game: { type: 'string', required: false, description: 'Current game directory if the game moved.' },
819
+ approve: { type: 'boolean', required: false, description: 'Approve the store writes.' },
820
+ 'confirm-price-change': { type: 'boolean', required: false, description: 'Confirm price changes on products players can already buy.' },
821
+ },
822
+ run: async (ctx) => {
823
+ const game = readOptionalString(ctx.values.game);
824
+ const result = await applyStoreProducts({
825
+ planFile: readRequiredCliOption(ctx.values.plan, '--plan'),
826
+ ...(game === undefined ? {} : { game }),
827
+ approved: ctx.values.approve === true,
828
+ ...(ctx.values['confirm-price-change'] === true ? { confirmPriceChange: true } : {}),
829
+ });
830
+ console.info(JSON.stringify(result, null, 2));
831
+ },
832
+ }),
833
+ },
834
+ }),
835
+ },
836
+ run: () => {
837
+ console.info('Use "mpgd store products plan" or "mpgd store products apply".');
838
+ },
839
+ });
746
840
  const gameCommand = defineI18n({
747
841
  name: 'game',
748
842
  description: 'Create standalone Phaser game starters.',
@@ -1,5 +1,5 @@
1
1
  {
2
- "kitGitSha": "ea82dfadb6cdcd6b842b753f4ce10b52ad1a1fb4",
2
+ "kitGitSha": "9492eb3679a942e0556e821ce45245db82974640",
3
3
  "kitDirty": false,
4
- "packageVersion": "0.62.0"
4
+ "packageVersion": "0.63.0"
5
5
  }