@mpgd/cli 0.62.0 → 0.63.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 +23 -0
- package/dist/app-store-iap-asc.js +326 -0
- package/dist/app-store-iap-port.d.ts +125 -0
- package/dist/app-store-iap-port.js +14 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +95 -1
- package/dist/native-build-info.json +2 -2
- package/dist/native-build-target.js +344 -339
- package/dist/play-product-port.d.ts +101 -0
- package/dist/play-product-port.js +7 -0
- package/dist/play-sdk-adapter.js +67 -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 +50 -0
- package/dist/store-products-command.js +182 -0
- package/dist/store-products-play.d.ts +112 -0
- package/dist/store-products-play.js +622 -0
- package/dist/store-products.d.ts +46 -0
- package/dist/store-products.js +124 -0
- package/dist/testflight-submission.d.ts +1 -1
- package/package.json +14 -14
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { openAscIapSession } from './app-store-iap-asc.js';
|
|
4
|
+
import { planNativeDeployment } from './deploy-planning.js';
|
|
5
|
+
import { readStoreCredential } from './native-deploy-run.js';
|
|
6
|
+
import { readStoreProductPlanFile, readStoreProductSources, StoreProductPlanStaleError, writeStoreProductPlan, } from './store-products.js';
|
|
7
|
+
import { applyAppStoreProductPlan, planAppStoreProducts, } from './store-products-app-store.js';
|
|
8
|
+
import { applyPlayProductPlan, planPlayProducts, } from './store-products-play.js';
|
|
9
|
+
const defaultPorts = {
|
|
10
|
+
googlePlay: loadPlayProductCatalog,
|
|
11
|
+
appStore: openAscIapSession,
|
|
12
|
+
};
|
|
13
|
+
export async function planStoreProducts(options, environment = process.env, ports = {}) {
|
|
14
|
+
return withStoreSession(options, environment, ports, async (session) => ({
|
|
15
|
+
schemaVersion: 1,
|
|
16
|
+
kind: 'mpgd-store-products-plan',
|
|
17
|
+
gameRoot: path.resolve(options.game),
|
|
18
|
+
profile: options.profile,
|
|
19
|
+
catalogFile: session.sources.catalogFile,
|
|
20
|
+
listingsFile: session.sources.listingsFile,
|
|
21
|
+
plan: await planWithSession(session, options),
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
export function saveStoreProductPlan(file, plan) {
|
|
25
|
+
return writeStoreProductPlan(file, plan);
|
|
26
|
+
}
|
|
27
|
+
export async function applyStoreProducts(options, environment = process.env, ports = {}) {
|
|
28
|
+
if (!options.approved) {
|
|
29
|
+
throw new Error('Writing store products requires --approve.');
|
|
30
|
+
}
|
|
31
|
+
const saved = readSavedPlan(options.planFile);
|
|
32
|
+
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
|
+
};
|
|
43
|
+
const planOptions = {
|
|
44
|
+
game: gameRoot,
|
|
45
|
+
profile: saved.profile,
|
|
46
|
+
store: saved.plan.store,
|
|
47
|
+
catalogFile: relocate(saved.catalogFile),
|
|
48
|
+
listingsFile: relocate(saved.listingsFile),
|
|
49
|
+
catalogTarget: saved.plan.catalogTarget,
|
|
50
|
+
keepLivePrices: saved.plan.keepLivePrices,
|
|
51
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
52
|
+
};
|
|
53
|
+
return withStoreSession(planOptions, environment, ports, async (session) => {
|
|
54
|
+
const current = await planWithSession(session, planOptions);
|
|
55
|
+
if (session.store === 'google-play' && current.store === 'google-play'
|
|
56
|
+
&& saved.plan.store === 'google-play') {
|
|
57
|
+
if (current.packageName !== saved.plan.packageName) {
|
|
58
|
+
throw new StoreProductPlanStaleError(`The deployment profile now names ${current.packageName}, not ${saved.plan.packageName}.`);
|
|
59
|
+
}
|
|
60
|
+
return applyPlayProductPlan({
|
|
61
|
+
saved: saved.plan,
|
|
62
|
+
current,
|
|
63
|
+
port: session.port,
|
|
64
|
+
approved: true,
|
|
65
|
+
...(options.confirmPriceChange === true ? { confirmPriceChange: true } : {}),
|
|
66
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (session.store === 'app-store' && current.store === 'app-store' && saved.plan.store === 'app-store') {
|
|
70
|
+
if (current.appId !== saved.plan.appId || current.bundleId !== saved.plan.bundleId) {
|
|
71
|
+
throw new StoreProductPlanStaleError(`The deployment profile now names app ${current.appId} (${current.bundleId}), not ${saved.plan.appId} (${saved.plan.bundleId}).`);
|
|
72
|
+
}
|
|
73
|
+
return applyAppStoreProductPlan({
|
|
74
|
+
saved: saved.plan,
|
|
75
|
+
current,
|
|
76
|
+
port: session.port,
|
|
77
|
+
approved: true,
|
|
78
|
+
...(options.confirmPriceChange === true ? { confirmPriceChange: true } : {}),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
throw new Error('Saved store product plan is malformed.');
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
async function withStoreSession(options, environment, overrides, action) {
|
|
85
|
+
if (options.store !== 'google-play' && options.store !== 'app-store') {
|
|
86
|
+
throw new Error('--store must be google-play or app-store.');
|
|
87
|
+
}
|
|
88
|
+
const ports = { ...defaultPorts, ...overrides };
|
|
89
|
+
const gameRoot = path.resolve(options.game);
|
|
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
|
+
}
|
|
100
|
+
const sources = readStoreProductSources({
|
|
101
|
+
gameRoot,
|
|
102
|
+
...(options.catalogFile === undefined ? {} : { catalogFile: options.catalogFile }),
|
|
103
|
+
...(options.listingsFile === undefined ? {} : { listingsFile: options.listingsFile }),
|
|
104
|
+
});
|
|
105
|
+
const credential = readStoreCredential(deployment, target, environment);
|
|
106
|
+
if (credential.target === 'android') {
|
|
107
|
+
const port = await ports.googlePlay(credential.serviceAccountFile);
|
|
108
|
+
return action({ store: 'google-play', packageName: planned.appId, port, sources });
|
|
109
|
+
}
|
|
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);
|
|
116
|
+
try {
|
|
117
|
+
return await action({
|
|
118
|
+
store: 'app-store',
|
|
119
|
+
appId: credential.appStoreAppId,
|
|
120
|
+
bundleId: planned.appId,
|
|
121
|
+
port: session.port,
|
|
122
|
+
sources,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
session.close();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async function planWithSession(session, options) {
|
|
130
|
+
const common = {
|
|
131
|
+
catalog: session.sources.catalog,
|
|
132
|
+
listings: session.sources.listings,
|
|
133
|
+
...(options.catalogTarget === undefined ? {} : { catalogTarget: options.catalogTarget }),
|
|
134
|
+
...(options.keepLivePrices === true ? { keepLivePrices: true } : {}),
|
|
135
|
+
};
|
|
136
|
+
if (session.store === 'google-play') {
|
|
137
|
+
return planPlayProducts({
|
|
138
|
+
packageName: session.packageName,
|
|
139
|
+
...common,
|
|
140
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
141
|
+
}, session.port);
|
|
142
|
+
}
|
|
143
|
+
return planAppStoreProducts({
|
|
144
|
+
appId: session.appId,
|
|
145
|
+
bundleId: session.bundleId,
|
|
146
|
+
listingsDirectory: path.dirname(session.sources.listingsFile),
|
|
147
|
+
...common,
|
|
148
|
+
}, session.port);
|
|
149
|
+
}
|
|
150
|
+
function readSavedPlan(file) {
|
|
151
|
+
const raw = readStoreProductPlanFile(file);
|
|
152
|
+
const plan = raw.plan;
|
|
153
|
+
const storeFields = typeof plan === 'object' && plan !== null && ((plan.store === 'google-play' && typeof plan.packageName === 'string')
|
|
154
|
+
|| (plan.store === 'app-store' && typeof plan.appId === 'string' && typeof plan.bundleId === 'string'));
|
|
155
|
+
if (raw.schemaVersion !== 1 || raw.kind !== 'mpgd-store-products-plan'
|
|
156
|
+
|| typeof raw.gameRoot !== 'string' || !path.isAbsolute(raw.gameRoot)
|
|
157
|
+
|| typeof raw.profile !== 'string' || typeof raw.catalogFile !== 'string'
|
|
158
|
+
|| typeof raw.listingsFile !== 'string' || typeof plan !== 'object' || plan === null
|
|
159
|
+
|| plan.schemaVersion !== 1 || !storeFields
|
|
160
|
+
|| typeof plan.catalogTarget !== 'string'
|
|
161
|
+
|| typeof plan.keepLivePrices !== 'boolean' || !Array.isArray(plan.products)
|
|
162
|
+
|| !plan.products.every(isSavedProduct)) {
|
|
163
|
+
throw new Error('Saved store product plan is malformed.');
|
|
164
|
+
}
|
|
165
|
+
return raw;
|
|
166
|
+
}
|
|
167
|
+
function isSavedProduct(value) {
|
|
168
|
+
if (typeof value !== 'object' || value === null) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
const product = value;
|
|
172
|
+
return typeof product.productId === 'string' && typeof product.action === 'string'
|
|
173
|
+
&& (product.remoteSha256 === null || typeof product.remoteSha256 === 'string')
|
|
174
|
+
&& typeof product.desiredSha256 === 'string';
|
|
175
|
+
}
|
|
176
|
+
async function loadPlayProductCatalog(serviceAccountFile) {
|
|
177
|
+
if (!existsSync(serviceAccountFile) || !statSync(serviceAccountFile).isFile()) {
|
|
178
|
+
throw new Error('Google Play service account file is missing.');
|
|
179
|
+
}
|
|
180
|
+
const { createServiceAccountProductCatalog } = await import(new URL('./play-sdk-adapter.js', import.meta.url).href);
|
|
181
|
+
return createServiceAccountProductCatalog(serviceAccountFile);
|
|
182
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { type ProductCatalog, type StoreListingCatalog } from '@mpgd/catalog';
|
|
2
|
+
import type { PlayOneTimeProduct, PlayProductCatalogPort } from './play-product-port.js';
|
|
3
|
+
import { type StoreProductAction } from './store-products.js';
|
|
4
|
+
/**
|
|
5
|
+
* Google Play one-time product sync: compare the game's catalog and store
|
|
6
|
+
* listing file with `monetization.onetimeproducts` and plan the writes.
|
|
7
|
+
*
|
|
8
|
+
* - Products: https://developers.google.com/android-publisher/api-ref/rest/v3/monetization.onetimeproducts
|
|
9
|
+
* - Create or update (`patch` with `allowMissing`): .../monetization.onetimeproducts/patch
|
|
10
|
+
* - Activation: .../monetization.onetimeproducts.purchaseOptions/batchUpdateStates
|
|
11
|
+
* - Price conversion: .../monetization/convertRegionPrices
|
|
12
|
+
*
|
|
13
|
+
* The plan never writes. Products only on Play are reported, never deleted;
|
|
14
|
+
* listing languages, purchase options and regions the file does not name
|
|
15
|
+
* are kept as they are.
|
|
16
|
+
*/
|
|
17
|
+
export type PlayProductChange = 'listings' | 'prices' | 'purchase-option' | 'multi-quantity' | 'tax-category' | 'activate';
|
|
18
|
+
export interface PlayPriceChange {
|
|
19
|
+
/** ISO region code, or `new-regions` for Play's future-regions USD/EUR prices. */
|
|
20
|
+
readonly region: string;
|
|
21
|
+
readonly from?: string;
|
|
22
|
+
readonly to?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface PlayProductWrite {
|
|
25
|
+
/** `onetimeproducts.patch`; absent when only an activation is planned. */
|
|
26
|
+
readonly patch?: {
|
|
27
|
+
readonly product: PlayOneTimeProduct;
|
|
28
|
+
readonly updateMask: string;
|
|
29
|
+
readonly allowMissing: boolean;
|
|
30
|
+
readonly regionsVersion: string;
|
|
31
|
+
};
|
|
32
|
+
/** Purchase option to activate after the patch (`DRAFT` or new). */
|
|
33
|
+
readonly activatePurchaseOptionId?: string;
|
|
34
|
+
}
|
|
35
|
+
export interface PlannedPlayProduct {
|
|
36
|
+
readonly logicalId: string;
|
|
37
|
+
readonly productId: string;
|
|
38
|
+
readonly action: StoreProductAction;
|
|
39
|
+
readonly changes: readonly PlayProductChange[];
|
|
40
|
+
readonly purchaseOptionId?: string;
|
|
41
|
+
/** The managed purchase option is `ACTIVE`: players can buy the product. */
|
|
42
|
+
readonly live: boolean;
|
|
43
|
+
/** A price of a live product changes; apply needs `--confirm-price-change`. */
|
|
44
|
+
readonly livePriceChange: boolean;
|
|
45
|
+
/** Each regional (or new-regions) price or availability that changes. */
|
|
46
|
+
readonly priceChanges: readonly PlayPriceChange[];
|
|
47
|
+
readonly notes: readonly string[];
|
|
48
|
+
/** Digest of the product as Play returned it; `null` when Play has none. */
|
|
49
|
+
readonly remoteSha256: string | null;
|
|
50
|
+
/** Digest of the planned write. */
|
|
51
|
+
readonly desiredSha256: string;
|
|
52
|
+
readonly write?: PlayProductWrite;
|
|
53
|
+
}
|
|
54
|
+
export interface PlayProductPlan {
|
|
55
|
+
readonly schemaVersion: 1;
|
|
56
|
+
readonly store: 'google-play';
|
|
57
|
+
readonly packageName: string;
|
|
58
|
+
readonly catalogTarget: string;
|
|
59
|
+
readonly keepLivePrices: boolean;
|
|
60
|
+
readonly products: readonly PlannedPlayProduct[];
|
|
61
|
+
/** Catalog products the sync leaves alone, with the reason. */
|
|
62
|
+
readonly skipped: readonly {
|
|
63
|
+
readonly logicalId: string;
|
|
64
|
+
readonly productId?: string;
|
|
65
|
+
readonly reason: string;
|
|
66
|
+
}[];
|
|
67
|
+
/** Product IDs on Play that no catalog product names. Never deleted. */
|
|
68
|
+
readonly remoteOnly: readonly string[];
|
|
69
|
+
}
|
|
70
|
+
export interface PlayProductPlanInput {
|
|
71
|
+
readonly packageName: string;
|
|
72
|
+
readonly catalog: ProductCatalog;
|
|
73
|
+
readonly listings: StoreListingCatalog;
|
|
74
|
+
/** Catalog `platformProductIds` key of Google Play; defaults to `android`. */
|
|
75
|
+
readonly catalogTarget?: string;
|
|
76
|
+
/** Leave the prices of live products as they are on Play. */
|
|
77
|
+
readonly keepLivePrices?: boolean;
|
|
78
|
+
readonly signal?: AbortSignal;
|
|
79
|
+
}
|
|
80
|
+
export declare function planPlayProducts(input: PlayProductPlanInput, port: PlayProductCatalogPort): Promise<PlayProductPlan>;
|
|
81
|
+
export interface PlayProductApplyResult {
|
|
82
|
+
readonly packageName: string;
|
|
83
|
+
readonly products: readonly {
|
|
84
|
+
readonly productId: string;
|
|
85
|
+
readonly status: 'created' | 'updated' | 'activated' | 'unchanged';
|
|
86
|
+
}[];
|
|
87
|
+
}
|
|
88
|
+
export declare class PlayProductApplyError extends Error {
|
|
89
|
+
readonly productId: string;
|
|
90
|
+
readonly stage: 'patch' | 'activate';
|
|
91
|
+
/** The products already written (created, updated or activated). */
|
|
92
|
+
readonly done: PlayProductApplyResult['products'];
|
|
93
|
+
constructor(productId: string, stage: 'patch' | 'activate',
|
|
94
|
+
/** The products already written (created, updated or activated). */
|
|
95
|
+
done: PlayProductApplyResult['products'], cause: unknown);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Apply a reviewed plan. `current` is a fresh plan from the same inputs:
|
|
99
|
+
* apply writes only when every product it would change still has the
|
|
100
|
+
* remote state and the planned write the reviewer saw. A product that is
|
|
101
|
+
* now unchanged (an earlier apply landed) is skipped, so a re-run is safe.
|
|
102
|
+
*/
|
|
103
|
+
export declare function applyPlayProductPlan(input: {
|
|
104
|
+
readonly saved: PlayProductPlan;
|
|
105
|
+
readonly current: PlayProductPlan;
|
|
106
|
+
readonly port: PlayProductCatalogPort;
|
|
107
|
+
readonly approved: boolean;
|
|
108
|
+
readonly confirmPriceChange?: boolean;
|
|
109
|
+
readonly signal?: AbortSignal;
|
|
110
|
+
}): Promise<PlayProductApplyResult>;
|
|
111
|
+
/** One line per product for the terminal. */
|
|
112
|
+
export declare function formatPlayProductPlan(plan: PlayProductPlan): string;
|