@mpgd/cli 0.35.0 → 0.36.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/android-bundle-signer.d.ts +2 -0
- package/dist/android-bundle-signer.js +38 -0
- package/dist/android-signing-session.d.ts +22 -0
- package/dist/android-signing-session.js +198 -0
- package/dist/artifact-inspection-environment.d.ts +2 -0
- package/dist/artifact-inspection-environment.js +31 -0
- package/dist/asc-pin.json +26 -0
- package/dist/capacitor-shell-starter.d.ts +22 -0
- package/dist/capacitor-shell-starter.js +130 -7
- package/dist/deploy-planning.d.ts +64 -0
- package/dist/deploy-planning.js +471 -0
- package/dist/deploy-process.d.ts +28 -0
- package/dist/deploy-process.js +197 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +409 -10
- package/dist/ios-ipa-inspection.js +151 -0
- package/dist/ios-keychain-import.swift +92 -0
- package/dist/ios-plist.d.ts +2 -0
- package/dist/ios-plist.js +75 -0
- package/dist/ios-signing-profile.d.ts +12 -0
- package/dist/ios-signing-profile.js +60 -0
- package/dist/ios-signing-session.d.ts +22 -0
- package/dist/ios-signing-session.js +256 -0
- package/dist/kit-upgrade-target-readiness.d.ts +8 -0
- package/dist/kit-upgrade-target-readiness.js +119 -0
- package/dist/kit-upgrade.d.ts +2 -0
- package/dist/kit-upgrade.js +3 -0
- package/dist/native-build-info.json +5 -0
- package/dist/native-build-target.js +12121 -0
- package/dist/native-deploy-run.d.ts +35 -0
- package/dist/native-deploy-run.js +462 -0
- package/dist/native-deploy-submission.d.ts +40 -0
- package/dist/native-deploy-submission.js +244 -0
- package/dist/native-deploy-workflow.d.ts +11 -0
- package/dist/native-deploy-workflow.js +328 -0
- package/dist/play-internal-submission.d.ts +32 -0
- package/dist/play-internal-submission.js +247 -0
- package/dist/play-publisher-port.d.ts +30 -0
- package/dist/play-publisher-port.js +1 -0
- package/dist/play-sdk-adapter.js +78 -0
- package/dist/release-state.d.ts +140 -0
- package/dist/release-state.js +1046 -0
- package/dist/release-workspace.d.ts +61 -0
- package/dist/release-workspace.js +504 -0
- package/dist/testflight-submission.d.ts +49 -0
- package/dist/testflight-submission.js +462 -0
- package/package.json +18 -13
- package/templates/phaser-game/README.md +30 -8
- package/templates/phaser-game/apps/target-devvit/README.md +12 -0
- package/templates/phaser-game/apps/target-devvit/src/server/bridge.ts +513 -0
- package/templates/phaser-game/apps/target-devvit/src/server/index.ts +8 -291
- package/templates/phaser-game/src/main.ts +34 -33
- package/templates/phaser-game/vite.shared.ts +5 -1
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { chmodSync, copyFileSync, createReadStream, existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { ReleaseProcessError, runReleaseProcess } from './deploy-process.js';
|
|
7
|
+
export const pinnedAscVersion = '5.5.0';
|
|
8
|
+
const sha256Pattern = /^[0-9a-f]{64}$/u;
|
|
9
|
+
const teamIdPattern = /^[A-Z0-9]{10}$/u;
|
|
10
|
+
const apiKeyIdPattern = /^[A-Z0-9]{10}$/u;
|
|
11
|
+
const ascIdPattern = /^[A-Za-z0-9][A-Za-z0-9-]*$/u;
|
|
12
|
+
const marketingVersionPattern = /^\d+(?:\.\d+){0,2}$/u;
|
|
13
|
+
const uploadTimeoutMs = 12 * 60 * 1000;
|
|
14
|
+
const lookupTimeoutMs = 60_000;
|
|
15
|
+
const maxJsonBytes = 1024 * 1024;
|
|
16
|
+
export class IosSubmissionUncertainError extends Error {
|
|
17
|
+
uploadId;
|
|
18
|
+
constructor(uploadId) {
|
|
19
|
+
super(`iOS upload ${uploadId} was committed but its local checkpoint failed; inspect remote state before retry.`);
|
|
20
|
+
this.uploadId = uploadId;
|
|
21
|
+
this.name = 'IosSubmissionUncertainError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Verify and use exactly the pinned asc release, with isolated environment auth. */
|
|
25
|
+
export async function submitVerifiedIosBuild(input) {
|
|
26
|
+
if (process.platform !== 'darwin') {
|
|
27
|
+
throw new Error('Signed iOS IPA verification requires macOS.');
|
|
28
|
+
}
|
|
29
|
+
const tempRoot = mkdtempSync(path.join(tmpdir(), 'mpgd-asc-session-'));
|
|
30
|
+
try {
|
|
31
|
+
if (!path.isAbsolute(input.ascBinary)) {
|
|
32
|
+
throw new Error('asc binary must be an absolute file path.');
|
|
33
|
+
}
|
|
34
|
+
const binary = path.join(tempRoot, 'asc');
|
|
35
|
+
copyFileSync(input.ascBinary, binary);
|
|
36
|
+
chmodSync(binary, 0o700);
|
|
37
|
+
await verifyPinnedAscBinary(binary);
|
|
38
|
+
const stagedIpa = path.join(tempRoot, 'release.ipa');
|
|
39
|
+
copyFileSync(input.ipaFile, stagedIpa);
|
|
40
|
+
const stagedInput = { ...input, ipaFile: stagedIpa };
|
|
41
|
+
const resolvedPreflight = await preflight(stagedInput);
|
|
42
|
+
const { inspectSignedIosIpa } = await import(new URL('./ios-ipa-inspection.js', import.meta.url).href);
|
|
43
|
+
inspectSignedIosIpa(stagedIpa, {
|
|
44
|
+
expectedBundleId: input.bundleId,
|
|
45
|
+
expectedMarketingVersion: resolvedPreflight.marketingVersion,
|
|
46
|
+
expectedBuildNumber: resolvedPreflight.buildNumber,
|
|
47
|
+
expectedTeamId: input.record.inspectedTeamId ?? '',
|
|
48
|
+
});
|
|
49
|
+
const environment = isolatedAscEnvironment(input, tempRoot);
|
|
50
|
+
const runner = async (args, timeoutMs) => {
|
|
51
|
+
const result = await runReleaseProcess({
|
|
52
|
+
command: binary,
|
|
53
|
+
args: [...args, '--output', 'json'],
|
|
54
|
+
cwd: tempRoot,
|
|
55
|
+
environment,
|
|
56
|
+
timeoutMs,
|
|
57
|
+
maxOutputBytes: maxJsonBytes,
|
|
58
|
+
captureMachineStdout: true,
|
|
59
|
+
secretValues: [input.apiKeyId, input.apiIssuerId, input.apiPrivateKeyBase64],
|
|
60
|
+
signal: input.signal,
|
|
61
|
+
});
|
|
62
|
+
if (result.truncated || result.machineStdout === undefined) {
|
|
63
|
+
throw new Error('asc JSON output was truncated or unavailable.');
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(result.machineStdout);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
throw new Error('asc returned invalid JSON.');
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
return await submitVerifiedIosBuildResolved(stagedInput, runner, resolvedPreflight);
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
rmSync(tempRoot, { recursive: true, force: true });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Internal mock boundary used by credential-free submission tests. */
|
|
79
|
+
export async function submitVerifiedIosBuildWithRunner(input, run) {
|
|
80
|
+
return submitVerifiedIosBuildResolved(input, run, await preflight(input));
|
|
81
|
+
}
|
|
82
|
+
async function submitVerifiedIosBuildResolved(input, run, { marketingVersion, buildNumber }) {
|
|
83
|
+
const base = {
|
|
84
|
+
appStoreAppId: input.appStoreAppId,
|
|
85
|
+
bundleId: input.bundleId,
|
|
86
|
+
marketingVersion,
|
|
87
|
+
buildNumber,
|
|
88
|
+
internalGroupId: input.internalGroupId,
|
|
89
|
+
};
|
|
90
|
+
const app = await run(['apps', 'view', '--id', input.appStoreAppId], lookupTimeoutMs);
|
|
91
|
+
if (!isRecord(app) || !isRecord(app.data) || app.data.id !== input.appStoreAppId
|
|
92
|
+
|| app.data.type !== 'apps' || !isRecord(app.data.attributes)
|
|
93
|
+
|| app.data.attributes.bundleId !== input.bundleId) {
|
|
94
|
+
throw new Error('App Store Connect app ID does not match the verified IPA bundle ID.');
|
|
95
|
+
}
|
|
96
|
+
const groupsResponse = await run([
|
|
97
|
+
'testflight',
|
|
98
|
+
'groups',
|
|
99
|
+
'list',
|
|
100
|
+
'--app',
|
|
101
|
+
input.appStoreAppId,
|
|
102
|
+
'--internal',
|
|
103
|
+
'--id',
|
|
104
|
+
input.internalGroupId,
|
|
105
|
+
'--paginate',
|
|
106
|
+
], lookupTimeoutMs);
|
|
107
|
+
if (!findInternalGroup(groupsResponse, input.internalGroupId)) {
|
|
108
|
+
throw new Error('Configured TestFlight group is not an internal group of this app.');
|
|
109
|
+
}
|
|
110
|
+
let uploadId = input.resumeUploadId;
|
|
111
|
+
if (uploadId === undefined && input.resumeBuildId === undefined) {
|
|
112
|
+
const before = await lookupBuild(input, marketingVersion, buildNumber, run);
|
|
113
|
+
if (before !== undefined) {
|
|
114
|
+
return {
|
|
115
|
+
...base,
|
|
116
|
+
status: 'unknown',
|
|
117
|
+
buildId: before.id,
|
|
118
|
+
detail: 'A build already uses this app version and build number; do not reupload.',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const existingUploads = await lookupUploadIds(input, marketingVersion, buildNumber, run);
|
|
122
|
+
if (existingUploads.length > 0) {
|
|
123
|
+
return {
|
|
124
|
+
...base,
|
|
125
|
+
status: 'unknown',
|
|
126
|
+
...(existingUploads.length === 1 ? { uploadId: existingUploads[0] } : {}),
|
|
127
|
+
detail: 'An upload already exists for this app version and build number; reconcile it before retry.',
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
let upload;
|
|
131
|
+
try {
|
|
132
|
+
upload = await run([
|
|
133
|
+
'builds', 'upload', '--app', input.appStoreAppId,
|
|
134
|
+
'--ipa', input.ipaFile, '--platform', 'IOS',
|
|
135
|
+
'--version', marketingVersion, '--build-number', buildNumber,
|
|
136
|
+
'--checksum',
|
|
137
|
+
], uploadTimeoutMs);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
const observed = await lookupBuild(input, marketingVersion, buildNumber, run).catch(() => undefined);
|
|
141
|
+
return {
|
|
142
|
+
...base,
|
|
143
|
+
status: 'unknown',
|
|
144
|
+
...(observed === undefined ? {} : { buildId: observed.id }),
|
|
145
|
+
detail: `Upload result was not confirmed${failureReason(error)}; inspect App Store Connect before retry.`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
uploadId = assertUploadResult(upload, statSync(input.ipaFile).size);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
const observed = await lookupBuild(input, marketingVersion, buildNumber, run).catch(() => undefined);
|
|
153
|
+
return {
|
|
154
|
+
...base,
|
|
155
|
+
status: 'unknown',
|
|
156
|
+
...(observed === undefined ? {} : { buildId: observed.id }),
|
|
157
|
+
detail: 'Upload output was invalid; inspect App Store Connect before retry.',
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
await input.onUploadCommitted?.(uploadId);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
throw new IosSubmissionUncertainError(uploadId);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
else if (uploadId !== undefined) {
|
|
168
|
+
const matches = (await lookupUploadIds(input, marketingVersion, buildNumber, run))
|
|
169
|
+
.filter((id) => id === uploadId);
|
|
170
|
+
if (matches.length !== 1) {
|
|
171
|
+
return {
|
|
172
|
+
...base,
|
|
173
|
+
status: 'unknown',
|
|
174
|
+
uploadId,
|
|
175
|
+
detail: 'Checkpointed upload is not uniquely visible for this app and version.',
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const uploadReference = uploadId === undefined ? {} : { uploadId };
|
|
180
|
+
let build;
|
|
181
|
+
try {
|
|
182
|
+
build = await lookupBuild(input, marketingVersion, buildNumber, run);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
return {
|
|
186
|
+
...base,
|
|
187
|
+
status: input.resumeBuildId === undefined ? 'uploaded' : 'unknown',
|
|
188
|
+
...uploadReference,
|
|
189
|
+
...(input.resumeBuildId === undefined ? {} : { buildId: input.resumeBuildId }),
|
|
190
|
+
detail: `Upload committed; build processing lookup is temporarily unavailable${failureReason(error)}.`,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (input.resumeBuildId !== undefined && build?.id !== input.resumeBuildId) {
|
|
194
|
+
return {
|
|
195
|
+
...base,
|
|
196
|
+
status: 'unknown',
|
|
197
|
+
...uploadReference,
|
|
198
|
+
buildId: input.resumeBuildId,
|
|
199
|
+
detail: 'Checkpointed build ID does not match the reserved App Store build.',
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (build === undefined) {
|
|
203
|
+
return {
|
|
204
|
+
...base,
|
|
205
|
+
status: 'uploaded',
|
|
206
|
+
...uploadReference,
|
|
207
|
+
detail: 'Upload committed; build is not yet discoverable.',
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
if (build.processingState === 'PROCESSING') {
|
|
211
|
+
return { ...base, status: 'processing', ...uploadReference, buildId: build.id };
|
|
212
|
+
}
|
|
213
|
+
if (build.processingState === 'FAILED' || build.processingState === 'INVALID') {
|
|
214
|
+
return {
|
|
215
|
+
...base,
|
|
216
|
+
status: 'failed',
|
|
217
|
+
...uploadReference,
|
|
218
|
+
buildId: build.id,
|
|
219
|
+
detail: `App Store Connect reported ${build.processingState}.`,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (build.processingState !== 'VALID') {
|
|
223
|
+
return {
|
|
224
|
+
...base,
|
|
225
|
+
status: 'unknown',
|
|
226
|
+
...uploadReference,
|
|
227
|
+
buildId: build.id,
|
|
228
|
+
detail: 'App Store Connect returned an unknown processing state.',
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
const added = await run(['builds', 'add-groups', '--build-id', build.id, '--group', input.internalGroupId], lookupTimeoutMs);
|
|
233
|
+
assertGroupAssignment(added, build.id, input.internalGroupId);
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
const operatorAction = error instanceof ReleaseProcessError
|
|
237
|
+
&& error.reason === 'exit'
|
|
238
|
+
&& /export compliance|encryption declaration|account agreement|tax and banking/iu
|
|
239
|
+
.test(error.output);
|
|
240
|
+
const status = operatorAction ? 'action-required' : 'unknown';
|
|
241
|
+
return {
|
|
242
|
+
...base,
|
|
243
|
+
status,
|
|
244
|
+
...uploadReference,
|
|
245
|
+
buildId: build.id,
|
|
246
|
+
detail: `Build is processed, but internal group assignment needs inspection${failureReason(error)}.`,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
let membershipFailure;
|
|
250
|
+
try {
|
|
251
|
+
const memberships = await run(['testflight', 'groups', 'list', '--build-id', build.id, '--internal'], lookupTimeoutMs);
|
|
252
|
+
if (findInternalGroup(memberships, input.internalGroupId)) {
|
|
253
|
+
return { ...base, status: 'testflight-ready', ...uploadReference, buildId: build.id };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
membershipFailure = error;
|
|
258
|
+
// Membership can be queried again by explicit build ID.
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
...base,
|
|
262
|
+
status: 'unknown',
|
|
263
|
+
...uploadReference,
|
|
264
|
+
buildId: build.id,
|
|
265
|
+
detail: `Group assignment response was not confirmed by a membership read${failureReason(membershipFailure)}.`,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
async function lookupUploadIds(input, marketingVersion, buildNumber, run) {
|
|
269
|
+
const response = await run([
|
|
270
|
+
'builds',
|
|
271
|
+
'uploads',
|
|
272
|
+
'list',
|
|
273
|
+
'--app',
|
|
274
|
+
input.appStoreAppId,
|
|
275
|
+
'--cf-bundle-short-version',
|
|
276
|
+
marketingVersion,
|
|
277
|
+
'--cf-bundle-version',
|
|
278
|
+
buildNumber,
|
|
279
|
+
'--platform',
|
|
280
|
+
'IOS',
|
|
281
|
+
'--paginate',
|
|
282
|
+
], lookupTimeoutMs);
|
|
283
|
+
const items = readDataArray(response, 'builds uploads list');
|
|
284
|
+
if (items.some((item) => !isRecord(item) || item.type !== 'buildUploads'
|
|
285
|
+
|| typeof item.id !== 'string' || !ascIdPattern.test(item.id)
|
|
286
|
+
|| !isRecord(item.attributes)
|
|
287
|
+
|| typeof item.attributes.cfBundleShortVersionString !== 'string'
|
|
288
|
+
|| typeof item.attributes.cfBundleVersion !== 'string'
|
|
289
|
+
|| typeof item.attributes.platform !== 'string')) {
|
|
290
|
+
throw new Error('asc builds uploads list returned a malformed upload.');
|
|
291
|
+
}
|
|
292
|
+
return items.filter((item) => isRecord(item) && isRecord(item.attributes)
|
|
293
|
+
&& item.attributes.cfBundleShortVersionString === marketingVersion
|
|
294
|
+
&& item.attributes.cfBundleVersion === buildNumber
|
|
295
|
+
&& item.attributes.platform === 'IOS')
|
|
296
|
+
.map((item) => item.id);
|
|
297
|
+
}
|
|
298
|
+
async function lookupBuild(input, marketingVersion, buildNumber, run) {
|
|
299
|
+
const response = await run([
|
|
300
|
+
'builds',
|
|
301
|
+
'list',
|
|
302
|
+
'--app',
|
|
303
|
+
input.appStoreAppId,
|
|
304
|
+
'--version',
|
|
305
|
+
marketingVersion,
|
|
306
|
+
'--build-number',
|
|
307
|
+
buildNumber,
|
|
308
|
+
'--platform',
|
|
309
|
+
'IOS',
|
|
310
|
+
'--paginate',
|
|
311
|
+
], lookupTimeoutMs);
|
|
312
|
+
const items = readDataArray(response, 'builds list');
|
|
313
|
+
if (items.some((item) => !isRecord(item) || item.type !== 'builds'
|
|
314
|
+
|| typeof item.id !== 'string' || item.id === ''
|
|
315
|
+
|| !isRecord(item.attributes) || typeof item.attributes.version !== 'string'
|
|
316
|
+
|| typeof item.attributes.processingState !== 'string')) {
|
|
317
|
+
throw new Error('asc builds list returned a malformed build.');
|
|
318
|
+
}
|
|
319
|
+
const matching = items.filter((item) => item
|
|
320
|
+
.attributes.version === buildNumber);
|
|
321
|
+
if (matching.length > 1) {
|
|
322
|
+
throw new Error('App Store Connect returned multiple builds for the reserved version.');
|
|
323
|
+
}
|
|
324
|
+
const item = matching[0];
|
|
325
|
+
if (item === undefined) {
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
if (!isRecord(item) || typeof item.id !== 'string' || item.id === ''
|
|
329
|
+
|| !isRecord(item.attributes) || typeof item.attributes.processingState !== 'string') {
|
|
330
|
+
throw new Error('asc builds list returned a malformed build.');
|
|
331
|
+
}
|
|
332
|
+
return { id: item.id, processingState: item.attributes.processingState };
|
|
333
|
+
}
|
|
334
|
+
function findInternalGroup(response, groupId) {
|
|
335
|
+
return readDataArray(response, 'TestFlight groups list').some((value) => isRecord(value)
|
|
336
|
+
&& value.type === 'betaGroups' && value.id === groupId
|
|
337
|
+
&& isRecord(value.attributes) && value.attributes.isInternalGroup === true);
|
|
338
|
+
}
|
|
339
|
+
function readDataArray(response, command) {
|
|
340
|
+
if (!isRecord(response) || !Array.isArray(response.data)) {
|
|
341
|
+
throw new Error(`asc ${command} returned an invalid JSON schema.`);
|
|
342
|
+
}
|
|
343
|
+
return response.data;
|
|
344
|
+
}
|
|
345
|
+
function assertUploadResult(value, expectedBytes) {
|
|
346
|
+
if (!isRecord(value) || typeof value.uploadId !== 'string' || value.uploadId === ''
|
|
347
|
+
|| typeof value.fileId !== 'string' || value.fileId === ''
|
|
348
|
+
|| value.uploaded !== true || value.fileSize !== expectedBytes
|
|
349
|
+
|| (Array.isArray(value.operations) && value.operations.length > 0)) {
|
|
350
|
+
throw new Error('asc upload result was not a confirmed, redacted committed upload.');
|
|
351
|
+
}
|
|
352
|
+
return value.uploadId;
|
|
353
|
+
}
|
|
354
|
+
function assertGroupAssignment(value, buildId, groupId) {
|
|
355
|
+
if (!isRecord(value) || value.action !== 'added' || value.buildId !== buildId
|
|
356
|
+
|| !Array.isArray(value.groupIds)
|
|
357
|
+
|| value.groupIds.some((id) => typeof id !== 'string'
|
|
358
|
+
|| id !== groupId)) {
|
|
359
|
+
throw new Error('asc returned an invalid TestFlight group assignment.');
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
function failureReason(error) {
|
|
363
|
+
return error instanceof ReleaseProcessError ? ` (${error.reason})` : '';
|
|
364
|
+
}
|
|
365
|
+
async function preflight(input) {
|
|
366
|
+
const version = input.record.platformVersion;
|
|
367
|
+
const buildNumber = version.buildNumber;
|
|
368
|
+
const marketingVersion = version.marketingVersion;
|
|
369
|
+
if (input.record.target !== 'ios' || input.bundleId !== input.record.inspectedAppId
|
|
370
|
+
|| !teamIdPattern.test(input.record.inspectedTeamId ?? '')
|
|
371
|
+
|| !/^[1-9]\d*$/u.test(String(buildNumber))
|
|
372
|
+
|| typeof marketingVersion !== 'string'
|
|
373
|
+
|| !marketingVersionPattern.test(marketingVersion)
|
|
374
|
+
|| !/^\d+$/u.test(input.appStoreAppId)
|
|
375
|
+
|| !ascIdPattern.test(input.internalGroupId)
|
|
376
|
+
|| (input.resumeUploadId !== undefined && !ascIdPattern.test(input.resumeUploadId))
|
|
377
|
+
|| (input.resumeUploadId === undefined) !== (input.resumeArtifactSha256 === undefined)
|
|
378
|
+
|| (input.resumeArtifactSha256 !== undefined
|
|
379
|
+
&& input.resumeArtifactSha256 !== input.record.artifactSha256)
|
|
380
|
+
|| !sha256Pattern.test(input.record.artifactSha256)
|
|
381
|
+
|| !input.ipaFile.endsWith('.ipa') || !existsSync(input.ipaFile)
|
|
382
|
+
|| !statSync(input.ipaFile).isFile()) {
|
|
383
|
+
throw new Error('TestFlight submission requires a matching verified iOS IPA build record.');
|
|
384
|
+
}
|
|
385
|
+
const hash = createHash('sha256');
|
|
386
|
+
for await (const chunk of createReadStream(input.ipaFile)) {
|
|
387
|
+
hash.update(chunk);
|
|
388
|
+
}
|
|
389
|
+
if (hash.digest('hex') !== input.record.artifactSha256) {
|
|
390
|
+
throw new Error('TestFlight IPA bytes differ from the immutable build record.');
|
|
391
|
+
}
|
|
392
|
+
return { marketingVersion, buildNumber: String(buildNumber) };
|
|
393
|
+
}
|
|
394
|
+
export function isolatedAscEnvironment(input, directory, source = process.env) {
|
|
395
|
+
if (!apiKeyIdPattern.test(input.apiKeyId)
|
|
396
|
+
|| input.apiIssuerId.trim() === '' || input.apiPrivateKeyBase64.trim() === '') {
|
|
397
|
+
throw new Error('App Store Connect API credentials are incomplete.');
|
|
398
|
+
}
|
|
399
|
+
// asc only needs network/locale process settings, never Git, registry, or signing secrets.
|
|
400
|
+
const inheritedNames = [
|
|
401
|
+
'PATH',
|
|
402
|
+
'LANG',
|
|
403
|
+
'LC_ALL',
|
|
404
|
+
'LC_CTYPE',
|
|
405
|
+
'TZ',
|
|
406
|
+
'HTTPS_PROXY',
|
|
407
|
+
'HTTP_PROXY',
|
|
408
|
+
'NO_PROXY',
|
|
409
|
+
'https_proxy',
|
|
410
|
+
'http_proxy',
|
|
411
|
+
'no_proxy',
|
|
412
|
+
'SSL_CERT_FILE',
|
|
413
|
+
'CURL_CA_BUNDLE',
|
|
414
|
+
];
|
|
415
|
+
const environment = Object.fromEntries(inheritedNames.flatMap((name) => {
|
|
416
|
+
const value = source[name];
|
|
417
|
+
return value === undefined ? [] : [[name, value]];
|
|
418
|
+
}));
|
|
419
|
+
return {
|
|
420
|
+
...environment,
|
|
421
|
+
HOME: directory,
|
|
422
|
+
CFFIXED_USER_HOME: directory,
|
|
423
|
+
XDG_CONFIG_HOME: directory,
|
|
424
|
+
TMPDIR: directory,
|
|
425
|
+
TMP: directory,
|
|
426
|
+
TEMP: directory,
|
|
427
|
+
ASC_TELEMETRY_DISABLED: '1',
|
|
428
|
+
ASC_BYPASS_KEYCHAIN: '1',
|
|
429
|
+
ASC_STRICT_AUTH: '1',
|
|
430
|
+
ASC_KEY_ID: input.apiKeyId,
|
|
431
|
+
ASC_ISSUER_ID: input.apiIssuerId,
|
|
432
|
+
ASC_PRIVATE_KEY_B64: input.apiPrivateKeyBase64,
|
|
433
|
+
ASC_TIMEOUT: '60s',
|
|
434
|
+
ASC_UPLOAD_TIMEOUT: '10m',
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
/** The package consumes one reviewed asc pin; it does not trust PATH or version text. */
|
|
438
|
+
export async function verifyPinnedAscBinary(binary, pinFile = fileURLToPath(new URL('./asc-pin.json', import.meta.url))) {
|
|
439
|
+
if (!path.isAbsolute(binary) || !existsSync(binary) || !statSync(binary).isFile()) {
|
|
440
|
+
throw new Error('asc binary must be an existing absolute file.');
|
|
441
|
+
}
|
|
442
|
+
const pin = JSON.parse(readFileSync(pinFile, 'utf8'));
|
|
443
|
+
const host = `${process.platform}-${process.arch}`;
|
|
444
|
+
if (!isRecord(pin) || pin.version !== pinnedAscVersion
|
|
445
|
+
|| !isRecord(pin.assets) || !isRecord(pin.assets[host])) {
|
|
446
|
+
throw new Error('No reviewed asc binary pin exists for this host.');
|
|
447
|
+
}
|
|
448
|
+
const expected = pin.assets[host].sha256;
|
|
449
|
+
if (typeof expected !== 'string' || !sha256Pattern.test(expected)) {
|
|
450
|
+
throw new Error('Reviewed asc binary pin is malformed.');
|
|
451
|
+
}
|
|
452
|
+
const hash = createHash('sha256');
|
|
453
|
+
for await (const chunk of createReadStream(realpathSync(binary))) {
|
|
454
|
+
hash.update(chunk);
|
|
455
|
+
}
|
|
456
|
+
if (hash.digest('hex') !== expected) {
|
|
457
|
+
throw new Error('asc binary checksum differs from the reviewed 5.5.0 pin.');
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
function isRecord(value) {
|
|
461
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
462
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.1",
|
|
4
4
|
"description": "Gunshi CLI for mpgd-kit starter generation and target workflow orchestration.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -40,15 +40,22 @@
|
|
|
40
40
|
"mpgd": "./dist/bin.js"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
+
"@googleapis/androidpublisher": "42.0.0",
|
|
43
44
|
"@gunshi/plugin-completion": "^0.35.1",
|
|
44
45
|
"@gunshi/plugin-i18n": "^0.35.1",
|
|
45
46
|
"@gunshi/resources": "^0.35.1",
|
|
46
47
|
"@xmldom/xmldom": "0.9.12",
|
|
48
|
+
"acorn": "8.17.0",
|
|
47
49
|
"entities": "^8.0.0",
|
|
48
50
|
"esbuild": "^0.28.1",
|
|
49
51
|
"gunshi": "^0.35.1",
|
|
50
52
|
"semver": "^7.8.5",
|
|
51
|
-
"
|
|
53
|
+
"sharp": "0.35.5",
|
|
54
|
+
"typia": "13.0.2",
|
|
55
|
+
"undici": "^7.29.1",
|
|
56
|
+
"vite": "8.1.3",
|
|
57
|
+
"@mpgd/catalog": "0.7.6",
|
|
58
|
+
"@mpgd/target-config": "0.17.0",
|
|
52
59
|
"@mpgd/phaser-assets": "0.7.0"
|
|
53
60
|
},
|
|
54
61
|
"devDependencies": {
|
|
@@ -56,18 +63,16 @@
|
|
|
56
63
|
"@types/semver": "^7.8.0",
|
|
57
64
|
"ttsc": "0.30.4",
|
|
58
65
|
"typescript": "7.0.2",
|
|
59
|
-
"@mpgd/adapter-ait": "0.12.
|
|
60
|
-
"@mpgd/adapter-
|
|
61
|
-
"@mpgd/adapter-
|
|
62
|
-
"@mpgd/adapter-
|
|
63
|
-
"@mpgd/adapter-
|
|
64
|
-
"@mpgd/analytics": "0.3.15",
|
|
66
|
+
"@mpgd/adapter-ait": "0.12.7",
|
|
67
|
+
"@mpgd/adapter-browser": "0.7.8",
|
|
68
|
+
"@mpgd/adapter-capacitor": "0.6.1",
|
|
69
|
+
"@mpgd/adapter-verse8": "0.3.11",
|
|
70
|
+
"@mpgd/adapter-devvit": "0.9.13",
|
|
65
71
|
"@mpgd/bridge": "0.10.0",
|
|
66
|
-
"@mpgd/
|
|
67
|
-
"@mpgd/game-services": "0.
|
|
68
|
-
"@mpgd/i18n": "0.6.
|
|
69
|
-
"@mpgd/platform": "0.
|
|
70
|
-
"@mpgd/target-config": "0.16.0"
|
|
72
|
+
"@mpgd/analytics": "0.4.0",
|
|
73
|
+
"@mpgd/game-services": "0.17.1",
|
|
74
|
+
"@mpgd/i18n": "0.6.5",
|
|
75
|
+
"@mpgd/platform": "0.14.0"
|
|
71
76
|
},
|
|
72
77
|
"main": "./dist/index.js",
|
|
73
78
|
"types": "./dist/index.d.ts",
|
|
@@ -149,17 +149,32 @@ preview, Apps in Toss, Devvit, Android, and iOS bundles.
|
|
|
149
149
|
## Viewport And Controls
|
|
150
150
|
|
|
151
151
|
The starter computes an initial viewport snapshot with `@mpgd/target-config`.
|
|
152
|
-
|
|
153
|
-
`window.
|
|
152
|
+
`measureTargetViewport` measures the game container first, then falls back to
|
|
153
|
+
`visualViewport` or the window. It returns `null` while every surface is still
|
|
154
|
+
zero-sized (a hidden iframe, a collapsed embed or a background tab before
|
|
155
|
+
layout), so the starter waits for the first usable size instead of failing
|
|
156
|
+
viewport validation:
|
|
154
157
|
|
|
155
158
|
```ts
|
|
156
|
-
import {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
+
import {
|
|
160
|
+
measureTargetViewport,
|
|
161
|
+
resolveTargetViewportSnapshot,
|
|
162
|
+
waitForTargetViewportMeasurement,
|
|
163
|
+
} from '@mpgd/target-config';
|
|
164
|
+
|
|
165
|
+
const measured = await waitForTargetViewportMeasurement({
|
|
166
|
+
measure: () =>
|
|
167
|
+
measureTargetViewport({
|
|
168
|
+
container: document.querySelector<HTMLElement>('#game'),
|
|
169
|
+
visualViewport: window.visualViewport,
|
|
170
|
+
window,
|
|
171
|
+
}),
|
|
172
|
+
// Call the listener on resize, visualViewport resize, visibilitychange and a
|
|
173
|
+
// ResizeObserver on #game; return a function that removes those listeners.
|
|
174
|
+
subscribe: subscribeToGameViewportChanges,
|
|
175
|
+
});
|
|
159
176
|
const viewport = resolveTargetViewportSnapshot({
|
|
160
|
-
|
|
161
|
-
height: measured.height,
|
|
162
|
-
source: measured.source,
|
|
177
|
+
...measured,
|
|
163
178
|
runtime: runtime.config.runtime,
|
|
164
179
|
orientationPolicy: {
|
|
165
180
|
mode: 'prefer-landscape',
|
|
@@ -280,6 +295,13 @@ pnpm ait:wrapper:dev:plain
|
|
|
280
295
|
pnpm ait:wrapper:dev:sandbox
|
|
281
296
|
```
|
|
282
297
|
|
|
298
|
+
`pnpm dev:ait` sets `BUILD_ID=ait-sandbox`, and only a non-production build with
|
|
299
|
+
that exact build id selects the `aitSandbox` gateway, whose in-memory bridge
|
|
300
|
+
self-completes purchases and rewarded ads without any backend verification. Any
|
|
301
|
+
other AIT build, including other debug builds, uses the production `ait` gateway
|
|
302
|
+
so the real Apps in Toss SDK and server authority stay in the loop. The same rule
|
|
303
|
+
gates the Devvit sandbox gateway behind `BUILD_ID=devvit-sandbox`.
|
|
304
|
+
|
|
283
305
|
`ait:wrapper:dev` loads the last game bundle copied by `pnpm build:ait` from the
|
|
284
306
|
wrapper's `public/game` directory, so run `pnpm build:ait` again after game
|
|
285
307
|
changes before opening it. The local mock deliberately keeps ads, promotions,
|
|
@@ -51,6 +51,18 @@ Games that intentionally support inline gameplay can opt in through
|
|
|
51
51
|
An opt-in inline surface must remain tap/click based and preserve Reddit-native
|
|
52
52
|
gestures across its complete gameplay flow.
|
|
53
53
|
|
|
54
|
+
The bridge method handlers live in `src/server/bridge.ts`; `src/server/index.ts`
|
|
55
|
+
only wires the Devvit `context`, `reddit`, and `redis` clients into them. Cloud
|
|
56
|
+
saves are bound to the authenticated Reddit user from the request context and
|
|
57
|
+
stored under `<game-name>:save:<user>:<key>`. Each value is capped at
|
|
58
|
+
`maxStorageValueBytes` and each user may hold at most `maxStorageKeysPerPlayer`
|
|
59
|
+
distinct keys, tracked in the `<game-name>:save-keys:<user>` hash and enforced
|
|
60
|
+
inside a WATCH/MULTI/EXEC transaction so concurrent saves cannot overshoot the
|
|
61
|
+
cap; saves past it fail with `DEVVIT_STORAGE_KEY_LIMIT`. Saves do not expire by
|
|
62
|
+
default; set `storageEntryTtlSeconds` to let idle saves age out. Keep
|
|
63
|
+
`bridge.ts` aligned with the kit's `apps/target-devvit/src/server/bridge.ts`
|
|
64
|
+
when upgrading so generated games inherit storage protections.
|
|
65
|
+
|
|
54
66
|
The generated bridge does not advertise or accept a generic platform
|
|
55
67
|
leaderboard. Devvit ranking should be owned by a server completion handler that
|
|
56
68
|
validates the game-specific attempt, records it through the verified leaderboard
|