storemeta 0.1.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.
Files changed (92) hide show
  1. package/.env.release.example +8 -0
  2. package/LICENSE +21 -0
  3. package/README.md +94 -0
  4. package/dist/auth/apple/credential-state.js +14 -0
  5. package/dist/auth/apple/jwt.js +56 -0
  6. package/dist/auth/apple/load-credentials.js +36 -0
  7. package/dist/auth/credential-state.js +6 -0
  8. package/dist/auth/google/credential-state.js +10 -0
  9. package/dist/auth/google/load-credentials.js +18 -0
  10. package/dist/auth/google/service-account-auth.js +25 -0
  11. package/dist/auth/redact.js +17 -0
  12. package/dist/cli/auth-check.js +46 -0
  13. package/dist/cli/config-doctor.js +40 -0
  14. package/dist/cli/context.js +23 -0
  15. package/dist/cli/errors.js +10 -0
  16. package/dist/cli/exit-code.js +6 -0
  17. package/dist/cli/init.js +60 -0
  18. package/dist/cli/locales-list.js +37 -0
  19. package/dist/cli/metadata-diff.js +57 -0
  20. package/dist/cli/metadata-pull.js +52 -0
  21. package/dist/cli/metadata-push.js +95 -0
  22. package/dist/cli/render.js +57 -0
  23. package/dist/cli/result-types.js +1 -0
  24. package/dist/cli/scaffold.js +76 -0
  25. package/dist/cli/screenshots-diff.js +65 -0
  26. package/dist/cli/screenshots-pull.js +72 -0
  27. package/dist/cli/screenshots-push.js +96 -0
  28. package/dist/cli/validate.js +74 -0
  29. package/dist/cli.js +222 -0
  30. package/dist/config/apps.js +21 -0
  31. package/dist/config/load-config.js +30 -0
  32. package/dist/config/schema.js +97 -0
  33. package/dist/config/select-app.js +10 -0
  34. package/dist/config/select-platforms.js +17 -0
  35. package/dist/config/single-app.js +16 -0
  36. package/dist/config/types.js +1 -0
  37. package/dist/config/validate-base-dirs.js +33 -0
  38. package/dist/config/validate-credential-env-names.js +32 -0
  39. package/dist/config/validate-platform-identifiers.js +24 -0
  40. package/dist/formats/load-metadata.js +38 -0
  41. package/dist/formats/metadata-types.js +1 -0
  42. package/dist/formats/screenshot-types.js +1 -0
  43. package/dist/formats/serialize-metadata.js +20 -0
  44. package/dist/locales/groups.js +12 -0
  45. package/dist/locales/map.js +15 -0
  46. package/dist/locales/normalize.js +22 -0
  47. package/dist/locales/types.js +1 -0
  48. package/dist/locales/validate-groups.js +14 -0
  49. package/dist/platforms/apple/client.js +109 -0
  50. package/dist/platforms/apple/metadata/pull.js +120 -0
  51. package/dist/platforms/apple/metadata/push.js +272 -0
  52. package/dist/platforms/apple/metadata/types.js +1 -0
  53. package/dist/platforms/apple/screenshots/pull.js +160 -0
  54. package/dist/platforms/apple/screenshots/push.js +418 -0
  55. package/dist/platforms/google/client.js +42 -0
  56. package/dist/platforms/google/edits.js +38 -0
  57. package/dist/platforms/google/metadata/pull.js +23 -0
  58. package/dist/platforms/google/metadata/push.js +73 -0
  59. package/dist/platforms/google/metadata/types.js +1 -0
  60. package/dist/platforms/google/screenshots/pull.js +129 -0
  61. package/dist/platforms/google/screenshots/push.js +252 -0
  62. package/dist/platforms/google/screenshots/types.js +7 -0
  63. package/dist/validation/metadata/apple.js +31 -0
  64. package/dist/validation/metadata/files.js +58 -0
  65. package/dist/validation/metadata/google.js +46 -0
  66. package/dist/validation/screenshots/extensions.js +17 -0
  67. package/dist/validation/screenshots/files.js +55 -0
  68. package/dist/validation/screenshots/folders.js +41 -0
  69. package/dist/validation/screenshots/order.js +25 -0
  70. package/dist/writers/ensure-directory.js +16 -0
  71. package/dist/writers/order-screenshots.js +19 -0
  72. package/dist/writers/resolve-screenshot-path.js +8 -0
  73. package/dist/writers/resolve-within-base-dir.js +17 -0
  74. package/dist/writers/write-metadata.js +17 -0
  75. package/docs/API_EDITABLE_METADATA_SURFACE.md +319 -0
  76. package/docs/AUTH_SETUP.md +67 -0
  77. package/docs/DOCUMENTATION.md +285 -0
  78. package/docs/PROJECT_PLAN.md +687 -0
  79. package/docs/RELEASE_VERIFICATION.md +70 -0
  80. package/docs/TODO.md +308 -0
  81. package/examples/README.md +8 -0
  82. package/examples/metadata/apple/en-US.yml +11 -0
  83. package/examples/metadata/apple/tr.yml +11 -0
  84. package/examples/metadata/google/en-US.yml +6 -0
  85. package/examples/metadata/google/tr.yml +6 -0
  86. package/examples/screenshots/apple/en-US/APP_IPHONE_65/1.png +1 -0
  87. package/examples/screenshots/apple/tr/APP_IPHONE_65/1.png +1 -0
  88. package/examples/screenshots/google/en-US/phoneScreenshots/1.png +1 -0
  89. package/examples/screenshots/google/tr/phoneScreenshots/1.png +1 -0
  90. package/examples/storemeta.yml +29 -0
  91. package/package.json +55 -0
  92. package/storemeta.release.example.yml +27 -0
@@ -0,0 +1,418 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, readdir, stat } from "node:fs/promises";
3
+ import { extname, join, resolve } from "node:path";
4
+ import { StoremetaError } from "../../../cli/errors.js";
5
+ import { normalizeLocaleCode } from "../../../locales/normalize.js";
6
+ import { patchAppStoreConnectJson, postAppStoreConnectJson, requestAllAppStoreConnectPages, } from "../client.js";
7
+ import { resolveEditableAppleAppStoreVersionResource } from "../metadata/push.js";
8
+ import { fetchAppleScreenshotsForSets, fetchAppleScreenshotSetsForLocalizations, } from "./pull.js";
9
+ const SUPPORTED_SCREENSHOT_EXTENSIONS = new Set([".png", ".jpg", ".jpeg"]);
10
+ async function listDirectoryEntries(directoryPath) {
11
+ try {
12
+ return await readdir(directoryPath, { withFileTypes: true });
13
+ }
14
+ catch (cause) {
15
+ if (typeof cause === "object" &&
16
+ cause !== null &&
17
+ "code" in cause &&
18
+ cause.code === "ENOENT") {
19
+ return [];
20
+ }
21
+ throw new StoremetaError("FILESYSTEM_ERROR", `Failed to read Apple screenshot directory at ${directoryPath}`, { cause });
22
+ }
23
+ }
24
+ function sortScreenshotFileNames(fileNames) {
25
+ return [...fileNames].sort((left, right) => left.localeCompare(right, undefined, {
26
+ numeric: true,
27
+ }));
28
+ }
29
+ function validateAppleScreenshotFileNames(assetTypeDirectory, fileNames) {
30
+ for (const [index, fileName] of sortScreenshotFileNames(fileNames).entries()) {
31
+ const extension = extname(fileName).toLowerCase();
32
+ if (!SUPPORTED_SCREENSHOT_EXTENSIONS.has(extension)) {
33
+ throw new StoremetaError("VALIDATION_ERROR", `${join(assetTypeDirectory, fileName)}: unsupported screenshot file extension`);
34
+ }
35
+ const numericName = fileName.slice(0, Math.max(0, fileName.length - extension.length));
36
+ if (!/^\d+$/u.test(numericName)) {
37
+ throw new StoremetaError("VALIDATION_ERROR", `${join(assetTypeDirectory, fileName)}: expected a numeric filename`);
38
+ }
39
+ if (Number(numericName) !== index + 1) {
40
+ throw new StoremetaError("VALIDATION_ERROR", `${join(assetTypeDirectory, fileName)}: expected screenshot order ${index + 1}`);
41
+ }
42
+ }
43
+ }
44
+ function createAppleScreenshotDescriptors(screenshotsBaseDir, localeDirectoryName, locale, assetType, fileNames) {
45
+ return sortScreenshotFileNames(fileNames).map((fileName, index) => ({
46
+ platform: "apple",
47
+ locale,
48
+ assetType,
49
+ filePath: resolve(screenshotsBaseDir, "apple", localeDirectoryName, assetType, fileName),
50
+ fileName,
51
+ position: index + 1,
52
+ }));
53
+ }
54
+ export async function loadAppleScreenshotSets(screenshotsBaseDir) {
55
+ const appleBaseDir = resolve(screenshotsBaseDir, "apple");
56
+ const localeEntries = await listDirectoryEntries(appleBaseDir);
57
+ const screenshotSets = [];
58
+ for (const localeEntry of localeEntries) {
59
+ if (!localeEntry.isDirectory()) {
60
+ throw new StoremetaError("VALIDATION_ERROR", `${join(appleBaseDir, localeEntry.name)}: expected a locale directory`);
61
+ }
62
+ const locale = normalizeLocaleCode(localeEntry.name);
63
+ const localeDirectory = join(appleBaseDir, localeEntry.name);
64
+ const assetTypeEntries = await listDirectoryEntries(localeDirectory);
65
+ for (const assetTypeEntry of assetTypeEntries) {
66
+ if (!assetTypeEntry.isDirectory()) {
67
+ throw new StoremetaError("VALIDATION_ERROR", `${join(localeDirectory, assetTypeEntry.name)}: expected a display type directory`);
68
+ }
69
+ const assetTypeDirectory = join(localeDirectory, assetTypeEntry.name);
70
+ const fileEntries = await listDirectoryEntries(assetTypeDirectory);
71
+ const fileNames = fileEntries
72
+ .filter((entry) => entry.isFile())
73
+ .map((entry) => entry.name);
74
+ validateAppleScreenshotFileNames(assetTypeDirectory, fileNames);
75
+ screenshotSets.push({
76
+ platform: "apple",
77
+ locale,
78
+ assetType: assetTypeEntry.name,
79
+ files: createAppleScreenshotDescriptors(screenshotsBaseDir, localeEntry.name, locale, assetTypeEntry.name, fileNames),
80
+ });
81
+ }
82
+ }
83
+ return screenshotSets.sort((left, right) => {
84
+ const localeOrder = left.locale.localeCompare(right.locale);
85
+ if (localeOrder !== 0) {
86
+ return localeOrder;
87
+ }
88
+ return left.assetType.localeCompare(right.assetType);
89
+ });
90
+ }
91
+ function mapAppleScreenshotLocaleToCreatePayload(locale, appStoreVersionId) {
92
+ return {
93
+ data: {
94
+ type: "appStoreVersionLocalizations",
95
+ attributes: {
96
+ locale,
97
+ },
98
+ relationships: {
99
+ appStoreVersion: {
100
+ data: {
101
+ type: "appStoreVersions",
102
+ id: appStoreVersionId,
103
+ },
104
+ },
105
+ },
106
+ },
107
+ };
108
+ }
109
+ async function listAppleAppStoreVersionLocalizations(client, appStoreVersionId) {
110
+ return requestAllAppStoreConnectPages(client, `/appStoreVersions/${encodeURIComponent(appStoreVersionId)}/appStoreVersionLocalizations`);
111
+ }
112
+ export async function resolveOrCreateAppleScreenshotLocalizations(client, appId, screenshotSets) {
113
+ const appStoreVersion = await resolveEditableAppleAppStoreVersionResource(client, appId);
114
+ const existingLocalizations = await listAppleAppStoreVersionLocalizations(client, appStoreVersion.id);
115
+ const localizationsByLocale = new Map(existingLocalizations
116
+ .filter((localization) => localization.attributes.locale !== undefined)
117
+ .map((localization) => [localization.attributes.locale, localization]));
118
+ const requiredLocales = [...new Set(screenshotSets.map((set) => set.locale))].sort((left, right) => left.localeCompare(right));
119
+ for (const locale of requiredLocales) {
120
+ if (localizationsByLocale.has(locale)) {
121
+ continue;
122
+ }
123
+ const response = await postAppStoreConnectJson(client, "/appStoreVersionLocalizations", mapAppleScreenshotLocaleToCreatePayload(locale, appStoreVersion.id));
124
+ if (response.data.id === undefined) {
125
+ throw new StoremetaError("API_ERROR", `App Store Connect did not return an app store version localization id for locale ${locale}`);
126
+ }
127
+ localizationsByLocale.set(locale, response.data);
128
+ }
129
+ return requiredLocales.map((locale) => {
130
+ const localization = localizationsByLocale.get(locale);
131
+ if (localization === undefined) {
132
+ throw new StoremetaError("API_ERROR", `App Store Connect did not return an app store version localization for locale ${locale}`);
133
+ }
134
+ return localization;
135
+ });
136
+ }
137
+ function mapAppleScreenshotSetToCreatePayload(localizationId, screenshotDisplayType) {
138
+ return {
139
+ data: {
140
+ type: "appScreenshotSets",
141
+ attributes: {
142
+ screenshotDisplayType,
143
+ },
144
+ relationships: {
145
+ appStoreVersionLocalization: {
146
+ data: {
147
+ type: "appStoreVersionLocalizations",
148
+ id: localizationId,
149
+ },
150
+ },
151
+ },
152
+ },
153
+ };
154
+ }
155
+ export async function resolveOrCreateAppleScreenshotUploadTargets(client, localizations, screenshotSets) {
156
+ const localizationsByLocale = new Map(localizations
157
+ .filter((localization) => localization.id !== undefined &&
158
+ localization.attributes.locale !== undefined)
159
+ .map((localization) => [localization.attributes.locale, localization]));
160
+ const existingScreenshotSets = await fetchAppleScreenshotSetsForLocalizations(client, localizations);
161
+ const screenshotSetsByTargetKey = new Map();
162
+ for (const localization of existingScreenshotSets) {
163
+ for (const screenshotSet of localization.screenshotSets) {
164
+ const displayType = screenshotSet.attributes?.screenshotDisplayType;
165
+ if (displayType === undefined) {
166
+ continue;
167
+ }
168
+ screenshotSetsByTargetKey.set(`${localization.localizationId}:${displayType}`, screenshotSet);
169
+ }
170
+ }
171
+ const uploadTargets = [];
172
+ for (const screenshotSet of screenshotSets) {
173
+ const localization = localizationsByLocale.get(screenshotSet.locale);
174
+ if (localization === undefined) {
175
+ throw new StoremetaError("API_ERROR", `App Store Connect did not return an app store version localization for locale ${screenshotSet.locale}`);
176
+ }
177
+ const targetKey = `${localization.id}:${screenshotSet.assetType}`;
178
+ let targetScreenshotSet = screenshotSetsByTargetKey.get(targetKey);
179
+ if (targetScreenshotSet === undefined) {
180
+ const response = await postAppStoreConnectJson(client, "/appScreenshotSets", mapAppleScreenshotSetToCreatePayload(localization.id, screenshotSet.assetType));
181
+ if (response.data.id === undefined) {
182
+ throw new StoremetaError("API_ERROR", `App Store Connect did not return an app screenshot set id for locale ${screenshotSet.locale} and display type ${screenshotSet.assetType}`);
183
+ }
184
+ targetScreenshotSet = response.data;
185
+ screenshotSetsByTargetKey.set(targetKey, targetScreenshotSet);
186
+ }
187
+ uploadTargets.push({
188
+ ...screenshotSet,
189
+ localizationId: localization.id,
190
+ screenshotSetId: targetScreenshotSet.id,
191
+ });
192
+ }
193
+ return uploadTargets.sort((left, right) => {
194
+ const localeOrder = left.locale.localeCompare(right.locale);
195
+ if (localeOrder !== 0) {
196
+ return localeOrder;
197
+ }
198
+ return left.assetType.localeCompare(right.assetType);
199
+ });
200
+ }
201
+ function mapUploadTargetsToAppleLocalizationScreenshotSets(uploadTargets) {
202
+ const targetsByLocalization = new Map();
203
+ for (const uploadTarget of uploadTargets) {
204
+ const existingLocalization = targetsByLocalization.get(uploadTarget.localizationId);
205
+ if (existingLocalization !== undefined) {
206
+ existingLocalization.screenshotSets.push({
207
+ id: uploadTarget.screenshotSetId,
208
+ type: "appScreenshotSets",
209
+ attributes: {
210
+ screenshotDisplayType: uploadTarget.assetType,
211
+ },
212
+ });
213
+ continue;
214
+ }
215
+ targetsByLocalization.set(uploadTarget.localizationId, {
216
+ localizationId: uploadTarget.localizationId,
217
+ locale: uploadTarget.locale,
218
+ screenshotSets: [
219
+ {
220
+ id: uploadTarget.screenshotSetId,
221
+ type: "appScreenshotSets",
222
+ attributes: {
223
+ screenshotDisplayType: uploadTarget.assetType,
224
+ },
225
+ },
226
+ ],
227
+ });
228
+ }
229
+ return [...targetsByLocalization.values()].sort((left, right) => (left.locale ?? left.localizationId).localeCompare(right.locale ?? right.localizationId));
230
+ }
231
+ export async function clearAppleScreenshotUploadTargets(client, uploadTargets, options) {
232
+ if (options?.clearExisting !== true) {
233
+ return [];
234
+ }
235
+ const screenshotsBySet = await fetchAppleScreenshotsForSets(client, mapUploadTargetsToAppleLocalizationScreenshotSets(uploadTargets));
236
+ const deletedResults = [];
237
+ for (const screenshotSet of screenshotsBySet) {
238
+ const deletedScreenshotIds = [];
239
+ for (const screenshot of screenshotSet.screenshots) {
240
+ await client.request(`/appScreenshots/${encodeURIComponent(screenshot.id)}`, {
241
+ method: "DELETE",
242
+ });
243
+ deletedScreenshotIds.push(screenshot.id);
244
+ }
245
+ deletedResults.push({
246
+ locale: screenshotSet.locale ?? screenshotSet.localizationId,
247
+ assetType: screenshotSet.screenshotSet.attributes?.screenshotDisplayType ??
248
+ screenshotSet.screenshotSet.id,
249
+ screenshotSetId: screenshotSet.screenshotSet.id,
250
+ deletedScreenshotIds,
251
+ });
252
+ }
253
+ return deletedResults.sort((left, right) => {
254
+ const localeOrder = left.locale.localeCompare(right.locale);
255
+ if (localeOrder !== 0) {
256
+ return localeOrder;
257
+ }
258
+ return left.assetType.localeCompare(right.assetType);
259
+ });
260
+ }
261
+ function mapAppleScreenshotReservationPayload(screenshotSetId, fileName, fileSize) {
262
+ return {
263
+ data: {
264
+ type: "appScreenshots",
265
+ attributes: {
266
+ fileName,
267
+ fileSize,
268
+ },
269
+ relationships: {
270
+ appScreenshotSet: {
271
+ data: {
272
+ type: "appScreenshotSets",
273
+ id: screenshotSetId,
274
+ },
275
+ },
276
+ },
277
+ },
278
+ };
279
+ }
280
+ export async function reserveAppleScreenshotUploads(client, uploadTargets) {
281
+ const reservations = [];
282
+ for (const uploadTarget of uploadTargets) {
283
+ for (const file of uploadTarget.files) {
284
+ const fileStats = await stat(file.filePath);
285
+ const response = await postAppStoreConnectJson(client, "/appScreenshots", mapAppleScreenshotReservationPayload(uploadTarget.screenshotSetId, file.fileName, fileStats.size));
286
+ const screenshotId = response.data.id;
287
+ const uploadOperations = response.data.attributes?.uploadOperations;
288
+ if (screenshotId === undefined) {
289
+ throw new StoremetaError("API_ERROR", `App Store Connect did not return an app screenshot id for ${uploadTarget.locale}/${uploadTarget.assetType}/${file.fileName}`);
290
+ }
291
+ if (uploadOperations === undefined || uploadOperations.length === 0) {
292
+ throw new StoremetaError("API_ERROR", `App Store Connect did not return upload operations for ${uploadTarget.locale}/${uploadTarget.assetType}/${file.fileName}`);
293
+ }
294
+ reservations.push({
295
+ locale: uploadTarget.locale,
296
+ assetType: uploadTarget.assetType,
297
+ screenshotSetId: uploadTarget.screenshotSetId,
298
+ screenshotId,
299
+ file,
300
+ uploadOperations,
301
+ });
302
+ }
303
+ }
304
+ return reservations.sort((left, right) => {
305
+ const localeOrder = left.locale.localeCompare(right.locale);
306
+ if (localeOrder !== 0) {
307
+ return localeOrder;
308
+ }
309
+ const assetTypeOrder = left.assetType.localeCompare(right.assetType);
310
+ if (assetTypeOrder !== 0) {
311
+ return assetTypeOrder;
312
+ }
313
+ return left.file.position - right.file.position;
314
+ });
315
+ }
316
+ function resolveAppleUploadOperationHeaders(uploadOperation) {
317
+ const headers = Object.fromEntries((uploadOperation.requestHeaders ?? [])
318
+ .filter((header) => header.name !== undefined && header.value !== undefined)
319
+ .map((header) => [header.name, header.value]));
320
+ if (Object.keys(headers).length === 0) {
321
+ throw new StoremetaError("API_ERROR", "Apple upload operation did not return request headers");
322
+ }
323
+ return headers;
324
+ }
325
+ function sliceAppleUploadChunk(fileBuffer, uploadOperation) {
326
+ if (uploadOperation.offset === undefined || uploadOperation.length === undefined) {
327
+ throw new StoremetaError("API_ERROR", "Apple upload operation did not return offset and length information");
328
+ }
329
+ return fileBuffer.subarray(uploadOperation.offset, uploadOperation.offset + uploadOperation.length);
330
+ }
331
+ function convertAppleUploadChunkToArrayBuffer(chunk) {
332
+ return chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength);
333
+ }
334
+ export async function uploadReservedAppleScreenshot(reservation) {
335
+ const fileBuffer = await readFile(reservation.file.filePath);
336
+ for (const uploadOperation of reservation.uploadOperations) {
337
+ if (uploadOperation.method === undefined ||
338
+ uploadOperation.url === undefined ||
339
+ uploadOperation.url.trim().length === 0) {
340
+ throw new StoremetaError("API_ERROR", `Apple upload operation is missing method or URL for ${reservation.locale}/${reservation.assetType}/${reservation.file.fileName}`);
341
+ }
342
+ const response = await fetch(uploadOperation.url, {
343
+ method: uploadOperation.method,
344
+ headers: resolveAppleUploadOperationHeaders(uploadOperation),
345
+ body: convertAppleUploadChunkToArrayBuffer(sliceAppleUploadChunk(fileBuffer, uploadOperation)),
346
+ });
347
+ if (!response.ok) {
348
+ throw new StoremetaError("API_ERROR", `Apple screenshot upload failed for ${reservation.locale}/${reservation.assetType}/${reservation.file.fileName} with ${response.status} ${response.statusText}`);
349
+ }
350
+ }
351
+ return {
352
+ locale: reservation.locale,
353
+ assetType: reservation.assetType,
354
+ screenshotId: reservation.screenshotId,
355
+ filePath: reservation.file.filePath,
356
+ };
357
+ }
358
+ export async function uploadReservedAppleScreenshots(reservations) {
359
+ const uploadResults = [];
360
+ for (const reservation of reservations) {
361
+ uploadResults.push(await uploadReservedAppleScreenshot(reservation));
362
+ }
363
+ return uploadResults.sort((left, right) => {
364
+ const localeOrder = left.locale.localeCompare(right.locale);
365
+ if (localeOrder !== 0) {
366
+ return localeOrder;
367
+ }
368
+ const assetTypeOrder = left.assetType.localeCompare(right.assetType);
369
+ if (assetTypeOrder !== 0) {
370
+ return assetTypeOrder;
371
+ }
372
+ return left.filePath.localeCompare(right.filePath);
373
+ });
374
+ }
375
+ function createAppleScreenshotChecksum(fileContents) {
376
+ return createHash("md5").update(fileContents).digest("hex");
377
+ }
378
+ function mapAppleScreenshotCommitPayload(screenshotId, sourceFileChecksum) {
379
+ return {
380
+ data: {
381
+ id: screenshotId,
382
+ type: "appScreenshots",
383
+ attributes: {
384
+ uploaded: true,
385
+ sourceFileChecksum,
386
+ },
387
+ },
388
+ };
389
+ }
390
+ export async function commitAppleScreenshotUpload(client, reservation) {
391
+ const fileContents = await readFile(reservation.file.filePath);
392
+ const sourceFileChecksum = createAppleScreenshotChecksum(fileContents);
393
+ await patchAppStoreConnectJson(client, `/appScreenshots/${encodeURIComponent(reservation.screenshotId)}`, mapAppleScreenshotCommitPayload(reservation.screenshotId, sourceFileChecksum));
394
+ return {
395
+ locale: reservation.locale,
396
+ assetType: reservation.assetType,
397
+ screenshotId: reservation.screenshotId,
398
+ filePath: reservation.file.filePath,
399
+ sourceFileChecksum,
400
+ };
401
+ }
402
+ export async function commitAppleScreenshotUploads(client, reservations) {
403
+ const commitResults = [];
404
+ for (const reservation of reservations) {
405
+ commitResults.push(await commitAppleScreenshotUpload(client, reservation));
406
+ }
407
+ return commitResults.sort((left, right) => {
408
+ const localeOrder = left.locale.localeCompare(right.locale);
409
+ if (localeOrder !== 0) {
410
+ return localeOrder;
411
+ }
412
+ const assetTypeOrder = left.assetType.localeCompare(right.assetType);
413
+ if (assetTypeOrder !== 0) {
414
+ return assetTypeOrder;
415
+ }
416
+ return left.filePath.localeCompare(right.filePath);
417
+ });
418
+ }
@@ -0,0 +1,42 @@
1
+ import { StoremetaError } from "../../cli/errors.js";
2
+ import { getGoogleAccessToken } from "../../auth/google/service-account-auth.js";
3
+ const GOOGLE_PLAY_API_BASE_URL = "https://androidpublisher.googleapis.com/androidpublisher/v3";
4
+ export class GooglePlayClient {
5
+ credentials;
6
+ env;
7
+ constructor(credentials, env = process.env) {
8
+ this.credentials = credentials;
9
+ this.env = env;
10
+ }
11
+ async request(path, options = {}) {
12
+ const accessToken = await getGoogleAccessToken(this.credentials, this.env);
13
+ const requestUrl = path.startsWith("https://") || path.startsWith("http://")
14
+ ? path
15
+ : `${GOOGLE_PLAY_API_BASE_URL}${path}`;
16
+ const response = await fetch(requestUrl, {
17
+ method: options.method ?? "GET",
18
+ headers: {
19
+ Authorization: `Bearer ${accessToken}`,
20
+ ...options.headers,
21
+ },
22
+ body: options.body,
23
+ });
24
+ if (!response.ok) {
25
+ throw new StoremetaError("API_ERROR", `Google Play API request failed with ${response.status} ${response.statusText}`);
26
+ }
27
+ return response;
28
+ }
29
+ async requestJson(path, options = {}) {
30
+ const response = await this.request(path, {
31
+ ...options,
32
+ headers: {
33
+ Accept: "application/json",
34
+ ...options.headers,
35
+ },
36
+ });
37
+ return (await response.json());
38
+ }
39
+ }
40
+ export function createGooglePlayClient(credentials, env = process.env) {
41
+ return new GooglePlayClient(credentials, env);
42
+ }
@@ -0,0 +1,38 @@
1
+ import { StoremetaError } from "../../cli/errors.js";
2
+ export async function createGoogleEdit(client, packageName) {
3
+ return client.requestJson(`/applications/${encodeURIComponent(packageName)}/edits`, {
4
+ method: "POST",
5
+ headers: {
6
+ "Content-Type": "application/json",
7
+ },
8
+ body: JSON.stringify({}),
9
+ });
10
+ }
11
+ export async function commitGoogleEdit(client, packageName, editId, options) {
12
+ const query = new URLSearchParams();
13
+ if (options?.changesNotSentForReview !== undefined) {
14
+ query.set("changesNotSentForReview", String(options.changesNotSentForReview));
15
+ }
16
+ const querySuffix = query.size === 0 ? "" : `?${query.toString()}`;
17
+ return client.requestJson(`/applications/${encodeURIComponent(packageName)}/edits/${encodeURIComponent(editId)}:commit${querySuffix}`, {
18
+ method: "POST",
19
+ });
20
+ }
21
+ export async function withGoogleEditSession(client, packageName, run, options) {
22
+ const edit = await createGoogleEdit(client, packageName);
23
+ try {
24
+ const result = await run(edit);
25
+ if (options?.autoCommit !== false) {
26
+ await commitGoogleEdit(client, packageName, edit.id, {
27
+ changesNotSentForReview: options?.changesNotSentForReview,
28
+ });
29
+ }
30
+ return {
31
+ edit,
32
+ result,
33
+ };
34
+ }
35
+ catch (cause) {
36
+ throw new StoremetaError("API_ERROR", `Google Play edit session ${edit.id} failed before commit`, { cause });
37
+ }
38
+ }
@@ -0,0 +1,23 @@
1
+ import { normalizeLocaleCode } from "../../../locales/normalize.js";
2
+ import { writeMetadataFile } from "../../../writers/write-metadata.js";
3
+ export async function fetchGoogleListingForLocale(client, packageName, editId, locale) {
4
+ return client.requestJson(`/applications/${encodeURIComponent(packageName)}/edits/${encodeURIComponent(editId)}/listings/${encodeURIComponent(locale)}`);
5
+ }
6
+ export async function fetchGoogleListingsForLocales(client, packageName, editId, locales) {
7
+ return Promise.all(locales.map((locale) => fetchGoogleListingForLocale(client, packageName, editId, locale)));
8
+ }
9
+ export function normalizeGoogleListing(listing) {
10
+ return {
11
+ locale: normalizeLocaleCode(listing.language),
12
+ title: listing.title,
13
+ short_description: listing.shortDescription,
14
+ full_description: listing.fullDescription,
15
+ video: listing.video,
16
+ };
17
+ }
18
+ export async function writeGoogleListingDocument(metadataBaseDir, document) {
19
+ return writeMetadataFile(metadataBaseDir, `google/${normalizeLocaleCode(document.locale)}.yml`, document);
20
+ }
21
+ export async function writeGoogleListingDocuments(metadataBaseDir, documents) {
22
+ return Promise.all(documents.map((document) => writeGoogleListingDocument(metadataBaseDir, document)));
23
+ }
@@ -0,0 +1,73 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { extname, join, resolve } from "node:path";
3
+ import { StoremetaError } from "../../../cli/errors.js";
4
+ import { loadMarkdownMetadataFile, loadYamlMetadataFile, loadYmlMetadataFile, } from "../../../formats/load-metadata.js";
5
+ import { validateGoogleMetadataDocument, validateGoogleMetadataLengthConstraints, } from "../../../validation/metadata/google.js";
6
+ async function loadGoogleMetadataFile(filePath) {
7
+ const extension = extname(filePath).toLowerCase();
8
+ if (extension === ".yml") {
9
+ return validateGoogleMetadataDocument((await loadYmlMetadataFile(filePath)).parsed);
10
+ }
11
+ if (extension === ".yaml") {
12
+ return validateGoogleMetadataDocument((await loadYamlMetadataFile(filePath)).parsed);
13
+ }
14
+ if (extension === ".md") {
15
+ return validateGoogleMetadataDocument((await loadMarkdownMetadataFile(filePath)).parsed);
16
+ }
17
+ return undefined;
18
+ }
19
+ export async function loadGoogleMetadataDocuments(metadataBaseDir) {
20
+ const googleMetadataDir = resolve(metadataBaseDir, "google");
21
+ let entries;
22
+ try {
23
+ entries = await readdir(googleMetadataDir, { withFileTypes: true });
24
+ }
25
+ catch (cause) {
26
+ throw new StoremetaError("FILESYSTEM_ERROR", `Failed to read local Google metadata directory at ${googleMetadataDir}`, { cause });
27
+ }
28
+ const documents = [];
29
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
30
+ if (!entry.isFile()) {
31
+ continue;
32
+ }
33
+ const document = await loadGoogleMetadataFile(join(googleMetadataDir, entry.name));
34
+ if (document === undefined) {
35
+ continue;
36
+ }
37
+ validateGoogleMetadataLengthConstraints(document);
38
+ documents.push(document);
39
+ }
40
+ return documents;
41
+ }
42
+ export function mapGoogleMetadataDocument(document) {
43
+ return {
44
+ language: document.locale,
45
+ body: {
46
+ title: document.title,
47
+ shortDescription: document.short_description,
48
+ fullDescription: document.full_description,
49
+ video: document.video,
50
+ },
51
+ };
52
+ }
53
+ export function mapGoogleMetadataDocuments(documents) {
54
+ return documents.map(mapGoogleMetadataDocument);
55
+ }
56
+ export async function uploadGoogleListing(client, packageName, editId, update) {
57
+ await client.request(`/applications/${encodeURIComponent(packageName)}/edits/${encodeURIComponent(editId)}/listings/${encodeURIComponent(update.language)}`, {
58
+ method: "PUT",
59
+ headers: {
60
+ "Content-Type": "application/json",
61
+ },
62
+ body: JSON.stringify({
63
+ language: update.language,
64
+ ...update.body,
65
+ }),
66
+ });
67
+ }
68
+ export async function uploadGoogleListings(client, packageName, editId, updates, options) {
69
+ for (const update of updates) {
70
+ await uploadGoogleListing(client, packageName, editId, update);
71
+ options?.onUploaded?.(update);
72
+ }
73
+ }
@@ -0,0 +1 @@
1
+ export {};