@mpgd/cli 0.63.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.
@@ -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 = 'google-play' | 'app-store';
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 { planNativeDeployment } from './deploy-planning.js';
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 writeStoreProductPlan(file, plan);
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
- if (options.store !== 'google-play' && options.store !== 'app-store') {
86
- throw new Error('--store must be google-play or app-store.');
87
- }
75
+ assertStoreSyncStore(options.store);
88
76
  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
- }
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 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 });
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: credential.appStoreAppId,
120
- bundleId: planned.appId,
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 = readStoreProductPlanFile(file);
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'));
@@ -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
- /** JSON with object keys sorted, so equal values always hash the same. */
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[]>;
@@ -1,7 +1,8 @@
1
- import { createHash } from 'node:crypto';
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(readJson(catalogFile));
26
- const listings = assertStoreListingCatalog(readJson(listingsFile), catalog);
26
+ const catalog = assertProductCatalog(readJsonFile(catalogFile));
27
+ const listings = assertStoreListingCatalog(readJsonFile(listingsFile), catalog);
27
28
  return { catalogFile, listingsFile, catalog, listings };
28
29
  }
29
- /** JSON with object keys sorted, so equal values always hash the same. */
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(`${detail} Run "mpgd store products plan" again and review the new plan.`);
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/cli",
3
- "version": "0.63.0",
3
+ "version": "0.64.0",
4
4
  "description": "Gunshi CLI for mpgd-kit starter generation and target workflow orchestration.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -70,9 +70,9 @@
70
70
  "parse5": "7.3.0",
71
71
  "postcss": "8.5.28",
72
72
  "postcss-value-parser": "4.2.0",
73
- "@mpgd/catalog": "0.12.0",
74
- "@mpgd/target-config": "0.24.2",
73
+ "@mpgd/catalog": "0.13.0",
75
74
  "@mpgd/platform": "0.32.0",
75
+ "@mpgd/target-config": "0.24.3",
76
76
  "@mpgd/phaser-assets": "0.9.1"
77
77
  },
78
78
  "devDependencies": {
@@ -81,17 +81,17 @@
81
81
  "@types/yauzl": "3.4.0",
82
82
  "ttsc": "0.32.1",
83
83
  "typescript": "7.0.2",
84
- "@mpgd/adapter-ait": "0.21.4",
84
+ "@mpgd/adapter-ait": "0.21.5",
85
85
  "@mpgd/adapter-browser": "0.12.1",
86
- "@mpgd/adapter-capacitor": "0.16.2",
87
- "@mpgd/adapter-devvit": "0.14.6",
88
- "@mpgd/analytics": "0.5.5",
89
- "@mpgd/adapter-verse8": "0.7.15",
90
- "@mpgd/capacitor-play-billing": "0.4.4",
86
+ "@mpgd/adapter-capacitor": "0.16.3",
87
+ "@mpgd/adapter-devvit": "0.14.7",
88
+ "@mpgd/adapter-verse8": "0.7.16",
91
89
  "@mpgd/bridge": "0.16.4",
92
- "@mpgd/capacitor-storekit": "0.4.4",
93
- "@mpgd/game-runtime": "0.8.8",
94
- "@mpgd/game-services": "0.30.4",
90
+ "@mpgd/analytics": "0.5.5",
91
+ "@mpgd/game-runtime": "0.8.9",
92
+ "@mpgd/capacitor-play-billing": "0.4.5",
93
+ "@mpgd/capacitor-storekit": "0.4.5",
94
+ "@mpgd/game-services": "0.30.5",
95
95
  "@mpgd/i18n": "0.6.26",
96
96
  "@mpgd/lite-shell": "0.1.0"
97
97
  },