pabal-store-api-mcp 1.3.13 → 1.3.15

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.
@@ -6,6 +6,7 @@ interface GooglePlayAppInfo {
6
6
  name?: string;
7
7
  supportedLocales?: string[];
8
8
  }
9
+ type GooglePlayScreenshotUploadOption = Parameters<GooglePlayClient["uploadScreenshotsForLocales"]>[0][number];
9
10
  export declare function resolveGooglePlayLocales(allLocales: string[], requestedLocales?: string[]): {
10
11
  localesToPush: string[];
11
12
  missingLocales: string[];
@@ -14,6 +15,7 @@ export declare function shouldPushGooglePlayAppDetails({ hasContactDetails, requ
14
15
  hasContactDetails: boolean;
15
16
  requestedLocales?: string[];
16
17
  }): boolean;
18
+ export declare function createGooglePlayScreenshotUploadBatches(options: GooglePlayScreenshotUploadOption[], batchSize?: number): GooglePlayScreenshotUploadOption[][];
17
19
  /**
18
20
  * Google Play-facing service layer that wraps client creation and common operations.
19
21
  * Keeps MCP tools independent from client factories and SDK details.
@@ -29,7 +31,7 @@ export declare class GooglePlayService {
29
31
  updateReleaseNotes(packageName: string, releaseNotes: Record<string, string>, track?: string, supportedLocales?: string[]): Promise<ServiceResult<UpdatedReleaseNotesResult>>;
30
32
  pullReleaseNotes(packageName: string): Promise<ServiceResult<GooglePlayReleaseNote[]>>;
31
33
  createVersion(packageName: string, versionString: string, versionCodes: number[]): Promise<ServiceResult<CreatedGooglePlayVersion>>;
32
- pushAsoData({ config, packageName, localAsoData, googlePlayDataPath, uploadImages, locales, imageUploadTimeoutMs, slug, }: {
34
+ pushAsoData({ config, packageName, localAsoData, googlePlayDataPath, uploadImages, locales, imageUploadTimeoutMs, imageLocaleBatchSize, slug, }: {
33
35
  config: EnvConfig;
34
36
  packageName?: string;
35
37
  localAsoData: AsoData;
@@ -37,6 +39,7 @@ export declare class GooglePlayService {
37
39
  uploadImages?: boolean;
38
40
  locales?: string[];
39
41
  imageUploadTimeoutMs?: number;
42
+ imageLocaleBatchSize?: number;
40
43
  slug?: string;
41
44
  }): Promise<PushAsoResult>;
42
45
  verifyAuth(): Promise<VerifyAuthResult<{
@@ -6,7 +6,6 @@ import { verifyPlayStoreAuth } from "../../packages/stores/play-store/verify-aut
6
6
  import { createGooglePlayClient } from "../../core/clients/google-play-factory.js";
7
7
  import { parseGooglePlayScreenshots, hasScreenshots, } from "../../core/helpers/screenshot-helpers.js";
8
8
  import { checkPushPrerequisites, serviceFailure, toServiceResult, updateRegisteredLocales, } from "./service-helpers.js";
9
- const GOOGLE_PLAY_SCREENSHOT_LOCALE_BATCH_SIZE = 5;
10
9
  export function resolveGooglePlayLocales(allLocales, requestedLocales) {
11
10
  if (!requestedLocales?.length) {
12
11
  return { localesToPush: allLocales, missingLocales: [] };
@@ -20,6 +19,15 @@ export function resolveGooglePlayLocales(allLocales, requestedLocales) {
20
19
  export function shouldPushGooglePlayAppDetails({ hasContactDetails, requestedLocales, }) {
21
20
  return hasContactDetails && !requestedLocales?.length;
22
21
  }
22
+ export function createGooglePlayScreenshotUploadBatches(options, batchSize) {
23
+ if (!batchSize)
24
+ return options.length > 0 ? [options] : [];
25
+ const batches = [];
26
+ for (let offset = 0; offset < options.length; offset += batchSize) {
27
+ batches.push(options.slice(offset, offset + batchSize));
28
+ }
29
+ return batches;
30
+ }
23
31
  /**
24
32
  * Google Play-facing service layer that wraps client creation and common operations.
25
33
  * Keeps MCP tools independent from client factories and SDK details.
@@ -162,7 +170,7 @@ export class GooglePlayService {
162
170
  return serviceFailure(AppError.wrap(error, HTTP_STATUS.INTERNAL_SERVER_ERROR, ERROR_CODES.GOOGLE_PLAY_CREATE_VERSION_FAILED, "Failed to create Google Play version"));
163
171
  }
164
172
  }
165
- async pushAsoData({ config, packageName, localAsoData, googlePlayDataPath, uploadImages = false, locales, imageUploadTimeoutMs, slug, }) {
173
+ async pushAsoData({ config, packageName, localAsoData, googlePlayDataPath, uploadImages = false, locales, imageUploadTimeoutMs, imageLocaleBatchSize, slug, }) {
166
174
  const skip = checkPushPrerequisites({
167
175
  storeLabel: "Google Play",
168
176
  configured: Boolean(config.playStore),
@@ -302,8 +310,8 @@ export class GooglePlayService {
302
310
  failedLocales.push(locale);
303
311
  }
304
312
  }
305
- for (let offset = 0; offset < screenshotUploadOptions.length; offset += GOOGLE_PLAY_SCREENSHOT_LOCALE_BATCH_SIZE) {
306
- const batch = screenshotUploadOptions.slice(offset, offset + GOOGLE_PLAY_SCREENSHOT_LOCALE_BATCH_SIZE);
313
+ const screenshotBatches = createGooglePlayScreenshotUploadBatches(screenshotUploadOptions, imageLocaleBatchSize);
314
+ for (const batch of screenshotBatches) {
307
315
  try {
308
316
  console.error(`[GooglePlay] 📤 Uploading screenshots for ${batch.length} locale(s) in one edit...`);
309
317
  const uploadResults = await client.uploadScreenshotsForLocales(batch);
package/dist/src/index.js CHANGED
@@ -204,7 +204,13 @@ registerToolWithInfo("aso-push", {
204
204
  .int()
205
205
  .positive()
206
206
  .optional()
207
- .describe("Per-image upload timeout in milliseconds"),
207
+ .describe("Per-image upload timeout in milliseconds (default: 600000 when uploadImages=true)"),
208
+ imageLocaleBatchSize: z
209
+ .number()
210
+ .int()
211
+ .positive()
212
+ .optional()
213
+ .describe("Google Play image locale batch size. Omit to upload all target locales in one edit commit"),
208
214
  dryRun: z
209
215
  .boolean()
210
216
  .optional()
@@ -108,15 +108,34 @@ export declare class AppStoreClient {
108
108
  * Delete all screenshots in a screenshot set
109
109
  */
110
110
  private deleteAllScreenshotsInSet;
111
+ private deleteScreenshots;
112
+ private prepareScreenshotSetForUpload;
111
113
  private sanitizePathSegment;
112
114
  private getScreenshotLockPath;
113
115
  private acquireScreenshotUploadLock;
114
116
  private preflightScreenshotBatch;
115
117
  /**
116
- * Upload multiple screenshots for a locale, replacing existing ones
118
+ * Upload multiple screenshots for a locale, replacing existing ones.
119
+ *
120
+ * Failure model:
121
+ *
122
+ * existing screenshots ──┐
123
+ * ├─ free only the slots App Store requires
124
+ * incoming screenshots ──┘
125
+ * │
126
+ * ├─ upload + commit every incoming screenshot
127
+ * │
128
+ * └─ only after success, delete the remaining old screenshots
129
+ *
130
+ * This avoids the old delete-first behavior where a timeout could leave the
131
+ * user with an empty screenshot set. If upload fails, most existing
132
+ * screenshots remain visible and any successfully uploaded new screenshots
133
+ * are left in App Store Connect for manual or retry cleanup.
134
+ *
117
135
  * 1. Find or create screenshot sets for each display type
118
- * 2. Delete existing screenshots in each set
136
+ * 2. Delete only enough existing screenshots to fit the incoming batch
119
137
  * 3. Upload new screenshots in order
138
+ * 4. Delete remaining old screenshots after the batch succeeds
120
139
  */
121
140
  uploadScreenshotsForLocale(options: {
122
141
  locale: string;
@@ -12,6 +12,9 @@ import { DEFAULT_LOCALE } from "../../../packages/configs/aso-config/constants.j
12
12
  import { getAsoDir } from "../../../packages/configs/aso-config/utils.js";
13
13
  import { convertToAsoData, convertToMultilingualAsoData, convertToReleaseNote, fetchScreenshotsForLocalization, mapLocalizationsByLocale, selectEnglishAppName, sortReleaseNotes, sortVersions, } from "./api-converters.js";
14
14
  import { APP_STORE_API_BASE_URL, APP_STORE_PLATFORM, DEFAULT_APP_LIST_LIMIT, DEFAULT_VERSIONS_FETCH_LIMIT, } from "./constants.js";
15
+ const APP_STORE_SCREENSHOT_SET_MAX_COUNT = 10;
16
+ const DEFAULT_IMAGE_UPLOAD_TIMEOUT_MS = 10 * 60 * 1000;
17
+ const SCREENSHOT_UPLOAD_LOCK_STALE_MS = 30 * 60 * 1000;
15
18
  export class AppStoreClient {
16
19
  issuerId;
17
20
  keyId;
@@ -727,9 +730,10 @@ export class AppStoreClient {
727
730
  }
728
731
  });
729
732
  req.on("error", reject);
730
- if (timeoutMs) {
731
- req.setTimeout(timeoutMs, () => {
732
- req.destroy(new Error(`Upload timed out after ${timeoutMs}ms`));
733
+ const effectiveTimeoutMs = timeoutMs ?? DEFAULT_IMAGE_UPLOAD_TIMEOUT_MS;
734
+ if (effectiveTimeoutMs > 0) {
735
+ req.setTimeout(effectiveTimeoutMs, () => {
736
+ req.destroy(new Error(`Upload timed out after ${effectiveTimeoutMs}ms`));
733
737
  });
734
738
  }
735
739
  req.write(fileBuffer);
@@ -779,6 +783,9 @@ export class AppStoreClient {
779
783
  async deleteAllScreenshotsInSet(screenshotSetId) {
780
784
  const screenshotsResponse = await this.listScreenshots(screenshotSetId);
781
785
  const screenshots = screenshotsResponse.data || [];
786
+ return this.deleteScreenshots(screenshots);
787
+ }
788
+ async deleteScreenshots(screenshots) {
782
789
  let deletedCount = 0;
783
790
  for (const screenshot of screenshots) {
784
791
  if (screenshot.type !== "appScreenshots") {
@@ -790,6 +797,33 @@ export class AppStoreClient {
790
797
  }
791
798
  return deletedCount;
792
799
  }
800
+ async prepareScreenshotSetForUpload(screenshotSetId, incomingCount) {
801
+ if (incomingCount > APP_STORE_SCREENSHOT_SET_MAX_COUNT) {
802
+ throw new Error(`Preflight failed: App Store allows up to ${APP_STORE_SCREENSHOT_SET_MAX_COUNT} screenshots per display type, got ${incomingCount}`);
803
+ }
804
+ const screenshotsResponse = await this.listScreenshots(screenshotSetId);
805
+ const existingScreenshots = (screenshotsResponse.data || []).filter((screenshot) => screenshot.type === "appScreenshots");
806
+ const nonScreenshotCount = (screenshotsResponse.data || []).length - existingScreenshots.length;
807
+ if (nonScreenshotCount > 0) {
808
+ console.error(`[AppStore] Ignoring ${nonScreenshotCount} non-screenshot asset(s) in screenshot set`);
809
+ }
810
+ const slotsToFree = Math.max(0, existingScreenshots.length +
811
+ incomingCount -
812
+ APP_STORE_SCREENSHOT_SET_MAX_COUNT);
813
+ // Preserve the first screenshots as long as possible. If the upload fails
814
+ // after freeing slots, users are more likely to still see the primary
815
+ // screenshots rather than the tail of the old set.
816
+ const screenshotsToDeleteBeforeUpload = slotsToFree > 0 ? existingScreenshots.slice(-slotsToFree) : [];
817
+ const screenshotsToDeleteAfterUpload = existingScreenshots.slice(0, existingScreenshots.length - slotsToFree);
818
+ const deletedBeforeUpload = await this.deleteScreenshots(screenshotsToDeleteBeforeUpload);
819
+ if (deletedBeforeUpload > 0) {
820
+ console.error(`[AppStore] Deleted ${deletedBeforeUpload} existing screenshots to free upload slots`);
821
+ }
822
+ return {
823
+ deletedBeforeUpload,
824
+ screenshotsToDeleteAfterUpload,
825
+ };
826
+ }
793
827
  sanitizePathSegment(value) {
794
828
  return value.replace(/[^a-zA-Z0-9._-]/g, "_");
795
829
  }
@@ -808,9 +842,19 @@ export class AppStoreClient {
808
842
  catch (error) {
809
843
  const code = error.code;
810
844
  if (code === "EEXIST") {
811
- throw new Error(`Screenshot upload lock is already held (${lockPath}). Another upload is running for this locale/display type.`);
845
+ const lockAgeMs = Date.now() - statSync(lockPath).mtimeMs;
846
+ if (lockAgeMs > SCREENSHOT_UPLOAD_LOCK_STALE_MS) {
847
+ console.error(`[AppStore] Removing stale screenshot upload lock (${lockPath})`);
848
+ unlinkSync(lockPath);
849
+ descriptor = openSync(lockPath, "wx");
850
+ }
851
+ else {
852
+ throw new Error(`Screenshot upload lock is already held (${lockPath}). Another upload is running for this locale/display type.`);
853
+ }
854
+ }
855
+ else {
856
+ throw error;
812
857
  }
813
- throw error;
814
858
  }
815
859
  closeSync(descriptor);
816
860
  return () => {
@@ -856,10 +900,27 @@ export class AppStoreClient {
856
900
  }
857
901
  }
858
902
  /**
859
- * Upload multiple screenshots for a locale, replacing existing ones
903
+ * Upload multiple screenshots for a locale, replacing existing ones.
904
+ *
905
+ * Failure model:
906
+ *
907
+ * existing screenshots ──┐
908
+ * ├─ free only the slots App Store requires
909
+ * incoming screenshots ──┘
910
+ * │
911
+ * ├─ upload + commit every incoming screenshot
912
+ * │
913
+ * └─ only after success, delete the remaining old screenshots
914
+ *
915
+ * This avoids the old delete-first behavior where a timeout could leave the
916
+ * user with an empty screenshot set. If upload fails, most existing
917
+ * screenshots remain visible and any successfully uploaded new screenshots
918
+ * are left in App Store Connect for manual or retry cleanup.
919
+ *
860
920
  * 1. Find or create screenshot sets for each display type
861
- * 2. Delete existing screenshots in each set
921
+ * 2. Delete only enough existing screenshots to fit the incoming batch
862
922
  * 3. Upload new screenshots in order
923
+ * 4. Delete remaining old screenshots after the batch succeeds
863
924
  */
864
925
  async uploadScreenshotsForLocale(options) {
865
926
  const { locale, screenshots, imageUploadTimeoutMs } = options;
@@ -906,13 +967,10 @@ export class AppStoreClient {
906
967
  console.error(`[AppStore] Processing ${displayType} (${screenshotList.length} screenshots)...`);
907
968
  // Find or create screenshot set
908
969
  const screenshotSetId = await this.findOrCreateScreenshotSet(localizationId, displayType);
909
- // Delete existing screenshots in this set
910
- const deletedCount = await this.deleteAllScreenshotsInSet(screenshotSetId);
911
- if (deletedCount > 0) {
912
- console.error(`[AppStore] Deleted ${deletedCount} existing screenshots`);
913
- result.deleted += deletedCount;
914
- }
970
+ const { deletedBeforeUpload, screenshotsToDeleteAfterUpload } = await this.prepareScreenshotSetForUpload(screenshotSetId, screenshotList.length);
971
+ result.deleted += deletedBeforeUpload;
915
972
  // Upload new screenshots in order
973
+ let uploadedForDisplayType = 0;
916
974
  for (const screenshot of screenshotList) {
917
975
  try {
918
976
  const fileBuffer = readFileSync(screenshot.path);
@@ -929,12 +987,20 @@ export class AppStoreClient {
929
987
  await this.commitAppScreenshot(screenshotData.id);
930
988
  console.error(`[AppStore] ✅ ${screenshot.filename}`);
931
989
  result.uploaded++;
990
+ uploadedForDisplayType++;
932
991
  }
933
992
  catch (error) {
934
993
  result.failed++;
935
994
  throw new Error(`[${displayType}] ${screenshot.filename}: ${error instanceof Error ? error.message : String(error)}`);
936
995
  }
937
996
  }
997
+ if (uploadedForDisplayType === screenshotList.length) {
998
+ const deletedAfterUpload = await this.deleteScreenshots(screenshotsToDeleteAfterUpload);
999
+ if (deletedAfterUpload > 0) {
1000
+ console.error(`[AppStore] Deleted ${deletedAfterUpload} replaced screenshots after successful upload`);
1001
+ result.deleted += deletedAfterUpload;
1002
+ }
1003
+ }
938
1004
  }
939
1005
  finally {
940
1006
  releaseLock();
@@ -7,6 +7,7 @@ interface AsoPushOptions {
7
7
  uploadImages?: boolean;
8
8
  locales?: string[];
9
9
  imageUploadTimeoutMs?: number;
10
+ imageLocaleBatchSize?: number;
10
11
  dryRun?: boolean;
11
12
  }
12
13
  export declare function handleAsoPush(options: AsoPushOptions): Promise<{
@@ -5,11 +5,14 @@ import { AppResolutionService } from "../../core/services/app-resolution-service
5
5
  import { AppStoreService } from "../../core/services/app-store-service.js";
6
6
  import { GooglePlayService } from "../../core/services/google-play-service.js";
7
7
  import { formatPushResult } from "../../core/helpers/formatters.js";
8
+ const DEFAULT_IMAGE_UPLOAD_TIMEOUT_MS = 10 * 60 * 1000;
8
9
  const appResolutionService = new AppResolutionService();
9
10
  const appStoreService = new AppStoreService();
10
11
  const googlePlayService = new GooglePlayService();
11
12
  export async function handleAsoPush(options) {
12
- const { store = "both", uploadImages = false, locales, imageUploadTimeoutMs, dryRun = false, } = options;
13
+ const { store = "both", uploadImages = false, locales, dryRun = false, } = options;
14
+ const imageUploadTimeoutMs = options.imageUploadTimeoutMs ??
15
+ (uploadImages ? DEFAULT_IMAGE_UPLOAD_TIMEOUT_MS : undefined);
13
16
  const resolved = appResolutionService.resolve({
14
17
  slug: options.app,
15
18
  packageName: options.packageName,
@@ -39,6 +42,9 @@ export async function handleAsoPush(options) {
39
42
  if (imageUploadTimeoutMs) {
40
43
  console.error(`[MCP] Image Upload Timeout: ${imageUploadTimeoutMs}ms`);
41
44
  }
45
+ if (options.imageLocaleBatchSize) {
46
+ console.error(`[MCP] Image Locale Batch Size: ${options.imageLocaleBatchSize}`);
47
+ }
42
48
  console.error(`[MCP] Mode: ${dryRun ? "Dry run" : "Actual push"}`);
43
49
  let config;
44
50
  try {
@@ -143,6 +149,7 @@ export async function handleAsoPush(options) {
143
149
  uploadImages,
144
150
  locales,
145
151
  imageUploadTimeoutMs,
152
+ imageLocaleBatchSize: options.imageLocaleBatchSize,
146
153
  slug,
147
154
  });
148
155
  results.push(formatPushResult("Google Play", result));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pabal-store-api-mcp",
3
- "version": "1.3.13",
3
+ "version": "1.3.15",
4
4
  "description": "MCP server for App Store / Play Store ASO workflows",
5
5
  "main": "dist/src/index.js",
6
6
  "types": "dist/src/index.d.ts",