@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.
Files changed (53) hide show
  1. package/dist/android-bundle-signer.d.ts +2 -0
  2. package/dist/android-bundle-signer.js +38 -0
  3. package/dist/android-signing-session.d.ts +22 -0
  4. package/dist/android-signing-session.js +198 -0
  5. package/dist/artifact-inspection-environment.d.ts +2 -0
  6. package/dist/artifact-inspection-environment.js +31 -0
  7. package/dist/asc-pin.json +26 -0
  8. package/dist/capacitor-shell-starter.d.ts +22 -0
  9. package/dist/capacitor-shell-starter.js +130 -7
  10. package/dist/deploy-planning.d.ts +64 -0
  11. package/dist/deploy-planning.js +471 -0
  12. package/dist/deploy-process.d.ts +28 -0
  13. package/dist/deploy-process.js +197 -0
  14. package/dist/index.d.ts +8 -0
  15. package/dist/index.js +409 -10
  16. package/dist/ios-ipa-inspection.js +151 -0
  17. package/dist/ios-keychain-import.swift +92 -0
  18. package/dist/ios-plist.d.ts +2 -0
  19. package/dist/ios-plist.js +75 -0
  20. package/dist/ios-signing-profile.d.ts +12 -0
  21. package/dist/ios-signing-profile.js +60 -0
  22. package/dist/ios-signing-session.d.ts +22 -0
  23. package/dist/ios-signing-session.js +256 -0
  24. package/dist/kit-upgrade-target-readiness.d.ts +8 -0
  25. package/dist/kit-upgrade-target-readiness.js +119 -0
  26. package/dist/kit-upgrade.d.ts +2 -0
  27. package/dist/kit-upgrade.js +3 -0
  28. package/dist/native-build-info.json +5 -0
  29. package/dist/native-build-target.js +12121 -0
  30. package/dist/native-deploy-run.d.ts +35 -0
  31. package/dist/native-deploy-run.js +462 -0
  32. package/dist/native-deploy-submission.d.ts +40 -0
  33. package/dist/native-deploy-submission.js +244 -0
  34. package/dist/native-deploy-workflow.d.ts +11 -0
  35. package/dist/native-deploy-workflow.js +328 -0
  36. package/dist/play-internal-submission.d.ts +32 -0
  37. package/dist/play-internal-submission.js +247 -0
  38. package/dist/play-publisher-port.d.ts +30 -0
  39. package/dist/play-publisher-port.js +1 -0
  40. package/dist/play-sdk-adapter.js +78 -0
  41. package/dist/release-state.d.ts +140 -0
  42. package/dist/release-state.js +1046 -0
  43. package/dist/release-workspace.d.ts +61 -0
  44. package/dist/release-workspace.js +504 -0
  45. package/dist/testflight-submission.d.ts +49 -0
  46. package/dist/testflight-submission.js +462 -0
  47. package/package.json +18 -13
  48. package/templates/phaser-game/README.md +30 -8
  49. package/templates/phaser-game/apps/target-devvit/README.md +12 -0
  50. package/templates/phaser-game/apps/target-devvit/src/server/bridge.ts +513 -0
  51. package/templates/phaser-game/apps/target-devvit/src/server/index.ts +8 -291
  52. package/templates/phaser-game/src/main.ts +34 -33
  53. package/templates/phaser-game/vite.shared.ts +5 -1
@@ -0,0 +1,513 @@
1
+ import {
2
+ bridgeStorageLoadProtocol,
3
+ createBridgeError,
4
+ type BridgeRequest,
5
+ type BridgeResponse,
6
+ type BridgeStorageLoadData,
7
+ } from '@mpgd/bridge';
8
+
9
+ export const maxStorageKeyLength = 128;
10
+ export const maxEncodedStorageKeyLength = 384;
11
+ export const maxStorageValueBytes = 262_144;
12
+ /**
13
+ * Hard cap on distinct `storage.save` keys per authenticated player. Combined with
14
+ * `maxStorageValueBytes` this bounds the Redis footprint one Reddit account can
15
+ * create inside the installation's shared quota.
16
+ */
17
+ export const maxStorageKeysPerPlayer = 32;
18
+ /**
19
+ * Optional expiry applied to every saved value and to the per-player key index on
20
+ * each save. Cloud saves are expected to persist for the lifetime of the player,
21
+ * so the default is no expiry. Set a positive number of seconds to let idle saves
22
+ * fall out of Redis; the index is refreshed on every save, so stale index fields
23
+ * for expired values still count toward `maxStorageKeysPerPlayer` until the
24
+ * player writes to those keys again.
25
+ */
26
+ export const storageEntryTtlSeconds: number | undefined = undefined;
27
+ /** Redis key namespace used when `storageKeyNamespace` is not injected. */
28
+ export const defaultStorageKeyNamespace = 'mpgd';
29
+ const storageIndexTransactionAttempts = 3;
30
+
31
+ export interface DevvitBridgeRedisTransactionLike {
32
+ multi(): Promise<void>;
33
+ discard(): Promise<unknown>;
34
+ set(key: string, value: string): Promise<unknown>;
35
+ hSet(key: string, fieldValues: Readonly<Record<string, string>>): Promise<unknown>;
36
+ expire(key: string, seconds: number): Promise<unknown>;
37
+ exec(): Promise<readonly unknown[] | null>;
38
+ unwatch(): Promise<unknown>;
39
+ }
40
+
41
+ /** Subset of the Devvit server Redis client used by the bridge storage handlers. */
42
+ export interface DevvitBridgeRedisLike {
43
+ get(key: string): Promise<string | undefined>;
44
+ exists(...keys: readonly string[]): Promise<number>;
45
+ hGet(key: string, field: string): Promise<string | undefined>;
46
+ hLen(key: string): Promise<number>;
47
+ watch(...keys: readonly string[]): Promise<DevvitBridgeRedisTransactionLike>;
48
+ }
49
+
50
+ export interface DevvitBridgeHandlerDependencies {
51
+ readonly redis: DevvitBridgeRedisLike;
52
+ /** Authenticated Reddit user ID from the Devvit request context, never from the payload. */
53
+ readonly currentPlayerId: () => string | undefined;
54
+ readonly currentDisplayName: (playerId: string) => Promise<string>;
55
+ /**
56
+ * Prefix for the per-player value and index keys. Generated games pass their
57
+ * game name so saves stay namespaced per game inside the shared Redis.
58
+ */
59
+ readonly storageKeyNamespace?: string | undefined;
60
+ readonly warn?: ((message: string) => void) | undefined;
61
+ }
62
+
63
+ export type DevvitBridgeHandler = (input: BridgeRequest) => Promise<BridgeResponse>;
64
+
65
+ export function createDevvitBridgeHandler(
66
+ dependencies: DevvitBridgeHandlerDependencies,
67
+ ): DevvitBridgeHandler {
68
+ const warn = dependencies.warn ?? ((message) => {
69
+ console.warn(message);
70
+ });
71
+ const storageKeyNamespace = dependencies.storageKeyNamespace ?? defaultStorageKeyNamespace;
72
+
73
+ if (storageKeyNamespace.length === 0) {
74
+ throw new TypeError('Devvit bridge storageKeyNamespace must not be empty.');
75
+ }
76
+
77
+ return async function handleBridgeRequest(input: BridgeRequest): Promise<BridgeResponse> {
78
+ switch (input.method) {
79
+ case 'runtime.getCapabilities':
80
+ return ok(input, {
81
+ nativeIap: false,
82
+ nativeAds: false,
83
+ rewardedAds: false,
84
+ interstitialAds: false,
85
+ nativeLeaderboard: false,
86
+ remoteLeaderboard: false,
87
+ achievements: false,
88
+ cloudSave: true,
89
+ socialShare: false,
90
+ haptics: false,
91
+ localizedContent: true,
92
+ });
93
+
94
+ case 'identity.getPlayer': {
95
+ const playerId = dependencies.currentPlayerId();
96
+
97
+ if (playerId === undefined) {
98
+ return ok(input, null);
99
+ }
100
+
101
+ return ok(input, {
102
+ playerId,
103
+ displayName: await dependencies.currentDisplayName(playerId),
104
+ });
105
+ }
106
+
107
+ case 'identity.getSession': {
108
+ const playerId = dependencies.currentPlayerId();
109
+
110
+ return ok(
111
+ input,
112
+ playerId === undefined
113
+ ? {
114
+ identityLevel: 'guest',
115
+ trustLevel: 'local',
116
+ }
117
+ : {
118
+ identityLevel: 'authenticated',
119
+ playerId,
120
+ trustLevel: 'server-verified',
121
+ },
122
+ );
123
+ }
124
+
125
+ case 'identity.requestUpgrade': {
126
+ const authenticated = dependencies.currentPlayerId() !== undefined;
127
+
128
+ return ok(input, {
129
+ status: authenticated ? 'completed' : 'unavailable',
130
+ reloadExpected: false,
131
+ });
132
+ }
133
+
134
+ case 'presentation.getLaunchIntent':
135
+ return ok(input, { entry: 'home' });
136
+
137
+ case 'presentation.requestGameSurface':
138
+ return ok(input, 'unavailable');
139
+
140
+ case 'share.share':
141
+ return ok(input, { status: 'unavailable' });
142
+
143
+ case 'share.readInboundShare':
144
+ return ok(input, null);
145
+
146
+ case 'notifications.getStatus':
147
+ return ok(input, 'approval-required');
148
+
149
+ case 'notifications.requestSubscription':
150
+ return ok(input, 'unavailable');
151
+
152
+ case 'commerce.getProducts':
153
+ case 'commerce.getEntitlements':
154
+ return ok(input, []);
155
+
156
+ case 'commerce.purchase':
157
+ return ok(input, {
158
+ status: 'cancelled',
159
+ entitlementIds: [],
160
+ });
161
+
162
+ case 'commerce.restore':
163
+ return ok(input, {
164
+ restoredEntitlements: [],
165
+ });
166
+
167
+ case 'ads.preload':
168
+ return ok(input, {});
169
+
170
+ case 'leaderboard.open':
171
+ return createBridgeError(
172
+ input.id,
173
+ 'DEVVIT_LEADERBOARD_OPEN_UNAVAILABLE',
174
+ 'Devvit leaderboard display is not implemented yet.',
175
+ );
176
+
177
+ case 'ads.showRewarded':
178
+ return ok(input, {
179
+ status: 'unavailable',
180
+ rewardGranted: false,
181
+ });
182
+
183
+ case 'ads.showInterstitial':
184
+ return ok(input, {
185
+ status: 'unavailable',
186
+ });
187
+
188
+ case 'leaderboard.submitScore':
189
+ return ok(input, {
190
+ submitted: false,
191
+ });
192
+
193
+ case 'storage.load':
194
+ return loadStorage(input, dependencies, storageKeyNamespace, warn);
195
+
196
+ case 'storage.save':
197
+ return saveStorage(input, dependencies, storageKeyNamespace, warn);
198
+
199
+ default:
200
+ return createBridgeError(
201
+ input.id,
202
+ 'UNSUPPORTED_METHOD',
203
+ `Unsupported Devvit bridge method: ${input.method}`,
204
+ );
205
+ }
206
+ };
207
+ }
208
+
209
+ async function loadStorage(
210
+ input: BridgeRequest,
211
+ dependencies: DevvitBridgeHandlerDependencies,
212
+ storageKeyNamespace: string,
213
+ warn: (message: string) => void,
214
+ ): Promise<BridgeResponse> {
215
+ const playerId = dependencies.currentPlayerId();
216
+
217
+ if (playerId === undefined) {
218
+ return createBridgeError(
219
+ input.id,
220
+ 'DEVVIT_STORAGE_IDENTITY_REQUIRED',
221
+ 'A current Reddit player is required to load storage.',
222
+ );
223
+ }
224
+
225
+ const location = storageLocation(input, playerId, storageKeyNamespace);
226
+
227
+ if (!('valueKey' in location)) {
228
+ return location;
229
+ }
230
+
231
+ let stored: string | null | undefined;
232
+
233
+ try {
234
+ stored = await dependencies.redis.get(location.valueKey);
235
+ } catch (error) {
236
+ warn(`devvit storage load failed: ${errorMessage(error)}`);
237
+ return createBridgeError(
238
+ input.id,
239
+ 'DEVVIT_STORAGE_LOAD_FAILED',
240
+ 'Devvit storage could not be loaded.',
241
+ true,
242
+ );
243
+ }
244
+
245
+ if (stored === undefined || stored === null) {
246
+ return ok(input, {
247
+ __mpgdBridgeProtocol: bridgeStorageLoadProtocol,
248
+ found: false,
249
+ } satisfies BridgeStorageLoadData);
250
+ }
251
+
252
+ try {
253
+ return ok(
254
+ input,
255
+ {
256
+ __mpgdBridgeProtocol: bridgeStorageLoadProtocol,
257
+ found: true,
258
+ value: JSON.parse(stored),
259
+ } satisfies BridgeStorageLoadData,
260
+ );
261
+ } catch {
262
+ return createBridgeError(input.id, 'CORRUPTED_STORAGE_VALUE', 'Stored data is not valid JSON.');
263
+ }
264
+ }
265
+
266
+ async function saveStorage(
267
+ input: BridgeRequest,
268
+ dependencies: DevvitBridgeHandlerDependencies,
269
+ storageKeyNamespace: string,
270
+ warn: (message: string) => void,
271
+ ): Promise<BridgeResponse> {
272
+ const playerId = dependencies.currentPlayerId();
273
+
274
+ if (playerId === undefined) {
275
+ return createBridgeError(
276
+ input.id,
277
+ 'DEVVIT_STORAGE_IDENTITY_REQUIRED',
278
+ 'A current Reddit player is required to save storage.',
279
+ );
280
+ }
281
+
282
+ const location = storageLocation(input, playerId, storageKeyNamespace);
283
+
284
+ if (!('valueKey' in location)) {
285
+ return location;
286
+ }
287
+
288
+ const payload = optionalObjectPayload(input.payload) as { readonly value?: unknown };
289
+ let serialized: string;
290
+
291
+ try {
292
+ const candidate = JSON.stringify(payload.value);
293
+
294
+ if (typeof candidate !== 'string') {
295
+ throw new Error('JSON serialization did not produce a string.');
296
+ }
297
+
298
+ serialized = candidate;
299
+ } catch {
300
+ return createBridgeError(
301
+ input.id,
302
+ 'INVALID_STORAGE_VALUE',
303
+ 'Storage values must be JSON serializable.',
304
+ );
305
+ }
306
+
307
+ if (new TextEncoder().encode(serialized).length > maxStorageValueBytes) {
308
+ return createBridgeError(
309
+ input.id,
310
+ 'DEVVIT_STORAGE_QUOTA_EXCEEDED',
311
+ `Storage values must not exceed ${String(maxStorageValueBytes)} UTF-8 bytes.`,
312
+ );
313
+ }
314
+
315
+ let persisted: boolean;
316
+
317
+ try {
318
+ persisted = await writeIndexedStorageValue(dependencies.redis, location, serialized);
319
+ } catch (error) {
320
+ warn(`devvit storage save was not persisted: ${errorMessage(error)}`);
321
+ return createBridgeError(
322
+ input.id,
323
+ 'DEVVIT_STORAGE_SAVE_FAILED',
324
+ 'Devvit storage could not be saved.',
325
+ true,
326
+ );
327
+ }
328
+
329
+ if (!persisted) {
330
+ return createBridgeError(
331
+ input.id,
332
+ 'DEVVIT_STORAGE_KEY_LIMIT',
333
+ `Players may store at most ${String(maxStorageKeysPerPlayer)} distinct storage keys.`,
334
+ );
335
+ }
336
+
337
+ return ok(input, {
338
+ saved: true,
339
+ playerId,
340
+ });
341
+ }
342
+
343
+ /**
344
+ * Writes the value and registers its key in the per-player index inside one
345
+ * WATCH/MULTI/EXEC transaction. The cap check reads the index while it is
346
+ * watched, so a concurrent save that changes the index aborts EXEC and the
347
+ * attempt is retried with fresh counts instead of overshooting the cap.
348
+ *
349
+ * The Devvit Redis client (`@devvit/redis` `TxClient.exec()`) never resolves
350
+ * `null` for an aborted transaction; it maps the server reply to an array, so a
351
+ * WATCH abort surfaces as an empty array. An EXEC result with fewer entries than
352
+ * the commands queued after MULTI is therefore treated as a conflict, and `null`
353
+ * is kept as a conflict too for clients that follow the classic Redis contract.
354
+ *
355
+ * Returns `false` when the key is new and the player already holds
356
+ * `maxStorageKeysPerPlayer` keys.
357
+ */
358
+ async function writeIndexedStorageValue(
359
+ redis: DevvitBridgeRedisLike,
360
+ location: StorageLocation,
361
+ serialized: string,
362
+ ): Promise<boolean> {
363
+ for (let attempt = 0; attempt < storageIndexTransactionAttempts; attempt += 1) {
364
+ const transaction = await redis.watch(location.indexKey, location.valueKey);
365
+ let multiStarted = false;
366
+
367
+ try {
368
+ if (!(await playerOwnsStorageKey(redis, location))) {
369
+ const indexedKeyCount = await redis.hLen(location.indexKey);
370
+
371
+ if (indexedKeyCount >= maxStorageKeysPerPlayer) {
372
+ await transaction.unwatch();
373
+ return false;
374
+ }
375
+ }
376
+
377
+ await transaction.multi();
378
+ multiStarted = true;
379
+ let queuedCommandCount = 0;
380
+ await transaction.set(location.valueKey, serialized);
381
+ queuedCommandCount += 1;
382
+ await transaction.hSet(location.indexKey, { [location.indexField]: '1' });
383
+ queuedCommandCount += 1;
384
+
385
+ if (storageEntryTtlSeconds !== undefined) {
386
+ await transaction.expire(location.valueKey, storageEntryTtlSeconds);
387
+ queuedCommandCount += 1;
388
+ await transaction.expire(location.indexKey, storageEntryTtlSeconds);
389
+ queuedCommandCount += 1;
390
+ }
391
+
392
+ const results = await transaction.exec();
393
+
394
+ if (results === null) {
395
+ continue;
396
+ }
397
+
398
+ if (!Array.isArray(results)) {
399
+ throw new Error('Devvit Redis transaction returned an unsupported response.');
400
+ }
401
+
402
+ if (results.length < queuedCommandCount) {
403
+ continue;
404
+ }
405
+
406
+ return true;
407
+ } catch (error) {
408
+ await bestEffortReset(transaction, multiStarted);
409
+ throw error;
410
+ }
411
+ }
412
+
413
+ throw new Error(
414
+ `Devvit Redis transaction contention exceeded ${String(storageIndexTransactionAttempts)} attempts for key: ${location.valueKey}`,
415
+ );
416
+ }
417
+
418
+ async function playerOwnsStorageKey(
419
+ redis: DevvitBridgeRedisLike,
420
+ location: StorageLocation,
421
+ ): Promise<boolean> {
422
+ if ((await redis.hGet(location.indexKey, location.indexField)) !== undefined) {
423
+ return true;
424
+ }
425
+
426
+ // Values written before the index existed are still owned by the player; they
427
+ // are backfilled into the index on their next save instead of counting as new.
428
+ return (await redis.exists(location.valueKey)) > 0;
429
+ }
430
+
431
+ async function bestEffortReset(
432
+ transaction: DevvitBridgeRedisTransactionLike,
433
+ multiStarted: boolean,
434
+ ): Promise<void> {
435
+ try {
436
+ if (multiStarted) {
437
+ await transaction.discard();
438
+ } else {
439
+ await transaction.unwatch();
440
+ }
441
+ } catch {
442
+ // Preserve the original Redis failure; this cleanup is best-effort.
443
+ }
444
+ }
445
+
446
+ function ok(input: BridgeRequest, data: unknown): BridgeResponse {
447
+ return {
448
+ id: input.id,
449
+ ok: true,
450
+ data,
451
+ };
452
+ }
453
+
454
+ interface StorageLocation {
455
+ readonly valueKey: string;
456
+ readonly indexKey: string;
457
+ readonly indexField: string;
458
+ }
459
+
460
+ export function storageValueKey(
461
+ playerId: string,
462
+ clientKey: string,
463
+ namespace: string = defaultStorageKeyNamespace,
464
+ ): string {
465
+ return `${namespace}:save:${encodeURIComponent(playerId)}:${encodeURIComponent(clientKey)}`;
466
+ }
467
+
468
+ export function storageIndexKey(
469
+ playerId: string,
470
+ namespace: string = defaultStorageKeyNamespace,
471
+ ): string {
472
+ return `${namespace}:save-keys:${encodeURIComponent(playerId)}`;
473
+ }
474
+
475
+ function storageLocation(
476
+ input: BridgeRequest,
477
+ playerId: string,
478
+ namespace: string,
479
+ ): StorageLocation | BridgeResponse {
480
+ const payload = optionalObjectPayload(input.payload);
481
+
482
+ if (typeof payload.key !== 'string' || payload.key.length === 0) {
483
+ return createBridgeError(input.id, 'INVALID_STORAGE_KEY', 'Storage key is required.');
484
+ }
485
+
486
+ if (payload.key.length > maxStorageKeyLength) {
487
+ return createBridgeError(input.id, 'INVALID_STORAGE_KEY', 'Storage key is too long.');
488
+ }
489
+
490
+ const encodedKey = encodeURIComponent(payload.key);
491
+
492
+ if (encodedKey.length > maxEncodedStorageKeyLength) {
493
+ return createBridgeError(input.id, 'INVALID_STORAGE_KEY', 'Encoded storage key is too long.');
494
+ }
495
+
496
+ return {
497
+ valueKey: storageValueKey(playerId, payload.key, namespace),
498
+ indexKey: storageIndexKey(playerId, namespace),
499
+ indexField: encodedKey,
500
+ };
501
+ }
502
+
503
+ function errorMessage(error: unknown): string {
504
+ return error instanceof Error ? error.message : String(error);
505
+ }
506
+
507
+ function optionalObjectPayload(payload: unknown): Record<string, unknown> {
508
+ if (typeof payload !== 'object' || payload === null) {
509
+ return {};
510
+ }
511
+
512
+ return payload as Record<string, unknown>;
513
+ }