@mpgd/cli 0.63.0 → 0.65.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.
- package/dist/app-store-iap-asc.d.ts +5 -14
- package/dist/app-store-iap-asc.js +9 -116
- package/dist/app-store-presence-asc.d.ts +23 -0
- package/dist/app-store-presence-asc.js +162 -0
- package/dist/app-store-presence-port.d.ts +101 -0
- package/dist/app-store-presence-port.js +13 -0
- package/dist/asc-session.d.ts +43 -0
- package/dist/asc-session.js +118 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +75 -1
- package/dist/native-build-info.json +2 -2
- package/dist/native-build-target.js +492 -413
- package/dist/play-listing-port.d.ts +88 -0
- package/dist/play-listing-port.js +12 -0
- package/dist/play-sdk-adapter.js +103 -17
- package/dist/store-presence-app-store.d.ts +139 -0
- package/dist/store-presence-app-store.js +537 -0
- package/dist/store-presence-command.d.ts +50 -0
- package/dist/store-presence-command.js +145 -0
- package/dist/store-presence-play.d.ts +116 -0
- package/dist/store-presence-play.js +495 -0
- package/dist/store-presence.d.ts +106 -0
- package/dist/store-presence.js +173 -0
- package/dist/store-products-command.d.ts +2 -1
- package/dist/store-products-command.js +15 -42
- package/dist/store-products.d.ts +3 -11
- package/dist/store-products.js +7 -75
- package/dist/store-sync.d.ts +50 -0
- package/dist/store-sync.js +137 -0
- package/package.json +19 -19
- package/templates/lite-overlay/README.md +4 -0
- package/templates/lite-overlay/apps/target-devvit/README.md +10 -0
- package/templates/phaser-game/README.md +32 -3
- package/templates/phaser-game/apps/target-ait/package.json +2 -2
- package/templates/phaser-game/apps/target-cloudflare-pages/package.json +2 -2
- package/templates/phaser-game/apps/target-devvit/README.md +10 -0
- package/templates/phaser-game/apps/target-devvit/package.json +2 -2
- package/templates/phaser-game/package.json +2 -2
- package/templates/phaser-game/src/i18n/messages.ts +3 -0
- package/templates/phaser-game/src/main.ts +42 -0
- package/templates/phaser-game/src/platform/buildGateways/capacitorAndroid.ts +5 -0
- package/templates/phaser-game/src/platform/buildGateways/capacitorIos.ts +5 -0
- package/templates/phaser-game/src/platform/commerceAccountBinding.ts +81 -1
- package/templates/phaser-game/src/platform/gameServices.ts +88 -4
- package/templates/phaser-game/src/platform/guestIdentity.ts +4 -3
- package/templates/phaser-game/src/platform/sessionGatedServices.ts +149 -21
- package/templates/phaser-game/src/styles.css +12 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { assertStorePresence, assertStorePresenceImage, readStoreImageInfo, } from '@mpgd/catalog';
|
|
5
|
+
import { readJsonFile, StorePlanStaleError } from './store-sync.js';
|
|
6
|
+
/**
|
|
7
|
+
* The game side of `mpgd store presence plan|apply`: the store presence
|
|
8
|
+
* file and the graphics it names, read and checked against the store rules
|
|
9
|
+
* before anything talks to a store.
|
|
10
|
+
*/
|
|
11
|
+
export const defaultStorePresenceFileName = 'mpgd.store-presence.json';
|
|
12
|
+
export const storePresencePlanCommand = 'mpgd store presence plan';
|
|
13
|
+
/**
|
|
14
|
+
* Read and validate the store presence file. A given path resolves against
|
|
15
|
+
* the current directory, like the other mpgd file flags; the default is the
|
|
16
|
+
* game's own file.
|
|
17
|
+
*/
|
|
18
|
+
export function readStorePresenceSource(input) {
|
|
19
|
+
const file = input.presenceFile === undefined
|
|
20
|
+
? path.join(path.resolve(input.gameRoot), defaultStorePresenceFileName)
|
|
21
|
+
: path.resolve(input.presenceFile);
|
|
22
|
+
if (!existsSync(file)) {
|
|
23
|
+
throw new Error(`Store presence sync needs ${file}.`);
|
|
24
|
+
}
|
|
25
|
+
return { file, directory: path.dirname(file), presence: assertStorePresence(readJsonFile(file)) };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Read one graphic: it must stay inside the file's directory (symbolic links
|
|
29
|
+
* included), be the PNG or JPEG its extension says, and meet the store's
|
|
30
|
+
* rules for its slot.
|
|
31
|
+
*/
|
|
32
|
+
export function readLocalStoreImage(directory, relative, target, label) {
|
|
33
|
+
const file = path.resolve(directory, relative);
|
|
34
|
+
if (!existsSync(file) || !statSync(file).isFile()) {
|
|
35
|
+
throw new Error(`${label}: ${relative} is missing.`);
|
|
36
|
+
}
|
|
37
|
+
const real = path.relative(realpathSync(directory), realpathSync(file));
|
|
38
|
+
if (real === '..' || real.startsWith(`..${path.sep}`) || path.isAbsolute(real)) {
|
|
39
|
+
throw new Error(`${label}: ${relative} resolves outside the store presence file's directory.`);
|
|
40
|
+
}
|
|
41
|
+
const bytes = readFileSync(file);
|
|
42
|
+
let info;
|
|
43
|
+
try {
|
|
44
|
+
info = readStoreImageInfo(bytes);
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
throw new Error(`${label}: ${relative} is ${error.message}.`);
|
|
48
|
+
}
|
|
49
|
+
const extensionFormat = /\.png$/iu.test(relative) ? 'png' : 'jpeg';
|
|
50
|
+
if (info.format !== extensionFormat) {
|
|
51
|
+
throw new Error(`${label}: ${relative} is a ${info.format} file with a .${path.extname(relative).slice(1)} name.`);
|
|
52
|
+
}
|
|
53
|
+
assertStorePresenceImage(target, info, bytes.byteLength, `${label}: ${relative}`);
|
|
54
|
+
return {
|
|
55
|
+
path: relative,
|
|
56
|
+
file,
|
|
57
|
+
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
58
|
+
sha1: createHash('sha1').update(bytes).digest('hex'),
|
|
59
|
+
md5: createHash('md5').update(bytes).digest('hex'),
|
|
60
|
+
byteLength: bytes.byteLength,
|
|
61
|
+
info,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** Refuse to upload a graphic whose bytes changed after the plan was checked. */
|
|
65
|
+
export function assertLocalStoreImageUnchanged(file, sha256) {
|
|
66
|
+
const current = createHash('sha256').update(readFileSync(file)).digest('hex');
|
|
67
|
+
if (current !== sha256) {
|
|
68
|
+
throw new StorePlanStaleError(`${file} changed since the plan.`, storePresencePlanCommand);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export function imageMimeType(info) {
|
|
72
|
+
return info.format === 'png' ? 'image/png' : 'image/jpeg';
|
|
73
|
+
}
|
|
74
|
+
const shortText = 80;
|
|
75
|
+
/** CRLF and lone CR as LF, so a file saved on Windows and the store's text compare equal. */
|
|
76
|
+
export function normalizeLineBreaks(value) {
|
|
77
|
+
return value.replace(/\r\n?/gu, '\n');
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Two URLs that differ only in form (host case, a trailing slash, default
|
|
81
|
+
* port): a store may normalize the URL it was given.
|
|
82
|
+
*/
|
|
83
|
+
export function sameUrlForm(left, right) {
|
|
84
|
+
const normal = (value) => {
|
|
85
|
+
try {
|
|
86
|
+
return new URL(value).href.replace(/\/$/u, '');
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const a = normal(left);
|
|
93
|
+
return a !== undefined && a === normal(right);
|
|
94
|
+
}
|
|
95
|
+
/** Compares after normalizing line breaks; `to` is expected normalized already. */
|
|
96
|
+
export function describeTextChange(field, from, to) {
|
|
97
|
+
const before = typeof from === 'string' ? normalizeLineBreaks(from) : null;
|
|
98
|
+
if (before === to) {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
const short = (value) => value === null || [...value].length <= shortText;
|
|
102
|
+
return {
|
|
103
|
+
field,
|
|
104
|
+
fromLength: before === null ? null : [...before].length,
|
|
105
|
+
toLength: [...to].length,
|
|
106
|
+
...(short(before) && short(to) ? { ...(before === null ? {} : { from: before }), to } : {}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
export function formatTextChange(change) {
|
|
110
|
+
const lengths = `${change.fromLength === null ? 'new' : `${change.fromLength} chars`} -> ${change.toLength} chars`;
|
|
111
|
+
if (change.to === undefined) {
|
|
112
|
+
return `${change.field}: ${lengths}`;
|
|
113
|
+
}
|
|
114
|
+
return `${change.field}: ${JSON.stringify(change.from ?? null)} -> ${JSON.stringify(change.to)}`;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Plan one image slot. A single-image slot (an icon) is replaced when it
|
|
118
|
+
* differs. A list adds the images the store lacks after the ones it holds;
|
|
119
|
+
* a complete set appends when the store holds a prefix of it, else replaces
|
|
120
|
+
* the slot so the store holds exactly the set in order.
|
|
121
|
+
*/
|
|
122
|
+
export function planStoreImageSet(input) {
|
|
123
|
+
const { local, remote, storeName } = input;
|
|
124
|
+
const remoteDigests = remote.map((image) => image.digest);
|
|
125
|
+
const desiredDigests = local.map((image) => image.digest);
|
|
126
|
+
const notes = [];
|
|
127
|
+
const unchanged = () => ({
|
|
128
|
+
action: 'unchanged',
|
|
129
|
+
upload: [],
|
|
130
|
+
keep: remoteDigests,
|
|
131
|
+
remove: [],
|
|
132
|
+
notes,
|
|
133
|
+
});
|
|
134
|
+
const replace = () => ({
|
|
135
|
+
action: 'replace',
|
|
136
|
+
upload: local,
|
|
137
|
+
keep: [],
|
|
138
|
+
remove: remote.filter((image) => !desiredDigests.includes(image.digest)),
|
|
139
|
+
notes,
|
|
140
|
+
});
|
|
141
|
+
const same = remoteDigests.length === desiredDigests.length
|
|
142
|
+
&& remoteDigests.every((digest, index) => digest === desiredDigests[index]);
|
|
143
|
+
if (same) {
|
|
144
|
+
return unchanged();
|
|
145
|
+
}
|
|
146
|
+
if (input.single) {
|
|
147
|
+
return remote.length === 0
|
|
148
|
+
? { action: 'add', upload: local, keep: [], remove: [], notes }
|
|
149
|
+
: replace();
|
|
150
|
+
}
|
|
151
|
+
const isPrefix = remoteDigests.length < desiredDigests.length
|
|
152
|
+
&& remoteDigests.every((digest, index) => digest === desiredDigests[index]);
|
|
153
|
+
if (input.complete && !isPrefix) {
|
|
154
|
+
if (remote.some((image) => desiredDigests.includes(image.digest))) {
|
|
155
|
+
notes.push(`${storeName}'s images are deleted and the set uploaded again in the file's order.`);
|
|
156
|
+
}
|
|
157
|
+
return replace();
|
|
158
|
+
}
|
|
159
|
+
const missing = local.filter((image) => !remoteDigests.includes(image.digest));
|
|
160
|
+
if (missing.length === 0) {
|
|
161
|
+
notes.push(`${storeName} holds the file's images in another order or with others; mark the set complete to match it.`);
|
|
162
|
+
return unchanged();
|
|
163
|
+
}
|
|
164
|
+
if (remote.length + missing.length > input.maxImages) {
|
|
165
|
+
throw new Error(`${input.label}: ${storeName} holds ${remote.length} and the file adds ${missing.length}; a set takes at `
|
|
166
|
+
+ `most ${input.maxImages}. Mark the set complete to replace ${storeName}'s images.`);
|
|
167
|
+
}
|
|
168
|
+
const kept = remote.length - (local.length - missing.length);
|
|
169
|
+
if (!input.complete && kept > 0) {
|
|
170
|
+
notes.push(`${storeName} keeps ${kept} image(s) the file does not list.`);
|
|
171
|
+
}
|
|
172
|
+
return { action: 'add', upload: missing, keep: remoteDigests, remove: [], notes };
|
|
173
|
+
}
|
|
@@ -3,12 +3,13 @@ import { type AscCredential } from './app-store-iap-asc.js';
|
|
|
3
3
|
import type { PlayProductCatalogPort } from './play-product-port.js';
|
|
4
4
|
import { type AppStoreProductApplyResult, type AppStoreProductPlan } from './store-products-app-store.js';
|
|
5
5
|
import { type PlayProductApplyResult, type PlayProductPlan } from './store-products-play.js';
|
|
6
|
+
import { type StoreSyncStore } from './store-sync.js';
|
|
6
7
|
/**
|
|
7
8
|
* `mpgd store products plan|apply`: the game's deployment profile names the
|
|
8
9
|
* app and the store credential (the same ones `deploy submit` uses), the
|
|
9
10
|
* game's catalog and store listing file name the products.
|
|
10
11
|
*/
|
|
11
|
-
export type StoreProductStore =
|
|
12
|
+
export type StoreProductStore = StoreSyncStore;
|
|
12
13
|
export interface StoreProductPlanOptions {
|
|
13
14
|
readonly game: string;
|
|
14
15
|
readonly profile: string;
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { existsSync, statSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { openAscIapSession } from './app-store-iap-asc.js';
|
|
4
|
-
import {
|
|
5
|
-
import { readStoreCredential } from './native-deploy-run.js';
|
|
6
|
-
import { readStoreProductPlanFile, readStoreProductSources, StoreProductPlanStaleError, writeStoreProductPlan, } from './store-products.js';
|
|
4
|
+
import { readStoreProductSources, StoreProductPlanStaleError, } from './store-products.js';
|
|
7
5
|
import { applyAppStoreProductPlan, planAppStoreProducts, } from './store-products-app-store.js';
|
|
8
6
|
import { applyPlayProductPlan, planPlayProducts, } from './store-products-play.js';
|
|
7
|
+
import { assertStoreSyncStore, readStorePlanFile, relocateStoreSyncFile, resolveStoreSyncApp, writeStorePlanFile, } from './store-sync.js';
|
|
9
8
|
const defaultPorts = {
|
|
10
9
|
googlePlay: loadPlayProductCatalog,
|
|
11
10
|
appStore: openAscIapSession,
|
|
@@ -22,7 +21,7 @@ export async function planStoreProducts(options, environment = process.env, port
|
|
|
22
21
|
}));
|
|
23
22
|
}
|
|
24
23
|
export function saveStoreProductPlan(file, plan) {
|
|
25
|
-
return
|
|
24
|
+
return writeStorePlanFile(file, plan);
|
|
26
25
|
}
|
|
27
26
|
export async function applyStoreProducts(options, environment = process.env, ports = {}) {
|
|
28
27
|
if (!options.approved) {
|
|
@@ -30,16 +29,7 @@ export async function applyStoreProducts(options, environment = process.env, por
|
|
|
30
29
|
}
|
|
31
30
|
const saved = readSavedPlan(options.planFile);
|
|
32
31
|
const gameRoot = options.game === undefined ? saved.gameRoot : path.resolve(options.game);
|
|
33
|
-
const relocate = (file) =>
|
|
34
|
-
if (options.game === undefined) {
|
|
35
|
-
return file;
|
|
36
|
-
}
|
|
37
|
-
const relative = path.relative(saved.gameRoot, file);
|
|
38
|
-
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
39
|
-
throw new Error(`The saved plan reads ${file}, outside its game directory; --game cannot move it. Plan again.`);
|
|
40
|
-
}
|
|
41
|
-
return path.resolve(gameRoot, relative);
|
|
42
|
-
};
|
|
32
|
+
const relocate = (file) => relocateStoreSyncFile(saved.gameRoot, options.game === undefined ? undefined : gameRoot, file);
|
|
43
33
|
const planOptions = {
|
|
44
34
|
game: gameRoot,
|
|
45
35
|
profile: saved.profile,
|
|
@@ -82,42 +72,25 @@ export async function applyStoreProducts(options, environment = process.env, por
|
|
|
82
72
|
});
|
|
83
73
|
}
|
|
84
74
|
async function withStoreSession(options, environment, overrides, action) {
|
|
85
|
-
|
|
86
|
-
throw new Error('--store must be google-play or app-store.');
|
|
87
|
-
}
|
|
75
|
+
assertStoreSyncStore(options.store);
|
|
88
76
|
const ports = { ...defaultPorts, ...overrides };
|
|
89
|
-
|
|
90
|
-
const target = options.store === 'google-play' ? 'android' : 'ios';
|
|
91
|
-
const deployment = planNativeDeployment({
|
|
92
|
-
game: gameRoot,
|
|
93
|
-
profile: options.profile,
|
|
94
|
-
targets: [target],
|
|
95
|
-
});
|
|
96
|
-
const planned = deployment.targets.find((entry) => entry.target === target);
|
|
97
|
-
if (planned === undefined) {
|
|
98
|
-
throw new Error(`Deployment profile ${options.profile} has no ${target} target.`);
|
|
99
|
-
}
|
|
77
|
+
// The game files first: a malformed file reports its own error without a credential.
|
|
100
78
|
const sources = readStoreProductSources({
|
|
101
|
-
gameRoot,
|
|
79
|
+
gameRoot: path.resolve(options.game),
|
|
102
80
|
...(options.catalogFile === undefined ? {} : { catalogFile: options.catalogFile }),
|
|
103
81
|
...(options.listingsFile === undefined ? {} : { listingsFile: options.listingsFile }),
|
|
104
82
|
});
|
|
105
|
-
const
|
|
106
|
-
if (
|
|
107
|
-
const port = await ports.googlePlay(
|
|
108
|
-
return action({ store: 'google-play', packageName:
|
|
83
|
+
const app = resolveStoreSyncApp(options, environment);
|
|
84
|
+
if (app.store === 'google-play') {
|
|
85
|
+
const port = await ports.googlePlay(app.serviceAccountFile);
|
|
86
|
+
return action({ store: 'google-play', packageName: app.packageName, port, sources });
|
|
109
87
|
}
|
|
110
|
-
const session = await ports.appStore(
|
|
111
|
-
ascBinary: credential.ascBinary,
|
|
112
|
-
apiKeyId: credential.apiKeyId,
|
|
113
|
-
apiIssuerId: credential.apiIssuerId,
|
|
114
|
-
apiPrivateKeyBase64: credential.apiPrivateKeyBase64,
|
|
115
|
-
}, options.signal);
|
|
88
|
+
const session = await ports.appStore(app.credential, options.signal);
|
|
116
89
|
try {
|
|
117
90
|
return await action({
|
|
118
91
|
store: 'app-store',
|
|
119
|
-
appId:
|
|
120
|
-
bundleId:
|
|
92
|
+
appId: app.appId,
|
|
93
|
+
bundleId: app.bundleId,
|
|
121
94
|
port: session.port,
|
|
122
95
|
sources,
|
|
123
96
|
});
|
|
@@ -148,7 +121,7 @@ async function planWithSession(session, options) {
|
|
|
148
121
|
}, session.port);
|
|
149
122
|
}
|
|
150
123
|
function readSavedPlan(file) {
|
|
151
|
-
const raw =
|
|
124
|
+
const raw = readStorePlanFile(file, 'store product plan');
|
|
152
125
|
const plan = raw.plan;
|
|
153
126
|
const storeFields = typeof plan === 'object' && plan !== null && ((plan.store === 'google-play' && typeof plan.packageName === 'string')
|
|
154
127
|
|| (plan.store === 'app-store' && typeof plan.appId === 'string' && typeof plan.bundleId === 'string'));
|
package/dist/store-products.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { type ProductCatalog, type StoreListingCatalog } from '@mpgd/catalog';
|
|
2
|
+
import { StorePlanStaleError } from './store-sync.js';
|
|
3
|
+
export { canonicalLanguageTag, mapWithConcurrency, stableJson, stableSha256, } from './store-sync.js';
|
|
2
4
|
/**
|
|
3
5
|
* Shared pieces of `mpgd store products plan|apply`: the game files the sync
|
|
4
6
|
* reads, the saved plan file, and the stable digests apply uses to refuse a
|
|
@@ -24,13 +26,7 @@ export declare function readStoreProductSources(input: {
|
|
|
24
26
|
readonly catalogFile?: string;
|
|
25
27
|
readonly listingsFile?: string;
|
|
26
28
|
}): StoreProductSources;
|
|
27
|
-
|
|
28
|
-
export declare function stableJson(value: unknown): string;
|
|
29
|
-
export declare function stableSha256(value: unknown): string;
|
|
30
|
-
/** A new plan file, never overwriting one (the same rule as `deploy plan --out`). */
|
|
31
|
-
export declare function writeStoreProductPlan(file: string, plan: unknown): string;
|
|
32
|
-
export declare function readStoreProductPlanFile(file: string): Record<string, unknown>;
|
|
33
|
-
export declare class StoreProductPlanStaleError extends Error {
|
|
29
|
+
export declare class StoreProductPlanStaleError extends StorePlanStaleError {
|
|
34
30
|
constructor(detail: string);
|
|
35
31
|
}
|
|
36
32
|
/** Decimal string (`"0.99"`) as whole units and nanos, without floating point. */
|
|
@@ -40,7 +36,3 @@ export declare function decimalToUnitsAndNanos(amount: string): {
|
|
|
40
36
|
};
|
|
41
37
|
/** Decimal strings compared by value: `0.99` equals `0.990`. */
|
|
42
38
|
export declare function sameDecimal(left: string, right: string): boolean;
|
|
43
|
-
/** BCP-47 casing: language lowercase, script title case, region uppercase. */
|
|
44
|
-
export declare function canonicalLanguageTag(tag: string): string;
|
|
45
|
-
/** `map` with at most `limit` calls in flight; results keep input order. */
|
|
46
|
-
export declare function mapWithConcurrency<T, R>(items: readonly T[], limit: number, map: (item: T) => Promise<R>): Promise<R[]>;
|
package/dist/store-products.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
3
2
|
import path from 'node:path';
|
|
4
3
|
import { assertProductCatalog, assertStoreListingCatalog, } from '@mpgd/catalog';
|
|
4
|
+
import { readJsonFile, StorePlanStaleError } from './store-sync.js';
|
|
5
|
+
export { canonicalLanguageTag, mapWithConcurrency, stableJson, stableSha256, } from './store-sync.js';
|
|
5
6
|
export const defaultStoreListingFileName = 'mpgd.store-listings.json';
|
|
6
7
|
export const defaultProductCatalogFileName = 'mpgd.catalog.json';
|
|
7
8
|
/**
|
|
@@ -22,33 +23,13 @@ export function readStoreProductSources(input) {
|
|
|
22
23
|
throw new Error(`Store product sync needs ${file}.`);
|
|
23
24
|
}
|
|
24
25
|
}
|
|
25
|
-
const catalog = assertProductCatalog(
|
|
26
|
-
const listings = assertStoreListingCatalog(
|
|
26
|
+
const catalog = assertProductCatalog(readJsonFile(catalogFile));
|
|
27
|
+
const listings = assertStoreListingCatalog(readJsonFile(listingsFile), catalog);
|
|
27
28
|
return { catalogFile, listingsFile, catalog, listings };
|
|
28
29
|
}
|
|
29
|
-
|
|
30
|
-
export function stableJson(value) {
|
|
31
|
-
return JSON.stringify(sortKeys(value));
|
|
32
|
-
}
|
|
33
|
-
export function stableSha256(value) {
|
|
34
|
-
return createHash('sha256').update(stableJson(value)).digest('hex');
|
|
35
|
-
}
|
|
36
|
-
/** A new plan file, never overwriting one (the same rule as `deploy plan --out`). */
|
|
37
|
-
export function writeStoreProductPlan(file, plan) {
|
|
38
|
-
const resolved = path.resolve(file);
|
|
39
|
-
writeFileSync(resolved, `${JSON.stringify(plan, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
40
|
-
return resolved;
|
|
41
|
-
}
|
|
42
|
-
export function readStoreProductPlanFile(file) {
|
|
43
|
-
const raw = readJson(path.resolve(file));
|
|
44
|
-
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
45
|
-
throw new Error('Saved store product plan is malformed.');
|
|
46
|
-
}
|
|
47
|
-
return raw;
|
|
48
|
-
}
|
|
49
|
-
export class StoreProductPlanStaleError extends Error {
|
|
30
|
+
export class StoreProductPlanStaleError extends StorePlanStaleError {
|
|
50
31
|
constructor(detail) {
|
|
51
|
-
super(
|
|
32
|
+
super(detail, 'mpgd store products plan');
|
|
52
33
|
this.name = 'StoreProductPlanStaleError';
|
|
53
34
|
}
|
|
54
35
|
}
|
|
@@ -73,52 +54,3 @@ export function sameDecimal(left, right) {
|
|
|
73
54
|
};
|
|
74
55
|
return normal(left) === normal(right);
|
|
75
56
|
}
|
|
76
|
-
/** BCP-47 casing: language lowercase, script title case, region uppercase. */
|
|
77
|
-
export function canonicalLanguageTag(tag) {
|
|
78
|
-
return tag.split('-').map((part, index) => {
|
|
79
|
-
if (index === 0) {
|
|
80
|
-
return part.toLowerCase();
|
|
81
|
-
}
|
|
82
|
-
if (/^[A-Za-z]{4}$/u.test(part)) {
|
|
83
|
-
return `${part.slice(0, 1).toUpperCase()}${part.slice(1).toLowerCase()}`;
|
|
84
|
-
}
|
|
85
|
-
if (/^[A-Za-z]{2}$/u.test(part)) {
|
|
86
|
-
return part.toUpperCase();
|
|
87
|
-
}
|
|
88
|
-
return part.toLowerCase();
|
|
89
|
-
}).join('-');
|
|
90
|
-
}
|
|
91
|
-
/** `map` with at most `limit` calls in flight; results keep input order. */
|
|
92
|
-
export async function mapWithConcurrency(items, limit, map) {
|
|
93
|
-
const results = new Array(items.length);
|
|
94
|
-
let next = 0;
|
|
95
|
-
const worker = async () => {
|
|
96
|
-
while (next < items.length) {
|
|
97
|
-
const index = next;
|
|
98
|
-
next += 1;
|
|
99
|
-
results[index] = await map(items[index]);
|
|
100
|
-
}
|
|
101
|
-
};
|
|
102
|
-
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
103
|
-
return results;
|
|
104
|
-
}
|
|
105
|
-
function readJson(file) {
|
|
106
|
-
try {
|
|
107
|
-
return JSON.parse(readFileSync(file, 'utf8'));
|
|
108
|
-
}
|
|
109
|
-
catch (error) {
|
|
110
|
-
throw new Error(`${file} is not valid JSON: ${error.message}`);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
function sortKeys(value) {
|
|
114
|
-
if (Array.isArray(value)) {
|
|
115
|
-
return value.map(sortKeys);
|
|
116
|
-
}
|
|
117
|
-
if (typeof value === 'object' && value !== null) {
|
|
118
|
-
return Object.fromEntries(Object.keys(value)
|
|
119
|
-
.sort()
|
|
120
|
-
.filter((key) => value[key] !== undefined)
|
|
121
|
-
.map((key) => [key, sortKeys(value[key])]));
|
|
122
|
-
}
|
|
123
|
-
return value;
|
|
124
|
-
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { AscCredential } from './app-store-iap-asc.js';
|
|
2
|
+
/**
|
|
3
|
+
* What every `mpgd store <kind> plan|apply` shares: the app and credential
|
|
4
|
+
* the deployment profile names, the saved plan file, the stable digests
|
|
5
|
+
* apply compares, and moving a reviewed plan to another checkout.
|
|
6
|
+
*/
|
|
7
|
+
export type StoreSyncStore = 'google-play' | 'app-store';
|
|
8
|
+
/** The store app the deployment profile names, with its credential. */
|
|
9
|
+
export type StoreSyncApp = {
|
|
10
|
+
readonly store: 'google-play';
|
|
11
|
+
readonly packageName: string;
|
|
12
|
+
readonly serviceAccountFile: string;
|
|
13
|
+
} | {
|
|
14
|
+
readonly store: 'app-store';
|
|
15
|
+
/** Numeric App Store Connect app ID. */
|
|
16
|
+
readonly appId: string;
|
|
17
|
+
readonly bundleId: string;
|
|
18
|
+
readonly credential: AscCredential;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the app and credential of `store` from the game's deployment
|
|
22
|
+
* profile, the same ones `deploy submit` uses.
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolveStoreSyncApp(options: {
|
|
25
|
+
readonly game: string;
|
|
26
|
+
readonly profile: string;
|
|
27
|
+
readonly store: StoreSyncStore;
|
|
28
|
+
}, environment: NodeJS.ProcessEnv): StoreSyncApp;
|
|
29
|
+
export declare function assertStoreSyncStore(store: string): asserts store is StoreSyncStore;
|
|
30
|
+
/**
|
|
31
|
+
* A file a saved plan read, moved to the game directory apply runs in
|
|
32
|
+
* (`apply --game`). Files outside the saved game directory cannot move.
|
|
33
|
+
*/
|
|
34
|
+
export declare function relocateStoreSyncFile(savedGameRoot: string, gameRoot: string | undefined, file: string): string;
|
|
35
|
+
/** JSON with object keys sorted, so equal values always hash the same. */
|
|
36
|
+
export declare function stableJson(value: unknown): string;
|
|
37
|
+
export declare function stableSha256(value: unknown): string;
|
|
38
|
+
/** A new plan file, never overwriting one (the same rule as `deploy plan --out`). */
|
|
39
|
+
export declare function writeStorePlanFile(file: string, plan: unknown): string;
|
|
40
|
+
/** A saved plan's top-level object; the caller checks its fields. */
|
|
41
|
+
export declare function readStorePlanFile(file: string, label: string): Record<string, unknown>;
|
|
42
|
+
/** The store or the game files moved past the reviewed plan. */
|
|
43
|
+
export declare class StorePlanStaleError extends Error {
|
|
44
|
+
constructor(detail: string, planCommand: string);
|
|
45
|
+
}
|
|
46
|
+
/** BCP-47 casing: language lowercase, script title case, region uppercase. */
|
|
47
|
+
export declare function canonicalLanguageTag(tag: string): string;
|
|
48
|
+
/** `map` with at most `limit` calls in flight; results keep input order. */
|
|
49
|
+
export declare function mapWithConcurrency<T, R>(items: readonly T[], limit: number, map: (item: T) => Promise<R>): Promise<R[]>;
|
|
50
|
+
export declare function readJsonFile(file: string): unknown;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { planNativeDeployment } from './deploy-planning.js';
|
|
5
|
+
import { readStoreCredential } from './native-deploy-run.js';
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the app and credential of `store` from the game's deployment
|
|
8
|
+
* profile, the same ones `deploy submit` uses.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveStoreSyncApp(options, environment) {
|
|
11
|
+
assertStoreSyncStore(options.store);
|
|
12
|
+
const target = options.store === 'google-play' ? 'android' : 'ios';
|
|
13
|
+
const deployment = planNativeDeployment({
|
|
14
|
+
game: path.resolve(options.game),
|
|
15
|
+
profile: options.profile,
|
|
16
|
+
targets: [target],
|
|
17
|
+
});
|
|
18
|
+
const planned = deployment.targets.find((entry) => entry.target === target);
|
|
19
|
+
if (planned === undefined) {
|
|
20
|
+
throw new Error(`Deployment profile ${options.profile} has no ${target} target.`);
|
|
21
|
+
}
|
|
22
|
+
const credential = readStoreCredential(deployment, target, environment);
|
|
23
|
+
if (credential.target === 'android') {
|
|
24
|
+
return {
|
|
25
|
+
store: 'google-play',
|
|
26
|
+
packageName: planned.appId,
|
|
27
|
+
serviceAccountFile: credential.serviceAccountFile,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
store: 'app-store',
|
|
32
|
+
appId: credential.appStoreAppId,
|
|
33
|
+
bundleId: planned.appId,
|
|
34
|
+
credential: {
|
|
35
|
+
ascBinary: credential.ascBinary,
|
|
36
|
+
apiKeyId: credential.apiKeyId,
|
|
37
|
+
apiIssuerId: credential.apiIssuerId,
|
|
38
|
+
apiPrivateKeyBase64: credential.apiPrivateKeyBase64,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export function assertStoreSyncStore(store) {
|
|
43
|
+
if (store !== 'google-play' && store !== 'app-store') {
|
|
44
|
+
throw new Error('--store must be google-play or app-store.');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A file a saved plan read, moved to the game directory apply runs in
|
|
49
|
+
* (`apply --game`). Files outside the saved game directory cannot move.
|
|
50
|
+
*/
|
|
51
|
+
export function relocateStoreSyncFile(savedGameRoot, gameRoot, file) {
|
|
52
|
+
if (gameRoot === undefined) {
|
|
53
|
+
return file;
|
|
54
|
+
}
|
|
55
|
+
const relative = path.relative(savedGameRoot, file);
|
|
56
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
57
|
+
throw new Error(`The saved plan reads ${file}, outside its game directory; --game cannot move it. Plan again.`);
|
|
58
|
+
}
|
|
59
|
+
return path.resolve(gameRoot, relative);
|
|
60
|
+
}
|
|
61
|
+
/** JSON with object keys sorted, so equal values always hash the same. */
|
|
62
|
+
export function stableJson(value) {
|
|
63
|
+
return JSON.stringify(sortKeys(value));
|
|
64
|
+
}
|
|
65
|
+
export function stableSha256(value) {
|
|
66
|
+
return createHash('sha256').update(stableJson(value)).digest('hex');
|
|
67
|
+
}
|
|
68
|
+
/** A new plan file, never overwriting one (the same rule as `deploy plan --out`). */
|
|
69
|
+
export function writeStorePlanFile(file, plan) {
|
|
70
|
+
const resolved = path.resolve(file);
|
|
71
|
+
writeFileSync(resolved, `${JSON.stringify(plan, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
72
|
+
return resolved;
|
|
73
|
+
}
|
|
74
|
+
/** A saved plan's top-level object; the caller checks its fields. */
|
|
75
|
+
export function readStorePlanFile(file, label) {
|
|
76
|
+
const raw = readJsonFile(path.resolve(file));
|
|
77
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
78
|
+
throw new Error(`Saved ${label} is malformed.`);
|
|
79
|
+
}
|
|
80
|
+
return raw;
|
|
81
|
+
}
|
|
82
|
+
/** The store or the game files moved past the reviewed plan. */
|
|
83
|
+
export class StorePlanStaleError extends Error {
|
|
84
|
+
constructor(detail, planCommand) {
|
|
85
|
+
super(`${detail} Run "${planCommand}" again and review the new plan.`);
|
|
86
|
+
this.name = 'StorePlanStaleError';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** BCP-47 casing: language lowercase, script title case, region uppercase. */
|
|
90
|
+
export function canonicalLanguageTag(tag) {
|
|
91
|
+
return tag.split('-').map((part, index) => {
|
|
92
|
+
if (index === 0) {
|
|
93
|
+
return part.toLowerCase();
|
|
94
|
+
}
|
|
95
|
+
if (/^[A-Za-z]{4}$/u.test(part)) {
|
|
96
|
+
return `${part.slice(0, 1).toUpperCase()}${part.slice(1).toLowerCase()}`;
|
|
97
|
+
}
|
|
98
|
+
if (/^[A-Za-z]{2}$/u.test(part)) {
|
|
99
|
+
return part.toUpperCase();
|
|
100
|
+
}
|
|
101
|
+
return part.toLowerCase();
|
|
102
|
+
}).join('-');
|
|
103
|
+
}
|
|
104
|
+
/** `map` with at most `limit` calls in flight; results keep input order. */
|
|
105
|
+
export async function mapWithConcurrency(items, limit, map) {
|
|
106
|
+
const results = new Array(items.length);
|
|
107
|
+
let next = 0;
|
|
108
|
+
const worker = async () => {
|
|
109
|
+
while (next < items.length) {
|
|
110
|
+
const index = next;
|
|
111
|
+
next += 1;
|
|
112
|
+
results[index] = await map(items[index]);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
116
|
+
return results;
|
|
117
|
+
}
|
|
118
|
+
export function readJsonFile(file) {
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
throw new Error(`${file} is not valid JSON: ${error.message}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function sortKeys(value) {
|
|
127
|
+
if (Array.isArray(value)) {
|
|
128
|
+
return value.map(sortKeys);
|
|
129
|
+
}
|
|
130
|
+
if (typeof value === 'object' && value !== null) {
|
|
131
|
+
return Object.fromEntries(Object.keys(value)
|
|
132
|
+
.sort()
|
|
133
|
+
.filter((key) => value[key] !== undefined)
|
|
134
|
+
.map((key) => [key, sortKeys(value[key])]));
|
|
135
|
+
}
|
|
136
|
+
return value;
|
|
137
|
+
}
|