@mpgd/cli 0.67.0 → 0.68.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,39 @@
1
+ import type { AscIapPort } from './app-store-iap-port.js';
2
+ import type { AscStorePresencePort } from './app-store-presence-port.js';
3
+ import type { AscReleasePort } from './app-store-release-port.js';
4
+ import { type AscCommandRunner, type AscCredential } from './asc-session.js';
5
+ /**
6
+ * The release port on the pinned `asc` 5.5.0 CLI. Commands (5.5.0 source,
7
+ * `internal/cli/...`):
8
+ *
9
+ * - `apps view` (`apps/apps.go`)
10
+ * - `versions list|create|update|attach-build|release` and
11
+ * `versions phased-release view|create|update|delete` (`versions/`)
12
+ * - `builds list` (`builds/builds_commands.go`)
13
+ * - `versions view --include-build` for the attached build
14
+ * - `review details-for-version|details-create|details-update`
15
+ * (`reviews/review_details.go`): the output withholds the demo password
16
+ * as "(redacted)"; a version without details prints `configured: false`
17
+ * - `age-rating view --app-info-id` (`agerating/age_rating.go`)
18
+ * - `review submissions-list|submissions-create|submissions-submit` and
19
+ * `review items-list|items-add` (`reviews/`): items are listed with
20
+ * `--include`, which is what makes App Store Connect return their
21
+ * version relationships (asc's own `submit` does the same)
22
+ *
23
+ * asc takes the demo account password only as a command-line flag, so it is
24
+ * visible in the local process list while that one `asc` call runs; it is
25
+ * redacted from the captured output and never written to a file.
26
+ */
27
+ export interface AscReleaseSession {
28
+ readonly release: AscReleasePort;
29
+ readonly presence: AscStorePresencePort;
30
+ readonly iap: AscIapPort;
31
+ close(): void;
32
+ }
33
+ /** Copy and verify the pinned binary; `close` removes it. */
34
+ export declare function openAscReleaseSession(credential: AscCredential, options?: {
35
+ readonly signal?: AbortSignal;
36
+ readonly secretValues?: readonly string[];
37
+ }): Promise<AscReleaseSession>;
38
+ /** The port over any runner; the tests drive it with recorded asc output. */
39
+ export declare function createAscReleasePort(run: AscCommandRunner): AscReleasePort;
@@ -0,0 +1,274 @@
1
+ import { createAscIapPort } from './app-store-iap-asc.js';
2
+ import { createAscStorePresencePort } from './app-store-presence-asc.js';
3
+ import { ascExitNotFound, ascListTimeoutMs, ascLookupTimeoutMs, isEmptyResource, isRecord, list, openAscSession, optionalString, relationshipId, requiredString, single, } from './asc-session.js';
4
+ import { ReleaseProcessError } from './deploy-process.js';
5
+ /** Copy and verify the pinned binary; `close` removes it. */
6
+ export async function openAscReleaseSession(credential, options = {}) {
7
+ const session = await openAscSession(credential, options.signal, options.secretValues);
8
+ return {
9
+ release: createAscReleasePort(session.run),
10
+ presence: createAscStorePresencePort(session.run),
11
+ iap: createAscIapPort(session.run),
12
+ close: () => session.close(),
13
+ };
14
+ }
15
+ // AgeRatingDeclaration content descriptors (App Store Connect API
16
+ // `AgeRatingDeclaration.Attributes`); a questionnaire never answered has none.
17
+ const ageRatingDescriptors = [
18
+ 'advertising',
19
+ 'alcoholTobaccoOrDrugUseOrReferences',
20
+ 'ageAssurance',
21
+ 'contests',
22
+ 'gambling',
23
+ 'gamblingSimulated',
24
+ 'gunsOrOtherWeapons',
25
+ 'healthOrWellnessTopics',
26
+ 'horrorOrFearThemes',
27
+ 'lootBox',
28
+ 'matureOrSuggestiveThemes',
29
+ 'medicalOrTreatmentInformation',
30
+ 'messagingAndChat',
31
+ 'parentalControls',
32
+ 'profanityOrCrudeHumor',
33
+ 'sexualContentGraphicAndNudity',
34
+ 'sexualContentOrNudity',
35
+ 'unrestrictedWebAccess',
36
+ 'userGeneratedContent',
37
+ 'violenceCartoonOrFantasy',
38
+ 'violenceRealistic',
39
+ 'violenceRealisticProlongedGraphicOrSadistic',
40
+ ];
41
+ /** The port over any runner; the tests drive it with recorded asc output. */
42
+ export function createAscReleasePort(run) {
43
+ const optional = async (args) => {
44
+ try {
45
+ return await run(args, ascLookupTimeoutMs);
46
+ }
47
+ catch (error) {
48
+ if (error instanceof ReleaseProcessError && error.exitCode === ascExitNotFound) {
49
+ return undefined;
50
+ }
51
+ throw error;
52
+ }
53
+ };
54
+ return {
55
+ async readApp(appId) {
56
+ const app = single(await run(['apps', 'view', '--id', appId], ascLookupTimeoutMs), 'apps view');
57
+ if (app.id !== appId) {
58
+ throw new Error('asc apps view returned another app.');
59
+ }
60
+ return {
61
+ bundleId: requiredString(app.attributes.bundleId, 'app bundle ID'),
62
+ ...optionalString('primaryLocale', app.attributes.primaryLocale),
63
+ };
64
+ },
65
+ async listVersions(appId) {
66
+ return list(await run(['versions', 'list', '--app', appId, '--platform', 'IOS', '--paginate'], ascListTimeoutMs), 'versions list').map((resource) => ({
67
+ id: resource.id,
68
+ versionString: requiredString(resource.attributes.versionString, 'version string'),
69
+ ...optionalString('state', resource.attributes.appVersionState ?? resource.attributes.appStoreState),
70
+ ...optionalString('appStoreState', resource.attributes.appStoreState),
71
+ ...optionalString('releaseType', resource.attributes.releaseType),
72
+ ...optionalString('earliestReleaseDate', resource.attributes.earliestReleaseDate),
73
+ ...optionalString('copyright', resource.attributes.copyright),
74
+ ...optionalString('createdDate', resource.attributes.createdDate),
75
+ }));
76
+ },
77
+ async listBuilds(input) {
78
+ return list(await run([
79
+ 'builds', 'list',
80
+ '--app', input.appId,
81
+ '--version', input.marketingVersion,
82
+ ...(input.buildNumber === undefined ? [] : ['--build-number', input.buildNumber]),
83
+ '--platform', 'IOS',
84
+ '--paginate',
85
+ ], ascListTimeoutMs), 'builds list').map((resource) => ({
86
+ id: resource.id,
87
+ buildNumber: requiredString(resource.attributes.version, 'build number'),
88
+ ...optionalString('processingState', resource.attributes.processingState),
89
+ ...(typeof resource.attributes.expired === 'boolean' ? { expired: resource.attributes.expired } : {}),
90
+ ...(typeof resource.attributes.usesNonExemptEncryption === 'boolean'
91
+ ? { usesNonExemptEncryption: resource.attributes.usesNonExemptEncryption }
92
+ : {}),
93
+ ...optionalString('uploadedDate', resource.attributes.uploadedDate),
94
+ }));
95
+ },
96
+ async readAttachedBuild(versionId) {
97
+ const result = await run(['versions', 'view', '--version-id', versionId, '--include-build'], ascLookupTimeoutMs);
98
+ if (!isRecord(result) || result.id !== versionId) {
99
+ throw new Error('asc versions view returned another version.');
100
+ }
101
+ return typeof result.buildId === 'string' && result.buildId !== ''
102
+ ? { id: result.buildId, ...optionalString('buildNumber', result.buildVersion) }
103
+ : undefined;
104
+ },
105
+ async readReviewDetail(versionId) {
106
+ const response = await optional(['review', 'details-for-version', '--version-id', versionId]);
107
+ if (response === undefined || isEmptyResource(response)
108
+ || (isRecord(response) && response.configured === false)) {
109
+ return undefined;
110
+ }
111
+ const detail = single(response, 'review details-for-version');
112
+ const text = (key) => optionalString(key, detail.attributes[key]);
113
+ return {
114
+ id: detail.id,
115
+ ...text('contactFirstName'),
116
+ ...text('contactLastName'),
117
+ ...text('contactEmail'),
118
+ ...text('contactPhone'),
119
+ ...text('demoAccountName'),
120
+ ...text('notes'),
121
+ demoAccountPasswordSet: typeof detail.attributes.demoAccountPassword === 'string'
122
+ && detail.attributes.demoAccountPassword !== '',
123
+ demoAccountRequired: detail.attributes.demoAccountRequired === true,
124
+ };
125
+ },
126
+ async readPhasedRelease(versionId) {
127
+ const response = await optional(['versions', 'phased-release', 'view', '--version-id', versionId]);
128
+ if (response === undefined || isEmptyResource(response)) {
129
+ return undefined;
130
+ }
131
+ const phased = single(response, 'versions phased-release view');
132
+ return {
133
+ id: phased.id,
134
+ state: requiredString(phased.attributes.phasedReleaseState, 'phased release state'),
135
+ ...(typeof phased.attributes.currentDayNumber === 'number'
136
+ ? { currentDayNumber: phased.attributes.currentDayNumber }
137
+ : {}),
138
+ ...optionalString('startDate', phased.attributes.startDate),
139
+ };
140
+ },
141
+ async readAgeRating(appInfoId) {
142
+ const response = await optional(['age-rating', 'view', '--app-info-id', appInfoId]);
143
+ if (response === undefined || isEmptyResource(response)) {
144
+ return undefined;
145
+ }
146
+ const declaration = single(response, 'age-rating view');
147
+ return {
148
+ id: declaration.id,
149
+ answered: ageRatingDescriptors.filter((key) => {
150
+ const value = declaration.attributes[key];
151
+ return typeof value === 'boolean' || (typeof value === 'string' && value !== '');
152
+ }),
153
+ };
154
+ },
155
+ async listReviewSubmissions(appId) {
156
+ return list(await run(['review', 'submissions-list', '--app', appId, '--platform', 'IOS', '--paginate'], ascListTimeoutMs), 'review submissions-list').map((resource) => ({
157
+ id: resource.id,
158
+ state: requiredString(resource.attributes.state, 'review submission state'),
159
+ ...optionalString('submittedDate', resource.attributes.submittedDate),
160
+ }));
161
+ },
162
+ async listReviewSubmissionItems(submissionId) {
163
+ return list(await run([
164
+ 'review', 'items-list',
165
+ '--submission', submissionId,
166
+ '--include', 'appStoreVersion,inAppPurchaseVersion',
167
+ '--paginate',
168
+ ], ascListTimeoutMs), 'review items-list').map((resource) => ({
169
+ id: resource.id,
170
+ ...optionalString('state', resource.attributes.state),
171
+ ...optionalString('appStoreVersionId', relationshipId(resource.relationships, 'appStoreVersion')),
172
+ ...optionalString('inAppPurchaseVersionId', relationshipId(resource.relationships, 'inAppPurchaseVersion')),
173
+ }));
174
+ },
175
+ async createVersion(input) {
176
+ const result = await run([
177
+ 'versions', 'create',
178
+ '--app', input.appId,
179
+ '--version', input.versionString,
180
+ '--platform', 'IOS',
181
+ '--release-type', input.releaseType,
182
+ ...(input.copyright === undefined ? [] : ['--copyright', input.copyright]),
183
+ ], ascLookupTimeoutMs);
184
+ if (!isRecord(result) || typeof result.id !== 'string' || result.id === ''
185
+ || result.versionString !== input.versionString) {
186
+ throw new Error('asc versions create returned no version.');
187
+ }
188
+ return result.id;
189
+ },
190
+ async updateVersion(input) {
191
+ const flags = [
192
+ ...(input.versionString === undefined ? [] : ['--version', input.versionString]),
193
+ ...(input.copyright === undefined ? [] : ['--copyright', input.copyright]),
194
+ ...(input.releaseType === undefined ? [] : ['--release-type', input.releaseType]),
195
+ ...(input.earliestReleaseDate === undefined ? [] : ['--earliest-release-date', input.earliestReleaseDate]),
196
+ ];
197
+ if (flags.length === 0) {
198
+ return;
199
+ }
200
+ await run(['versions', 'update', '--version-id', input.versionId, ...flags], ascLookupTimeoutMs);
201
+ },
202
+ async attachBuild(input) {
203
+ const result = await run(['versions', 'attach-build', '--version-id', input.versionId, '--build-id', input.buildId], ascLookupTimeoutMs);
204
+ if (!isRecord(result) || result.attached !== true || result.buildId !== input.buildId) {
205
+ throw new Error('asc versions attach-build did not confirm the build.');
206
+ }
207
+ },
208
+ async createReviewDetail(input) {
209
+ await run(['review', 'details-create', '--version-id', input.versionId, ...reviewDetailFlags(input.fields)], ascLookupTimeoutMs);
210
+ },
211
+ async updateReviewDetail(input) {
212
+ const flags = reviewDetailFlags(input.fields);
213
+ if (flags.length === 0) {
214
+ return;
215
+ }
216
+ await run(['review', 'details-update', '--id', input.detailId, ...flags], ascLookupTimeoutMs);
217
+ },
218
+ async createPhasedRelease(versionId) {
219
+ await run(['versions', 'phased-release', 'create', '--version-id', versionId, '--state', 'INACTIVE'], ascLookupTimeoutMs);
220
+ },
221
+ async deletePhasedRelease(phasedReleaseId) {
222
+ await run(['versions', 'phased-release', 'delete', '--id', phasedReleaseId, '--confirm'], ascLookupTimeoutMs);
223
+ },
224
+ async updatePhasedRelease(input) {
225
+ await run([
226
+ 'versions', 'phased-release', 'update',
227
+ '--id', input.phasedReleaseId,
228
+ '--state', input.state,
229
+ ...(input.state === 'COMPLETE' ? ['--confirm'] : []),
230
+ ], ascLookupTimeoutMs);
231
+ },
232
+ async createReviewSubmission(appId) {
233
+ return single(await run(['review', 'submissions-create', '--app', appId, '--platform', 'IOS'], ascLookupTimeoutMs), 'review submissions-create').id;
234
+ },
235
+ async addReviewSubmissionItem(input) {
236
+ await run([
237
+ 'review', 'items-add',
238
+ '--submission', input.submissionId,
239
+ '--item-type', input.type,
240
+ '--item-id', input.id,
241
+ ], ascLookupTimeoutMs);
242
+ },
243
+ async submitReviewSubmission(submissionId) {
244
+ await run(['review', 'submissions-submit', '--id', submissionId, '--confirm'], ascLookupTimeoutMs);
245
+ },
246
+ async releaseVersion(versionId) {
247
+ const result = await run(['versions', 'release', '--version-id', versionId, '--confirm'], ascLookupTimeoutMs);
248
+ if (!isRecord(result) || result.versionId !== versionId || typeof result.releaseRequestId !== 'string') {
249
+ throw new Error('asc versions release returned no release request.');
250
+ }
251
+ },
252
+ };
253
+ }
254
+ const reviewDetailFlagNames = [
255
+ ['contactFirstName', '--contact-first-name'],
256
+ ['contactLastName', '--contact-last-name'],
257
+ ['contactEmail', '--contact-email'],
258
+ ['contactPhone', '--contact-phone'],
259
+ ['notes', '--notes'],
260
+ ['demoAccountName', '--demo-account-name'],
261
+ ['demoAccountPassword', '--demo-account-password'],
262
+ ];
263
+ function reviewDetailFlags(fields) {
264
+ return [
265
+ ...reviewDetailFlagNames.flatMap(([field, flag]) => {
266
+ const value = fields[field];
267
+ return value === undefined ? [] : [flag, value];
268
+ }),
269
+ // A Go boolean flag takes its value only in the `--flag=value` form.
270
+ ...(fields.demoAccountRequired === undefined
271
+ ? []
272
+ : [`--demo-account-required=${String(fields.demoAccountRequired)}`]),
273
+ ];
274
+ }
@@ -0,0 +1,73 @@
1
+ import { type AscReleaseSession } from './app-store-release-asc.js';
2
+ import { changeAppStorePhasedRelease, publishAppStoreVersion, type AppStoreReleaseApplyResult, type AppStoreReleasePlan, type AppStoreReleaseRequest, type AppStoreReleaseStatus } from './app-store-release.js';
3
+ import type { AscCredential } from './asc-session.js';
4
+ import { readNativeReleaseStatus } from './release-state.js';
5
+ /**
6
+ * `mpgd store release plan|apply|status|publish|phased`: the deployment
7
+ * profile names the App Store Connect app and the asc credential (the same
8
+ * ones `deploy submit` uses); `mpgd.store-presence.json` names What's New,
9
+ * the copyright line and App Review information; `mpgd.targets.json` says
10
+ * whether the app runs on iPad; the release ledger names the build
11
+ * `deploy run` recorded.
12
+ */
13
+ export interface AppStoreReleaseCommandPorts {
14
+ readonly appStore: (credential: AscCredential, options: {
15
+ readonly signal?: AbortSignal;
16
+ readonly secretValues?: readonly string[];
17
+ }) => Promise<AscReleaseSession>;
18
+ readonly readReleaseStatus: typeof readNativeReleaseStatus;
19
+ readonly now: () => Date;
20
+ }
21
+ export type ReleaseTypeOption = 'manual' | 'after-approval' | 'scheduled';
22
+ export interface AppStoreReleasePlanOptions {
23
+ readonly game: string;
24
+ readonly profile: string;
25
+ readonly presenceFile?: string;
26
+ /** The marketing version; optional with `release`, whose recorded build names it. */
27
+ readonly version?: string;
28
+ readonly buildNumber?: string;
29
+ /** Release ledger game ID and release ID of the build `deploy run` recorded. */
30
+ readonly gameId?: string;
31
+ readonly release?: string;
32
+ readonly releaseType?: ReleaseTypeOption;
33
+ readonly releaseDate?: string;
34
+ readonly phasedRelease?: boolean;
35
+ readonly skipIaps?: readonly string[];
36
+ readonly rotateDemoPassword?: boolean;
37
+ readonly signal?: AbortSignal;
38
+ }
39
+ /** The saved plan: the inputs apply re-reads, and the reviewed plan. No secret is saved. */
40
+ export interface AppStoreReleasePlanFile {
41
+ readonly schemaVersion: 1;
42
+ readonly kind: 'mpgd-store-release-plan';
43
+ readonly gameRoot: string;
44
+ readonly profile: string;
45
+ readonly presenceFile: string;
46
+ readonly request: AppStoreReleaseRequest;
47
+ readonly plan: AppStoreReleasePlan;
48
+ }
49
+ export declare function planAppStoreReleaseCommand(options: AppStoreReleasePlanOptions, environment?: NodeJS.ProcessEnv, overrides?: Partial<AppStoreReleaseCommandPorts>): Promise<AppStoreReleasePlanFile>;
50
+ export declare function saveAppStoreReleasePlan(file: string, plan: AppStoreReleasePlanFile): string;
51
+ export interface AppStoreReleaseApplyOptions {
52
+ readonly planFile: string;
53
+ /** Current game directory, when the game moved since the plan. */
54
+ readonly game?: string;
55
+ readonly approved: boolean;
56
+ readonly dryRun?: boolean;
57
+ readonly signal?: AbortSignal;
58
+ }
59
+ export declare function applyAppStoreReleaseCommand(options: AppStoreReleaseApplyOptions, environment?: NodeJS.ProcessEnv, overrides?: Partial<AppStoreReleaseCommandPorts>): Promise<AppStoreReleaseApplyResult>;
60
+ export interface AppStoreReleaseVersionOptions {
61
+ readonly game: string;
62
+ readonly profile: string;
63
+ readonly version?: string;
64
+ readonly signal?: AbortSignal;
65
+ }
66
+ export declare function readAppStoreReleaseStatusCommand(options: AppStoreReleaseVersionOptions, environment?: NodeJS.ProcessEnv, overrides?: Partial<AppStoreReleaseCommandPorts>): Promise<AppStoreReleaseStatus>;
67
+ export declare function publishAppStoreVersionCommand(options: AppStoreReleaseVersionOptions & {
68
+ readonly approved: boolean;
69
+ }, environment?: NodeJS.ProcessEnv, overrides?: Partial<AppStoreReleaseCommandPorts>): Promise<Awaited<ReturnType<typeof publishAppStoreVersion>>>;
70
+ export declare function changeAppStorePhasedReleaseCommand(options: AppStoreReleaseVersionOptions & {
71
+ readonly action: string;
72
+ readonly approved: boolean;
73
+ }, environment?: NodeJS.ProcessEnv, overrides?: Partial<AppStoreReleaseCommandPorts>): Promise<Awaited<ReturnType<typeof changeAppStorePhasedRelease>>>;
@@ -0,0 +1,280 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { openAscReleaseSession } from './app-store-release-asc.js';
4
+ import { applyAppStoreRelease, appStoreReleasePlanCommand, changeAppStorePhasedRelease, planAppStoreRelease, publishAppStoreVersion, readAppStoreReleaseStatus, } from './app-store-release.js';
5
+ import { readIosDeviceFamily } from './native-shell-store-policy.js';
6
+ import { readNativeReleaseStatus } from './release-state.js';
7
+ import { readStorePresenceSource } from './store-presence.js';
8
+ import { readJsonFile, readStorePlanFile, relocateStoreSyncFile, resolveStoreSyncApp, StorePlanStaleError, writeStorePlanFile, } from './store-sync.js';
9
+ const defaultPorts = {
10
+ appStore: openAscReleaseSession,
11
+ readReleaseStatus: readNativeReleaseStatus,
12
+ now: () => new Date(),
13
+ };
14
+ const releaseTypes = {
15
+ manual: 'MANUAL',
16
+ 'after-approval': 'AFTER_APPROVAL',
17
+ scheduled: 'SCHEDULED',
18
+ };
19
+ export async function planAppStoreReleaseCommand(options, environment = process.env, overrides = {}) {
20
+ const ports = { ...defaultPorts, ...overrides };
21
+ const gameRoot = path.resolve(options.game);
22
+ const source = readPresence(gameRoot, options.presenceFile);
23
+ const request = await resolveRequest(options, gameRoot, environment, ports);
24
+ const plan = await withSession({ game: gameRoot, profile: options.profile }, source.presence, environment, ports, options.signal, (session, app, demo) => planAppStoreRelease({
25
+ appId: app.appId,
26
+ bundleId: app.bundleId,
27
+ presence: source.presence,
28
+ request,
29
+ ...(demo.account === undefined ? {} : { demoAccount: demo.account }),
30
+ demoAccountProblems: demo.problems,
31
+ now: ports.now,
32
+ }, session));
33
+ return {
34
+ schemaVersion: 1,
35
+ kind: 'mpgd-store-release-plan',
36
+ gameRoot,
37
+ profile: options.profile,
38
+ presenceFile: source.file,
39
+ request,
40
+ plan,
41
+ };
42
+ }
43
+ export function saveAppStoreReleasePlan(file, plan) {
44
+ return writeStorePlanFile(file, plan);
45
+ }
46
+ export async function applyAppStoreReleaseCommand(options, environment = process.env, overrides = {}) {
47
+ if (!options.approved && options.dryRun !== true) {
48
+ throw new Error('Submitting the App Store release requires --approve (or --dry-run to print it).');
49
+ }
50
+ const ports = { ...defaultPorts, ...overrides };
51
+ const saved = readSavedPlan(options.planFile);
52
+ const gameRoot = options.game === undefined ? saved.gameRoot : path.resolve(options.game);
53
+ const presenceFile = relocateStoreSyncFile(saved.gameRoot, options.game === undefined ? undefined : gameRoot, saved.presenceFile);
54
+ const source = readPresence(gameRoot, presenceFile);
55
+ // The universal flag comes from the game again: a changed target is a new plan.
56
+ const request = { ...saved.request, universal: readUniversal(gameRoot) };
57
+ return withSession({ game: gameRoot, profile: saved.profile }, source.presence, environment, ports, options.signal, (session, app, demo) => applyAppStoreRelease({
58
+ saved: saved.plan,
59
+ plan: {
60
+ appId: app.appId,
61
+ bundleId: app.bundleId,
62
+ presence: source.presence,
63
+ request,
64
+ ...(demo.account === undefined ? {} : { demoAccount: demo.account }),
65
+ demoAccountProblems: demo.problems,
66
+ now: ports.now,
67
+ },
68
+ ports: session,
69
+ approved: options.approved,
70
+ ...(options.dryRun === true ? { dryRun: true } : {}),
71
+ }));
72
+ }
73
+ export async function readAppStoreReleaseStatusCommand(options, environment = process.env, overrides = {}) {
74
+ const ports = { ...defaultPorts, ...overrides };
75
+ return withSession({ game: path.resolve(options.game), profile: options.profile }, undefined, environment, ports, options.signal, (session, app) => readAppStoreReleaseStatus({
76
+ appId: app.appId,
77
+ bundleId: app.bundleId,
78
+ ...(options.version === undefined ? {} : { marketingVersion: options.version }),
79
+ }, session.release));
80
+ }
81
+ export async function publishAppStoreVersionCommand(options, environment = process.env, overrides = {}) {
82
+ const marketingVersion = requireVersion(options.version);
83
+ if (!options.approved) {
84
+ throw new Error('Releasing the App Store version requires --approve.');
85
+ }
86
+ const ports = { ...defaultPorts, ...overrides };
87
+ return withSession({ game: path.resolve(options.game), profile: options.profile }, undefined, environment, ports, options.signal, (session, app) => publishAppStoreVersion({
88
+ appId: app.appId,
89
+ bundleId: app.bundleId,
90
+ marketingVersion,
91
+ approved: true,
92
+ }, session.release));
93
+ }
94
+ export async function changeAppStorePhasedReleaseCommand(options, environment = process.env, overrides = {}) {
95
+ const marketingVersion = requireVersion(options.version);
96
+ if (!['pause', 'resume', 'complete'].includes(options.action)) {
97
+ throw new Error('--action must be pause, resume or complete.');
98
+ }
99
+ if (!options.approved) {
100
+ throw new Error('Changing the phased release requires --approve.');
101
+ }
102
+ const ports = { ...defaultPorts, ...overrides };
103
+ return withSession({ game: path.resolve(options.game), profile: options.profile }, undefined, environment, ports, options.signal, (session, app) => changeAppStorePhasedRelease({
104
+ appId: app.appId,
105
+ bundleId: app.bundleId,
106
+ marketingVersion,
107
+ action: options.action,
108
+ approved: true,
109
+ }, session.release));
110
+ }
111
+ function requireVersion(version) {
112
+ if (version === undefined || version === '') {
113
+ throw new Error('--marketing-version is required.');
114
+ }
115
+ return version;
116
+ }
117
+ function readPresence(gameRoot, presenceFile) {
118
+ const source = readStorePresenceSource({
119
+ gameRoot,
120
+ ...(presenceFile === undefined ? {} : { presenceFile }),
121
+ });
122
+ if (source.presence.appStore === undefined) {
123
+ throw new Error(`${source.file} has no appStore section.`);
124
+ }
125
+ return source;
126
+ }
127
+ /**
128
+ * Whether the app runs on iPad: `mpgd.targets.json` ios `metadata.iosDeviceFamily`.
129
+ * Unset counts as universal, the device family of the Capacitor iOS template.
130
+ */
131
+ function readUniversal(gameRoot) {
132
+ const file = path.join(gameRoot, 'mpgd.targets.json');
133
+ if (!existsSync(file)) {
134
+ return true;
135
+ }
136
+ const targets = readJsonFile(file);
137
+ const ios = isRecord(targets) && isRecord(targets.targets) ? targets.targets.ios : undefined;
138
+ return readIosDeviceFamily(isRecord(ios) ? ios.metadata : undefined) !== 'iphone';
139
+ }
140
+ async function resolveRequest(options, gameRoot, environment, ports) {
141
+ let marketingVersion = options.version;
142
+ let buildNumber = options.buildNumber;
143
+ let recordedBuildId;
144
+ if ((options.gameId === undefined) !== (options.release === undefined)) {
145
+ throw new Error('--game-id and --release go together.');
146
+ }
147
+ if (options.gameId !== undefined && options.release !== undefined) {
148
+ const status = await ports.readReleaseStatus({
149
+ gameRoot,
150
+ gameId: options.gameId,
151
+ releaseKey: options.release,
152
+ environment,
153
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
154
+ });
155
+ const record = status.builds.ios;
156
+ if (record === undefined) {
157
+ throw new Error(`Release ${options.release} has no recorded iOS build.`);
158
+ }
159
+ const recordedVersion = record.platformVersion.marketingVersion;
160
+ const recordedBuild = record.platformVersion.buildNumber;
161
+ if (typeof recordedVersion !== 'string' || (typeof recordedBuild !== 'string' && typeof recordedBuild !== 'number')) {
162
+ throw new Error(`Release ${options.release} recorded no iOS marketing version and build number.`);
163
+ }
164
+ if (marketingVersion !== undefined && marketingVersion !== recordedVersion) {
165
+ throw new Error(`--marketing-version ${marketingVersion} differs from ${recordedVersion} that release ${options.release} built.`);
166
+ }
167
+ if (buildNumber !== undefined && buildNumber !== String(recordedBuild)) {
168
+ throw new Error(`--build-number ${buildNumber} differs from ${String(recordedBuild)} that release ${options.release} built.`);
169
+ }
170
+ marketingVersion = recordedVersion;
171
+ buildNumber = String(recordedBuild);
172
+ recordedBuildId = status.submissions.ios?.remoteBuildId;
173
+ if (recordedBuildId === undefined) {
174
+ throw new Error(`Release ${options.release} recorded no App Store Connect build ID for its iOS build (the TestFlight `
175
+ + `submission is ${status.submissions.ios?.status ?? 'missing'}): finish it with "mpgd deploy submit", or `
176
+ + 'name the build with --marketing-version and --build-number.');
177
+ }
178
+ }
179
+ if (marketingVersion === undefined) {
180
+ throw new Error('Name the version with --marketing-version, or the recorded build with --game-id and --release.');
181
+ }
182
+ const releaseType = options.releaseType ?? 'manual';
183
+ if (!(releaseType in releaseTypes)) {
184
+ throw new Error('--release-type must be manual, after-approval or scheduled.');
185
+ }
186
+ return {
187
+ marketingVersion,
188
+ ...(buildNumber === undefined ? {} : { buildNumber }),
189
+ ...(recordedBuildId === undefined ? {} : { recordedBuildId }),
190
+ universal: readUniversal(gameRoot),
191
+ releaseType: releaseTypes[releaseType],
192
+ ...(options.releaseDate === undefined ? {} : { earliestReleaseDate: options.releaseDate }),
193
+ phasedRelease: options.phasedRelease === true,
194
+ skipIaps: [...new Set(options.skipIaps ?? [])].sort(),
195
+ rotateDemoPassword: options.rotateDemoPassword === true,
196
+ };
197
+ }
198
+ /**
199
+ * The demo account values from the variables the presence file names. A
200
+ * variable that is unset or unusable is a problem the plan reports as a
201
+ * blocker, so a plan can be reviewed without the secrets.
202
+ */
203
+ function readDemoAccount(presence, environment) {
204
+ const names = presence?.appStore?.review?.demoAccount;
205
+ if (names === undefined) {
206
+ return { problems: [] };
207
+ }
208
+ const problems = [];
209
+ const read = (name) => {
210
+ const value = environment[name];
211
+ if (value === undefined || value.trim() === '') {
212
+ problems.push(`appStore.review.demoAccount names ${name}, which is not set: export the demo account there, then plan again.`);
213
+ return undefined;
214
+ }
215
+ // asc trims the flag values it sends, so a value with surrounding spaces would
216
+ // reach App Review as another password: refuse it instead of changing it.
217
+ if (value.trim() !== value) {
218
+ problems.push(`${name} starts or ends with whitespace, which asc would strip before sending it to App Store Connect.`);
219
+ return undefined;
220
+ }
221
+ return value;
222
+ };
223
+ const name = read(names.nameEnv);
224
+ const password = read(names.passwordEnv);
225
+ if (password !== undefined && [...password].length > 100) {
226
+ problems.push(`${names.passwordEnv} is longer than the 100 characters App Store Connect takes.`);
227
+ }
228
+ return name === undefined || password === undefined || problems.length > 0
229
+ ? { problems }
230
+ : { account: { name, password }, problems };
231
+ }
232
+ /** One asc session per command, with the demo account redacted from its output. */
233
+ async function withSession(options, presence, environment, ports, signal, action) {
234
+ const demo = readDemoAccount(presence, environment);
235
+ const demoAccount = demo.account;
236
+ const app = resolveStoreSyncApp({ ...options, store: 'app-store' }, environment);
237
+ if (app.store !== 'app-store') {
238
+ throw new Error('The deployment profile has no App Store app.');
239
+ }
240
+ const session = await ports.appStore(app.credential, {
241
+ ...(signal === undefined ? {} : { signal }),
242
+ secretValues: demoAccount === undefined
243
+ ? []
244
+ // The password always; the user name only when long enough not to mangle unrelated text.
245
+ : [...secretForms(demoAccount.password), ...(demoAccount.name.length >= 4 ? secretForms(demoAccount.name) : [])],
246
+ });
247
+ try {
248
+ return await action(session, { appId: app.appId, bundleId: app.bundleId }, demo);
249
+ }
250
+ finally {
251
+ session.close();
252
+ }
253
+ }
254
+ /** A secret as given and as JSON escapes it, so asc's JSON error output is redacted too. */
255
+ function secretForms(value) {
256
+ return [...new Set([value, JSON.stringify(value).slice(1, -1)])];
257
+ }
258
+ function readSavedPlan(file) {
259
+ const raw = readStorePlanFile(file, 'store release plan');
260
+ const plan = raw.plan;
261
+ const request = raw.request;
262
+ if (raw.schemaVersion !== 1 || raw.kind !== 'mpgd-store-release-plan'
263
+ || typeof raw.gameRoot !== 'string' || !path.isAbsolute(raw.gameRoot)
264
+ || typeof raw.profile !== 'string' || typeof raw.presenceFile !== 'string'
265
+ || typeof request !== 'object' || request === null || typeof request.marketingVersion !== 'string'
266
+ || typeof request.releaseType !== 'string' || !Array.isArray(request.skipIaps)
267
+ || typeof plan !== 'object' || plan === null || plan.schemaVersion !== 1 || plan.store !== 'app-store'
268
+ || typeof plan.appId !== 'string' || typeof plan.bundleId !== 'string'
269
+ || typeof plan.remoteSha256 !== 'string' || typeof plan.desiredSha256 !== 'string'
270
+ || !Array.isArray(plan.operations) || !Array.isArray(plan.blockers)) {
271
+ throw new Error('Saved store release plan is malformed.');
272
+ }
273
+ if (plan.marketingVersion !== request.marketingVersion) {
274
+ throw new StorePlanStaleError('The saved plan does not match its request.', appStoreReleasePlanCommand);
275
+ }
276
+ return raw;
277
+ }
278
+ function isRecord(value) {
279
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
280
+ }