@mpgd/cli 0.58.0 → 0.61.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,237 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { isDefinitiveHttpError, isHttpStatus } from './play-internal-submission.js';
3
+ import { assertPlayReleaseConfirmed, assertPlayTrackName, assertPlayUserFraction, } from './play-release-options.js';
4
+ export const playRolloutActions = Object.freeze([
5
+ 'update',
6
+ 'halt',
7
+ 'resume',
8
+ 'complete',
9
+ ]);
10
+ export class PlayRolloutUncertainError extends Error {
11
+ stage;
12
+ editId;
13
+ versionCodes;
14
+ constructor(stage, editId,
15
+ /** The release that was being changed; pass one of them as --version-code on the re-run. */
16
+ versionCodes = []) {
17
+ super(`Google Play rollout ${stage} result is uncertain for edit ${editId}. Re-run the same `
18
+ + `command${versionCodes[0] === undefined ? '' : ` with --version-code ${versionCodes[0]}`}: `
19
+ + 'it reads the live track first and reports "unchanged" if the change landed.');
20
+ this.stage = stage;
21
+ this.editId = editId;
22
+ this.versionCodes = versionCodes;
23
+ this.name = 'PlayRolloutUncertainError';
24
+ }
25
+ }
26
+ // Without --version-code, the release an action applies to (or has already
27
+ // been applied to, so a re-run after a lost response reports "unchanged").
28
+ const sourceStatuses = {
29
+ update: ['inProgress'],
30
+ halt: ['inProgress', 'halted'],
31
+ resume: ['halted', 'inProgress'],
32
+ complete: ['inProgress', 'halted'],
33
+ };
34
+ /** Change one existing release on one named track with a service account. */
35
+ export async function changePlayRollout(input) {
36
+ if (!existsSync(input.serviceAccountFile) || !statSync(input.serviceAccountFile).isFile()) {
37
+ throw new Error('Google Play service account file is missing.');
38
+ }
39
+ assertPlayRolloutInput(input);
40
+ const { createServiceAccountPublisher } = await import(new URL('./play-sdk-adapter.js', import.meta.url).href);
41
+ return changePlayRolloutWithPublisher(input, createServiceAccountPublisher(input.serviceAccountFile));
42
+ }
43
+ export function assertPlayRolloutInput(input) {
44
+ assertPlayTrackName(input.track);
45
+ if (!playRolloutActions.includes(input.action)) {
46
+ throw new Error('--action must be update, halt, resume or complete.');
47
+ }
48
+ if (input.versionCode !== undefined
49
+ && (!Number.isSafeInteger(input.versionCode) || input.versionCode < 1)) {
50
+ throw new Error('--version-code must be a positive integer.');
51
+ }
52
+ if (input.userFraction !== undefined) {
53
+ if (input.action !== 'update' && input.action !== 'resume') {
54
+ throw new Error('--user-fraction applies only to --action update or resume.');
55
+ }
56
+ assertPlayUserFraction(input.userFraction);
57
+ }
58
+ else if (input.action === 'update') {
59
+ throw new Error('--action update needs --user-fraction.');
60
+ }
61
+ }
62
+ /** Credential-free injection point for the mock-transport tests. */
63
+ export async function changePlayRolloutWithPublisher(input, publisher) {
64
+ assertPlayRolloutInput(input);
65
+ if (input.dryRun !== true && (input.action === 'update' || input.action === 'resume')) {
66
+ // Leaves (or keeps) a staged rollout running: confirm before any request.
67
+ assertPlayReleaseConfirmed({ track: input.track, status: 'inProgress' }, input.confirmProduction === true);
68
+ }
69
+ else if (input.dryRun !== true) {
70
+ assertPlayReleaseConfirmed({ track: input.track, status: 'completed' }, input.confirmProduction === true);
71
+ }
72
+ input.signal?.throwIfAborted();
73
+ const { packageName, track: trackName } = input;
74
+ const editId = await publisher.insertEdit(packageName, input.signal);
75
+ let committed = false;
76
+ try {
77
+ let current;
78
+ try {
79
+ current = await publisher.getTrack(packageName, editId, trackName, input.signal);
80
+ }
81
+ catch (error) {
82
+ if (isHttpStatus(error, 404)) {
83
+ throw new Error(`Google Play has no ${trackName} track for ${packageName}.`);
84
+ }
85
+ throw error;
86
+ }
87
+ const releases = current.releases ?? [];
88
+ const target = selectRelease(releases, input);
89
+ const before = releaseState(target);
90
+ const changed = applyAction(target, input);
91
+ const versionCodes = [...(target.versionCodes ?? [])];
92
+ if (changed === undefined) {
93
+ return {
94
+ status: 'unchanged',
95
+ packageName,
96
+ track: trackName,
97
+ action: input.action,
98
+ versionCodes,
99
+ before,
100
+ after: before,
101
+ };
102
+ }
103
+ // Only the selected release changes; every other release goes back as Play returned it.
104
+ const desired = {
105
+ track: trackName,
106
+ releases: releases.map((release) => release === target ? changed : release),
107
+ };
108
+ const after = releaseState(changed);
109
+ if (input.dryRun === true) {
110
+ return {
111
+ status: 'dry-run',
112
+ packageName,
113
+ track: trackName,
114
+ action: input.action,
115
+ versionCodes,
116
+ before,
117
+ after,
118
+ plannedTrack: desired,
119
+ };
120
+ }
121
+ try {
122
+ await publisher.updateTrack(packageName, editId, desired, input.signal);
123
+ }
124
+ catch (error) {
125
+ if (isDefinitiveHttpError(error)) {
126
+ throw error;
127
+ }
128
+ throw new PlayRolloutUncertainError('track-update', editId, versionCodes);
129
+ }
130
+ try {
131
+ await publisher.validateEdit(packageName, editId, input.signal);
132
+ }
133
+ catch (error) {
134
+ if (isDefinitiveHttpError(error)) {
135
+ throw error;
136
+ }
137
+ throw new PlayRolloutUncertainError('validate', editId, versionCodes);
138
+ }
139
+ try {
140
+ await publisher.commitEdit(packageName, editId, input.signal);
141
+ }
142
+ catch (error) {
143
+ if (isDefinitiveHttpError(error)) {
144
+ throw error;
145
+ }
146
+ committed = true; // Unknown: never delete an edit that may have been committed.
147
+ throw new PlayRolloutUncertainError('commit', editId, versionCodes);
148
+ }
149
+ committed = true;
150
+ return {
151
+ status: 'committed',
152
+ packageName,
153
+ track: trackName,
154
+ action: input.action,
155
+ versionCodes,
156
+ before,
157
+ after,
158
+ editId,
159
+ };
160
+ }
161
+ finally {
162
+ if (!committed) {
163
+ // Discard the uncommitted edit (dry run, no-op or refusal); a failure here is harmless.
164
+ await publisher.deleteEdit(packageName, editId, input.signal).catch(() => undefined);
165
+ }
166
+ }
167
+ }
168
+ function selectRelease(releases, input) {
169
+ const candidates = input.versionCode === undefined
170
+ ? releases.filter((release) => sourceStatuses[input.action].includes(String(release.status)))
171
+ : releases.filter((release) => release.versionCodes?.includes(String(input.versionCode)));
172
+ if (candidates.length !== 1 || candidates[0] === undefined) {
173
+ const listed = releases.map((release) => `${String(release.status)} [${(release.versionCodes ?? []).join(',')}]`).join('; ') || 'none';
174
+ throw new Error(input.versionCode === undefined
175
+ ? `Google Play ${input.track} needs exactly one ${sourceStatuses[input.action].join(' or ')} `
176
+ + `release for --action ${input.action}; pass --version-code. Releases: ${listed}.`
177
+ : `Google Play ${input.track} has no single release with version code ${input.versionCode}. `
178
+ + `Releases: ${listed}.`);
179
+ }
180
+ return candidates[0];
181
+ }
182
+ /** The changed release, or undefined when it is already in the requested state. */
183
+ function applyAction(release, input) {
184
+ const status = String(release.status);
185
+ const fraction = typeof release.userFraction === 'number' ? release.userFraction : undefined;
186
+ const refuse = (detail) => {
187
+ throw new Error(`Google Play ${input.track} release is ${status}; ${detail}`);
188
+ };
189
+ const { userFraction: _dropped, ...withoutFraction } = release;
190
+ switch (input.action) {
191
+ case 'update':
192
+ if (status !== 'inProgress') {
193
+ return refuse('only an inProgress release has a fraction to update.');
194
+ }
195
+ return fraction === input.userFraction
196
+ ? undefined
197
+ : { ...release, userFraction: input.userFraction };
198
+ case 'halt':
199
+ if (status === 'halted') {
200
+ return undefined;
201
+ }
202
+ if (status !== 'inProgress' && status !== 'completed') {
203
+ return refuse('only an inProgress or completed release can be halted.');
204
+ }
205
+ // Halting a completed release serves the previous completed release instead.
206
+ return { ...release, status: 'halted' };
207
+ case 'resume': {
208
+ if (status === 'inProgress') {
209
+ return input.userFraction === undefined || input.userFraction === fraction
210
+ ? undefined
211
+ : refuse('use --action update to change its fraction.');
212
+ }
213
+ if (status !== 'halted') {
214
+ return refuse('only a halted release can be resumed.');
215
+ }
216
+ const resumed = input.userFraction ?? fraction;
217
+ if (resumed === undefined) {
218
+ return refuse('it has no rollout fraction; pass --user-fraction or use --action complete.');
219
+ }
220
+ return { ...release, status: 'inProgress', userFraction: resumed };
221
+ }
222
+ case 'complete':
223
+ if (status === 'completed') {
224
+ return undefined;
225
+ }
226
+ if (status !== 'inProgress' && status !== 'halted') {
227
+ return refuse('only an inProgress or halted release can be completed.');
228
+ }
229
+ return { ...withoutFraction, status: 'completed' };
230
+ }
231
+ }
232
+ function releaseState(release) {
233
+ return {
234
+ status: String(release.status),
235
+ ...(typeof release.userFraction === 'number' ? { userFraction: release.userFraction } : {}),
236
+ };
237
+ }
@@ -43,17 +43,34 @@ function wrapPublisher(publisher) {
43
43
  });
44
44
  return response.data;
45
45
  },
46
- async getTrack(packageName, editId, signal) {
46
+ async uploadDeobfuscationFile(input) {
47
+ await publisher.edits.deobfuscationfiles.upload({
48
+ packageName: input.packageName,
49
+ editId: input.editId,
50
+ apkVersionCode: input.versionCode,
51
+ deobfuscationFileType: "proguard",
52
+ media: { mimeType: "application/octet-stream", body: createReadStream(input.mappingFile) }
53
+ }, {
54
+ timeout: input.timeoutMs,
55
+ retry: false,
56
+ signal: input.signal,
57
+ ...input.requestRootUrl === void 0 ? {} : { rootUrl: input.requestRootUrl }
58
+ });
59
+ },
60
+ async getTrack(packageName, editId, track, signal) {
47
61
  return (await publisher.edits.tracks.get(
48
- { packageName, editId, track: "internal" },
62
+ { packageName, editId, track },
49
63
  { signal }
50
64
  )).data;
51
65
  },
52
66
  async updateTrack(packageName, editId, track, signal) {
67
+ if (typeof track.track !== "string" || track.track === "") {
68
+ throw new Error("Google Play track update needs a track name.");
69
+ }
53
70
  await publisher.edits.tracks.update({
54
71
  packageName,
55
72
  editId,
56
- track: "internal",
73
+ track: track.track,
57
74
  requestBody: track
58
75
  }, { retry: false, signal });
59
76
  },
@@ -69,6 +86,12 @@ function wrapPublisher(publisher) {
69
86
  editId,
70
87
  changesInReviewBehavior: "ERROR_IF_IN_REVIEW"
71
88
  }, { retry: false, signal });
89
+ },
90
+ async deleteEdit(packageName, editId, signal) {
91
+ await publisher.edits.delete(
92
+ { packageName, editId },
93
+ { retry: false, signal }
94
+ );
72
95
  }
73
96
  };
74
97
  }
@@ -54,6 +54,12 @@ export interface NativeSubmissionCheckpoint {
54
54
  readonly remoteEditId?: string;
55
55
  readonly remoteUploadId?: string;
56
56
  readonly remoteBuildId?: string;
57
+ /**
58
+ * Android only: digest of the Google Play release options (track, status,
59
+ * fraction, notes, mapping) the attempt started with. Absent for the original
60
+ * internal/completed submission. It never changes once written.
61
+ */
62
+ readonly playReleaseSha256?: string;
57
63
  }
58
64
  export interface NativeReleaseStatus {
59
65
  readonly plan: PlatformVersionReleasePlan;
@@ -410,6 +410,11 @@ export async function checkpointNativeSubmission(input) {
410
410
  }
411
411
  if (isNativeSubmissionSettled(previous.status)
412
412
  || previous.status === 'failed'
413
+ || (previous.playReleaseSha256 !== input.checkpoint.playReleaseSha256
414
+ // Options may be corrected only before any remote edit exists.
415
+ && !(previous.status === 'started' && input.checkpoint.status === 'started'
416
+ && previous.remoteEditId === undefined
417
+ && input.checkpoint.remoteEditId === undefined))
413
418
  || previous.remoteEditId !== undefined
414
419
  && previous.remoteEditId !== input.checkpoint.remoteEditId
415
420
  && !(previous.target === 'android'
@@ -872,6 +877,7 @@ const checkpointFields = [
872
877
  'remoteEditId',
873
878
  'remoteUploadId',
874
879
  'remoteBuildId',
880
+ 'playReleaseSha256',
875
881
  ];
876
882
  /** Validate persisted checkpoint shape before any remote mutation or recovery. */
877
883
  export function assertNativeSubmissionCheckpoint(value) {
@@ -907,6 +913,8 @@ export function assertNativeSubmissionCheckpoint(value) {
907
913
  || !Number.isFinite(Date.parse(value.leaseExpiresAt))
908
914
  || Object.keys(value).some((key) => !checkpointFields.includes(key))
909
915
  || !id('remoteEditId') || !id('remoteUploadId') || !id('remoteBuildId')
916
+ || (value.playReleaseSha256 !== undefined
917
+ && (value.target !== 'android' || !sha256Pattern.test(String(value.playReleaseSha256))))
910
918
  || (value.target === 'android' && (!androidStatuses.includes(value.status)
911
919
  || value.remoteUploadId !== undefined || value.remoteBuildId !== undefined
912
920
  || (['edit-open', 'committed'].includes(String(value.status))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/cli",
3
- "version": "0.58.0",
3
+ "version": "0.61.0",
4
4
  "description": "Gunshi CLI for mpgd-kit starter generation and target workflow orchestration.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -70,10 +70,10 @@
70
70
  "parse5": "7.3.0",
71
71
  "postcss": "8.5.28",
72
72
  "postcss-value-parser": "4.2.0",
73
- "@mpgd/phaser-assets": "0.9.1",
74
- "@mpgd/catalog": "0.11.0",
75
- "@mpgd/platform": "0.29.0",
76
- "@mpgd/target-config": "0.23.9"
73
+ "@mpgd/catalog": "0.11.3",
74
+ "@mpgd/platform": "0.32.0",
75
+ "@mpgd/target-config": "0.24.1",
76
+ "@mpgd/phaser-assets": "0.9.1"
77
77
  },
78
78
  "devDependencies": {
79
79
  "@types/node": "^24.0.0",
@@ -81,18 +81,18 @@
81
81
  "@types/yauzl": "3.4.0",
82
82
  "ttsc": "0.32.1",
83
83
  "typescript": "7.0.2",
84
- "@mpgd/adapter-ait": "0.20.6",
85
- "@mpgd/adapter-devvit": "0.14.1",
86
- "@mpgd/adapter-browser": "0.11.9",
87
- "@mpgd/adapter-verse8": "0.7.10",
88
- "@mpgd/adapter-capacitor": "0.13.0",
89
- "@mpgd/analytics": "0.5.2",
90
- "@mpgd/bridge": "0.16.1",
91
- "@mpgd/capacitor-play-billing": "0.3.10",
92
- "@mpgd/capacitor-storekit": "0.3.10",
93
- "@mpgd/game-runtime": "0.8.3",
94
- "@mpgd/game-services": "0.29.0",
95
- "@mpgd/i18n": "0.6.23",
84
+ "@mpgd/adapter-ait": "0.21.1",
85
+ "@mpgd/adapter-browser": "0.12.1",
86
+ "@mpgd/adapter-capacitor": "0.16.0",
87
+ "@mpgd/adapter-verse8": "0.7.13",
88
+ "@mpgd/adapter-devvit": "0.14.4",
89
+ "@mpgd/bridge": "0.16.4",
90
+ "@mpgd/analytics": "0.5.5",
91
+ "@mpgd/capacitor-play-billing": "0.4.2",
92
+ "@mpgd/capacitor-storekit": "0.4.2",
93
+ "@mpgd/game-runtime": "0.8.6",
94
+ "@mpgd/game-services": "0.30.2",
95
+ "@mpgd/i18n": "0.6.26",
96
96
  "@mpgd/lite-shell": "0.1.0"
97
97
  },
98
98
  "main": "./dist/index.js",
@@ -68,6 +68,7 @@ const targetIntegrations = Object.keys({
68
68
  sharing: true,
69
69
  inboundShare: true,
70
70
  notifications: true,
71
+ reviews: true,
71
72
  } satisfies Record<TargetIntegration, true>);
72
73
 
73
74
  export function assertRuntimeTargetConfigMatrix(input: unknown): TargetConfigMatrix {
@@ -142,6 +143,9 @@ function assertTargetConfig(input: unknown, label: string): asserts input is Tar
142
143
  if (input.integrations !== undefined) {
143
144
  assertRecord(input.integrations, `${label}.integrations`);
144
145
  for (const key of targetIntegrations) {
146
+ if (key === 'reviews' && input.integrations[key] === undefined) {
147
+ continue;
148
+ }
145
149
  assertOneOf(
146
150
  input.integrations[key],
147
151
  integrationAvailabilityValues,