@mpgd/cli 0.62.0 → 0.64.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 +14 -0
- package/dist/app-store-iap-asc.js +219 -0
- package/dist/app-store-iap-port.d.ts +125 -0
- package/dist/app-store-iap-port.js +14 -0
- 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 +10 -0
- package/dist/index.js +169 -1
- package/dist/native-build-info.json +2 -2
- package/dist/native-build-target.js +411 -339
- package/dist/play-listing-port.d.ts +88 -0
- package/dist/play-listing-port.js +12 -0
- package/dist/play-product-port.d.ts +101 -0
- package/dist/play-product-port.js +7 -0
- package/dist/play-sdk-adapter.js +168 -15
- 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-app-store.d.ts +144 -0
- package/dist/store-products-app-store.js +632 -0
- package/dist/store-products-command.d.ts +51 -0
- package/dist/store-products-command.js +155 -0
- package/dist/store-products-play.d.ts +112 -0
- package/dist/store-products-play.js +622 -0
- package/dist/store-products.d.ts +38 -0
- package/dist/store-products.js +56 -0
- package/dist/store-sync.d.ts +50 -0
- package/dist/store-sync.js +137 -0
- package/dist/testflight-submission.d.ts +1 -1
- package/package.json +13 -13
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { type ProductCatalog, type StoreListingCatalog } from '@mpgd/catalog';
|
|
2
|
+
import type { AscIapPort, AscIapType } from './app-store-iap-port.js';
|
|
3
|
+
import { type StoreProductAction } from './store-products.js';
|
|
4
|
+
/**
|
|
5
|
+
* App Store Connect in-app purchase sync: compare the game's catalog and
|
|
6
|
+
* store listing file with App Store Connect and plan the writes. The plan
|
|
7
|
+
* only reads; in-app purchases only on App Store Connect are reported, never
|
|
8
|
+
* deleted; localizations and territories the file does not name are kept.
|
|
9
|
+
* Nothing is submitted for review: an in-app purchase's first review goes
|
|
10
|
+
* with an app version (App Store Connect Help, "Submit an In-App Purchase").
|
|
11
|
+
*/
|
|
12
|
+
export type AppStoreProductChange = 'create' | 'reference-name' | 'family-sharing' | 'version' | 'localizations' | 'prices' | 'availability' | 'review-screenshot';
|
|
13
|
+
export interface AppStoreLocalizationEntry {
|
|
14
|
+
readonly locale: string;
|
|
15
|
+
readonly name: string;
|
|
16
|
+
readonly description: string;
|
|
17
|
+
}
|
|
18
|
+
export interface AppStoreProductWrite {
|
|
19
|
+
readonly create?: {
|
|
20
|
+
readonly type: AscIapType;
|
|
21
|
+
readonly referenceName: string;
|
|
22
|
+
readonly familySharable: boolean;
|
|
23
|
+
};
|
|
24
|
+
readonly update?: {
|
|
25
|
+
readonly referenceName?: string;
|
|
26
|
+
readonly familySharable?: true;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Localizations to upsert by locale. `existing` writes to `versionId`;
|
|
30
|
+
* `create` makes a new draft version first (App Store Connect copies the
|
|
31
|
+
* current metadata into it); `resolve` (a new in-app purchase) uses the
|
|
32
|
+
* version App Store Connect provisioned, or creates one.
|
|
33
|
+
*/
|
|
34
|
+
readonly localizations?: {
|
|
35
|
+
readonly version: 'existing' | 'create' | 'resolve';
|
|
36
|
+
readonly versionId?: string;
|
|
37
|
+
readonly entries: readonly AppStoreLocalizationEntry[];
|
|
38
|
+
};
|
|
39
|
+
/** A new schedule effective now; the base territory's price comes first. */
|
|
40
|
+
readonly prices?: {
|
|
41
|
+
readonly baseTerritory: string;
|
|
42
|
+
/** `pricePointId` is resolved at plan time for an existing in-app purchase. */
|
|
43
|
+
readonly prices: readonly {
|
|
44
|
+
readonly territory: string;
|
|
45
|
+
readonly customerPrice: string;
|
|
46
|
+
readonly pricePointId?: string;
|
|
47
|
+
}[];
|
|
48
|
+
};
|
|
49
|
+
readonly availability?: {
|
|
50
|
+
readonly territories: readonly string[];
|
|
51
|
+
readonly availableInNewTerritories: boolean;
|
|
52
|
+
};
|
|
53
|
+
readonly reviewScreenshot?: {
|
|
54
|
+
/** Absolute path on this checkout. */
|
|
55
|
+
readonly file: string;
|
|
56
|
+
/** Path relative to the listing file, as the file names it. */
|
|
57
|
+
readonly path: string;
|
|
58
|
+
readonly md5: string;
|
|
59
|
+
/** The completed screenshot to delete first (App Store Connect keeps one). */
|
|
60
|
+
readonly replaceId?: string;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export interface AppStorePriceChange {
|
|
64
|
+
/** Alpha-3 territory, or `base-territory`. */
|
|
65
|
+
readonly territory: string;
|
|
66
|
+
readonly from?: string;
|
|
67
|
+
readonly to?: string;
|
|
68
|
+
}
|
|
69
|
+
export interface PlannedAppStoreProduct {
|
|
70
|
+
readonly logicalId: string;
|
|
71
|
+
readonly productId: string;
|
|
72
|
+
readonly action: StoreProductAction;
|
|
73
|
+
readonly changes: readonly AppStoreProductChange[];
|
|
74
|
+
readonly iapId?: string;
|
|
75
|
+
readonly state?: string;
|
|
76
|
+
/** `APPROVED`: the in-app purchase is on sale wherever the app is. */
|
|
77
|
+
readonly live: boolean;
|
|
78
|
+
readonly livePriceChange: boolean;
|
|
79
|
+
readonly priceChanges: readonly AppStorePriceChange[];
|
|
80
|
+
readonly notes: readonly string[];
|
|
81
|
+
readonly remoteSha256: string | null;
|
|
82
|
+
readonly desiredSha256: string;
|
|
83
|
+
readonly write?: AppStoreProductWrite;
|
|
84
|
+
}
|
|
85
|
+
export interface AppStoreProductPlan {
|
|
86
|
+
readonly schemaVersion: 1;
|
|
87
|
+
readonly store: 'app-store';
|
|
88
|
+
/** Numeric App Store Connect app ID. */
|
|
89
|
+
readonly appId: string;
|
|
90
|
+
readonly bundleId: string;
|
|
91
|
+
readonly catalogTarget: string;
|
|
92
|
+
readonly keepLivePrices: boolean;
|
|
93
|
+
readonly products: readonly PlannedAppStoreProduct[];
|
|
94
|
+
readonly skipped: readonly {
|
|
95
|
+
readonly logicalId: string;
|
|
96
|
+
readonly productId?: string;
|
|
97
|
+
readonly reason: string;
|
|
98
|
+
}[];
|
|
99
|
+
/** Product IDs on App Store Connect that no catalog product names. Never deleted. */
|
|
100
|
+
readonly remoteOnly: readonly string[];
|
|
101
|
+
/** What the sync cannot do; the reviewer does it in App Store Connect. */
|
|
102
|
+
readonly manual: readonly string[];
|
|
103
|
+
}
|
|
104
|
+
export interface AppStoreProductPlanInput {
|
|
105
|
+
readonly appId: string;
|
|
106
|
+
readonly bundleId: string;
|
|
107
|
+
readonly catalog: ProductCatalog;
|
|
108
|
+
readonly listings: StoreListingCatalog;
|
|
109
|
+
/** Directory of the listing file; review screenshot paths resolve against it. */
|
|
110
|
+
readonly listingsDirectory: string;
|
|
111
|
+
/** Catalog `platformProductIds` key of the App Store; defaults to `ios`. */
|
|
112
|
+
readonly catalogTarget?: string;
|
|
113
|
+
readonly keepLivePrices?: boolean;
|
|
114
|
+
}
|
|
115
|
+
export declare function planAppStoreProducts(input: AppStoreProductPlanInput, port: AscIapPort): Promise<AppStoreProductPlan>;
|
|
116
|
+
export interface AppStoreProductApplyResult {
|
|
117
|
+
readonly appId: string;
|
|
118
|
+
readonly products: readonly {
|
|
119
|
+
readonly productId: string;
|
|
120
|
+
readonly status: 'created' | 'updated' | 'unchanged';
|
|
121
|
+
readonly iapId?: string;
|
|
122
|
+
}[];
|
|
123
|
+
readonly manual: readonly string[];
|
|
124
|
+
}
|
|
125
|
+
export declare class AppStoreProductApplyError extends Error {
|
|
126
|
+
readonly productId: string;
|
|
127
|
+
readonly stage: string;
|
|
128
|
+
readonly done: AppStoreProductApplyResult['products'];
|
|
129
|
+
constructor(productId: string, stage: string, done: AppStoreProductApplyResult['products'], cause: unknown, detail?: string);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Apply a reviewed plan, like the Google Play apply: `current` is a fresh
|
|
133
|
+
* plan, and every product apply would write must still match the reviewed
|
|
134
|
+
* remote state and planned write.
|
|
135
|
+
*/
|
|
136
|
+
export declare function applyAppStoreProductPlan(input: {
|
|
137
|
+
readonly saved: AppStoreProductPlan;
|
|
138
|
+
readonly current: AppStoreProductPlan;
|
|
139
|
+
readonly port: AscIapPort;
|
|
140
|
+
readonly approved: boolean;
|
|
141
|
+
readonly confirmPriceChange?: boolean;
|
|
142
|
+
}): Promise<AppStoreProductApplyResult>;
|
|
143
|
+
/** One line per product for the terminal, then the manual steps. */
|
|
144
|
+
export declare function formatAppStoreProductPlan(plan: AppStoreProductPlan): string;
|