@gpc-cli/api 1.0.22 → 1.0.24

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/index.d.ts CHANGED
@@ -415,18 +415,80 @@ interface SubscriptionPurchaseV2 {
415
415
  startTime?: string;
416
416
  subscriptionState: string;
417
417
  acknowledgementState?: string;
418
+ linkedPurchaseToken?: string;
419
+ /** Current offer phase: free trial, introductory price, proration, or base plan price. (Jan 2026) */
420
+ offerPhase?: string;
421
+ /** Resubscription context when purchase originates from Play Store. (Nov 2025) */
422
+ outOfAppPurchaseContext?: {
423
+ externalTransactionToken?: string;
424
+ };
425
+ /** Cancellation details: reason, survey result, time. */
426
+ canceledStateContext?: {
427
+ cancelTime?: string;
428
+ cancelSurveyResult?: {
429
+ reason?: number;
430
+ reasonUserInput?: string;
431
+ };
432
+ userInitiatedCancellation?: Record<string, unknown>;
433
+ systemInitiatedCancellation?: Record<string, unknown>;
434
+ developerInitiatedCancellation?: Record<string, unknown>;
435
+ replacementCancellation?: Record<string, unknown>;
436
+ };
437
+ testPurchase?: Record<string, unknown>;
438
+ signupPromotion?: {
439
+ promotionType?: string;
440
+ promotionCode?: string;
441
+ };
442
+ externalAccountIdentifiers?: {
443
+ externalAccountId?: string;
444
+ obfuscatedExternalAccountId?: string;
445
+ obfuscatedExternalProfileId?: string;
446
+ };
447
+ pausedStateContext?: {
448
+ autoResumeTime?: string;
449
+ };
450
+ subscribeWithGoogleInfo?: {
451
+ profileName?: string;
452
+ emailAddress?: string;
453
+ givenName?: string;
454
+ familyName?: string;
455
+ profileId?: string;
456
+ };
418
457
  }
419
458
  interface SubscriptionPurchaseLineItem {
420
459
  productId: string;
421
460
  expiryTime?: string;
422
461
  autoRenewingPlan?: {
423
462
  autoRenewEnabled?: boolean;
463
+ recurringPrice?: Money;
464
+ priceChangeDetails?: {
465
+ newPrice?: Money;
466
+ priceChangeState?: string;
467
+ expectedNewPriceChargeTime?: string;
468
+ };
469
+ /** Price step-up consent details. (Sep 2025) */
470
+ priceStepUpConsentDetails?: {
471
+ consentStatus?: string;
472
+ lastConsentTime?: string;
473
+ };
424
474
  };
425
475
  offerDetails?: {
426
476
  basePlanId?: string;
427
477
  offerId?: string;
428
478
  offerTags?: string[];
429
479
  };
480
+ /** Replaces deprecated latestOrderId. (May 2025) */
481
+ latestSuccessfulOrderId?: string;
482
+ /** Details about item being replaced, if applicable. (Nov 2025) */
483
+ itemReplacement?: {
484
+ productId?: string;
485
+ offerDetails?: {
486
+ basePlanId?: string;
487
+ offerId?: string;
488
+ };
489
+ };
490
+ /** Current offer phase identifier. (Jan 2026) */
491
+ offerPhase?: string;
430
492
  }
431
493
  interface SubscriptionPurchase {
432
494
  startTimeMillis: string;
@@ -457,6 +519,92 @@ interface VoidedPurchasesListResponse {
457
519
  voidedPurchases: VoidedPurchase[];
458
520
  tokenPagination?: TokenPagination;
459
521
  }
522
+ interface Order {
523
+ orderId: string;
524
+ state: string;
525
+ purchaseToken?: string;
526
+ createTime?: string;
527
+ lastEventTime?: string;
528
+ total?: Money;
529
+ tax?: Money;
530
+ lineItems?: OrderLineItem[];
531
+ buyerAddress?: {
532
+ regionCode?: string;
533
+ postalCode?: string;
534
+ };
535
+ developerRevenueInBuyerCurrency?: Money;
536
+ orderHistory?: {
537
+ processedEvent?: {
538
+ eventTime?: string;
539
+ };
540
+ cancellationEvent?: {
541
+ eventTime?: string;
542
+ };
543
+ refundEvent?: {
544
+ eventTime?: string;
545
+ refundDetails?: {
546
+ tax?: Money;
547
+ refund?: Money;
548
+ };
549
+ refundReason?: string;
550
+ };
551
+ partialRefundEvents?: Array<{
552
+ createTime?: string;
553
+ processTime?: string;
554
+ state?: string;
555
+ refundDetails?: {
556
+ tax?: Money;
557
+ refund?: Money;
558
+ };
559
+ }>;
560
+ };
561
+ /** Offer phase details for prorated periods. (Nov 2025) */
562
+ offerPhaseDetails?: {
563
+ offerPhase?: string;
564
+ };
565
+ }
566
+ interface OrderLineItem {
567
+ productId?: string;
568
+ productType?: string;
569
+ quantity?: number;
570
+ price?: Money;
571
+ }
572
+ interface BatchGetOrdersResponse {
573
+ orders: Order[];
574
+ }
575
+ interface ProductPurchaseV2 {
576
+ kind?: string;
577
+ productLineItem?: ProductPurchaseLineItem[];
578
+ purchaseStateContext?: {
579
+ state?: string;
580
+ };
581
+ orderId?: string;
582
+ regionCode?: string;
583
+ purchaseCompletionTime?: string;
584
+ acknowledgementState?: string;
585
+ obfuscatedExternalAccountId?: string;
586
+ obfuscatedExternalProfileId?: string;
587
+ testPurchaseContext?: Record<string, unknown>;
588
+ }
589
+ interface ProductPurchaseLineItem {
590
+ productId?: string;
591
+ quantity?: number;
592
+ offerDetails?: {
593
+ offerId?: string;
594
+ offerTags?: string[];
595
+ };
596
+ }
597
+ interface SubscriptionsV2CancelRequest {
598
+ cancellationType?: string;
599
+ }
600
+ interface SubscriptionsV2DeferRequest {
601
+ deferralInfo: {
602
+ desiredExpiryTime: string;
603
+ };
604
+ }
605
+ interface SubscriptionsV2DeferResponse {
606
+ newExpiryTime: string;
607
+ }
460
608
  interface ConvertRegionPricesRequest {
461
609
  price: Money;
462
610
  }
@@ -877,6 +1025,12 @@ interface PlayApiClient {
877
1025
  deferSubscription(packageName: string, subscriptionId: string, token: string, body: SubscriptionDeferRequest): Promise<SubscriptionDeferResponse>;
878
1026
  revokeSubscriptionV2(packageName: string, token: string): Promise<void>;
879
1027
  refundSubscriptionV2(packageName: string, token: string): Promise<void>;
1028
+ /** V2 cancel with cancellationType support. (Sep 2025) */
1029
+ cancelSubscriptionV2(packageName: string, token: string, body?: SubscriptionsV2CancelRequest): Promise<void>;
1030
+ /** V2 defer for subscriptions with add-ons. (Jan 2026) */
1031
+ deferSubscriptionV2(packageName: string, token: string, body: SubscriptionsV2DeferRequest): Promise<SubscriptionsV2DeferResponse>;
1032
+ /** V2 product purchase details for multi-offer OTPs. (Jun 2025) */
1033
+ getProductV2(packageName: string, token: string): Promise<ProductPurchaseV2>;
880
1034
  listVoided(packageName: string, options?: {
881
1035
  startTime?: string;
882
1036
  endTime?: string;
@@ -885,9 +1039,12 @@ interface PlayApiClient {
885
1039
  }): Promise<VoidedPurchasesListResponse>;
886
1040
  };
887
1041
  orders: {
1042
+ get(packageName: string, orderId: string): Promise<Order>;
1043
+ batchGet(packageName: string, orderIds: string[]): Promise<Order[]>;
888
1044
  refund(packageName: string, orderId: string, body?: {
889
1045
  fullRefund?: boolean;
890
1046
  proratedRefund?: boolean;
1047
+ revoke?: boolean;
891
1048
  }): Promise<void>;
892
1049
  };
893
1050
  monetization: {
@@ -1123,4 +1280,4 @@ declare class PlayApiError extends Error {
1123
1280
  /** Files below this threshold use simple upload instead. */
1124
1281
  declare const RESUMABLE_THRESHOLD: number;
1125
1282
 
1126
- export { type Achievement, type Anomaly, type AnomalyDetectionResponse, type ApiClientOptions, type ApiResponse, type ApkInfo, type AppDetails, type AppEdit, type AppRecoveriesListResponse, type AppRecoveryAction, type AppRecoveryTargeting, type BasePlan, type BasePlanMigratePricesRequest, type Bundle, type BundleListResponse, type ConvertRegionPricesRequest, type ConvertRegionPricesResponse, type ConvertedRegionPrice, type CountryAvailability, type CreateAppRecoveryActionRequest, type CustomApp, type CustomAppsListResponse, type DataSafety, type DataSafetyDataType, type DataSafetyPurpose, type DeobfuscationFile, type DeobfuscationUploadResponse, type DeveloperComment, type DeveloperPermission, type DeviceGroup, type DeviceSelector, type DeviceTier, type DeviceTierConfig, type DeviceTierConfigsListResponse, type EnterpriseApiClient, type ErrorIssue, type ErrorIssuesResponse, type ErrorReport, type ErrorReportsResponse, type ExternalTransaction, type ExternalTransactionAmount, type ExternalTransactionRefund, type ExternallyHostedApk, type ExternallyHostedApkResponse, type GameEvent, type GamesApiClient, type GeneratedApk, type GeneratedApksPerVersion, type Grant, type GrantsListResponse, type HttpClient, type Image, type ImageType, type ImageUploadResponse, type ImagesDeleteAllResponse, type ImagesListResponse, type InAppProduct, type InAppProductListing, type InAppProductsBatchGetRequest, type InAppProductsBatchUpdateRequest, type InAppProductsBatchUpdateResponse, type InAppProductsListResponse, type InternalAppSharingArtifact, type Leaderboard, type LeaderboardScore, type Listing, type ListingsListResponse, type MetricRow, type MetricSetQuery, type MetricSetResponse, type Money, type OffersListResponse, type OneTimeOffer, type OneTimeOfferRegionalConfig, type OneTimeOffersListResponse, type OneTimeProduct, type OneTimeProductListing, type OneTimeProductsListResponse, type PagedResponse, type PaginateOptions, type PlayApiClient, PlayApiError, type ProductPurchase, type PurchaseOption, type PurchaseOptionsListResponse, RATE_LIMIT_BUCKETS, RESUMABLE_THRESHOLD, type RateLimitBucket, type RateLimiter, type RegionalBasePlanConfig, type Release, type ReleaseNote, type ReleaseStatus, type ReportBucket, type ReportType, type ReportingAggregation, type ReportingApiClient, type ReportingDimension, type ReportsListResponse, type ResumableUploadOptions, type RetryLogEntry, type Review, type ReviewComment, type ReviewReplyRequest, type ReviewReplyResponse, type ReviewsListOptions, type ReviewsListResponse, type StatsDimension, type Subscription, type SubscriptionDeferRequest, type SubscriptionDeferResponse, type SubscriptionListing, type SubscriptionOffer, type SubscriptionOfferPhase, type SubscriptionPurchase, type SubscriptionPurchaseLineItem, type SubscriptionPurchaseV2, type SubscriptionsListResponse, type TaxAndComplianceSettings, type Testers, type TokenPagination, type Track, type TrackListResponse, type UploadProgressEvent, type UploadResponse, type User, type UserComment, type UsersApiClient, type UsersListResponse, type VitalsMetricSet, type VoidedPurchase, type VoidedPurchasesListResponse, createApiClient, createEnterpriseClient, createGamesClient, createHttpClient, createRateLimiter, createReportingClient, createUsersClient, paginate, paginateAll, paginateParallel };
1283
+ export { type Achievement, type Anomaly, type AnomalyDetectionResponse, type ApiClientOptions, type ApiResponse, type ApkInfo, type AppDetails, type AppEdit, type AppRecoveriesListResponse, type AppRecoveryAction, type AppRecoveryTargeting, type BasePlan, type BasePlanMigratePricesRequest, type BatchGetOrdersResponse, type Bundle, type BundleListResponse, type ConvertRegionPricesRequest, type ConvertRegionPricesResponse, type ConvertedRegionPrice, type CountryAvailability, type CreateAppRecoveryActionRequest, type CustomApp, type CustomAppsListResponse, type DataSafety, type DataSafetyDataType, type DataSafetyPurpose, type DeobfuscationFile, type DeobfuscationUploadResponse, type DeveloperComment, type DeveloperPermission, type DeviceGroup, type DeviceSelector, type DeviceTier, type DeviceTierConfig, type DeviceTierConfigsListResponse, type EnterpriseApiClient, type ErrorIssue, type ErrorIssuesResponse, type ErrorReport, type ErrorReportsResponse, type ExternalTransaction, type ExternalTransactionAmount, type ExternalTransactionRefund, type ExternallyHostedApk, type ExternallyHostedApkResponse, type GameEvent, type GamesApiClient, type GeneratedApk, type GeneratedApksPerVersion, type Grant, type GrantsListResponse, type HttpClient, type Image, type ImageType, type ImageUploadResponse, type ImagesDeleteAllResponse, type ImagesListResponse, type InAppProduct, type InAppProductListing, type InAppProductsBatchGetRequest, type InAppProductsBatchUpdateRequest, type InAppProductsBatchUpdateResponse, type InAppProductsListResponse, type InternalAppSharingArtifact, type Leaderboard, type LeaderboardScore, type Listing, type ListingsListResponse, type MetricRow, type MetricSetQuery, type MetricSetResponse, type Money, type OffersListResponse, type OneTimeOffer, type OneTimeOfferRegionalConfig, type OneTimeOffersListResponse, type OneTimeProduct, type OneTimeProductListing, type OneTimeProductsListResponse, type Order, type OrderLineItem, type PagedResponse, type PaginateOptions, type PlayApiClient, PlayApiError, type ProductPurchase, type ProductPurchaseLineItem, type ProductPurchaseV2, type PurchaseOption, type PurchaseOptionsListResponse, RATE_LIMIT_BUCKETS, RESUMABLE_THRESHOLD, type RateLimitBucket, type RateLimiter, type RegionalBasePlanConfig, type Release, type ReleaseNote, type ReleaseStatus, type ReportBucket, type ReportType, type ReportingAggregation, type ReportingApiClient, type ReportingDimension, type ReportsListResponse, type ResumableUploadOptions, type RetryLogEntry, type Review, type ReviewComment, type ReviewReplyRequest, type ReviewReplyResponse, type ReviewsListOptions, type ReviewsListResponse, type StatsDimension, type Subscription, type SubscriptionDeferRequest, type SubscriptionDeferResponse, type SubscriptionListing, type SubscriptionOffer, type SubscriptionOfferPhase, type SubscriptionPurchase, type SubscriptionPurchaseLineItem, type SubscriptionPurchaseV2, type SubscriptionsListResponse, type SubscriptionsV2CancelRequest, type SubscriptionsV2DeferRequest, type SubscriptionsV2DeferResponse, type TaxAndComplianceSettings, type Testers, type TokenPagination, type Track, type TrackListResponse, type UploadProgressEvent, type UploadResponse, type User, type UserComment, type UsersApiClient, type UsersListResponse, type VitalsMetricSet, type VoidedPurchase, type VoidedPurchasesListResponse, createApiClient, createEnterpriseClient, createGamesClient, createHttpClient, createRateLimiter, createReportingClient, createUsersClient, paginate, paginateAll, paginateParallel };
package/dist/index.js CHANGED
@@ -27,6 +27,10 @@ import { resolve, isAbsolute } from "path";
27
27
  // src/resumable-upload.ts
28
28
  import { open, stat } from "fs/promises";
29
29
  var CHUNK_ALIGNMENT = 256 * 1024;
30
+ var GUPLOADER_NO_308_HEADER = "X-GUploader-No-308";
31
+ function isResumeIncomplete(response) {
32
+ return response.headers.get("X-Http-Status-Code-Override") === "308";
33
+ }
30
34
  var DEFAULT_CHUNK_SIZE = 8 * 1024 * 1024;
31
35
  var RESUMABLE_THRESHOLD = 5 * 1024 * 1024;
32
36
  function envInt(name) {
@@ -214,10 +218,13 @@ async function sendChunk(sessionUri, chunk, contentRange, ctx) {
214
218
  headers: {
215
219
  Authorization: `Bearer ${token}`,
216
220
  "Content-Length": String(chunk.byteLength),
217
- "Content-Range": contentRange
221
+ "Content-Range": contentRange,
222
+ [GUPLOADER_NO_308_HEADER]: "yes"
218
223
  },
219
224
  body: chunk,
220
- signal: controller.signal
225
+ signal: controller.signal,
226
+ redirect: "manual"
227
+ // Belt-and-suspenders: don't follow redirects even without the header
221
228
  });
222
229
  } catch {
223
230
  return void 0;
@@ -225,6 +232,10 @@ async function sendChunk(sessionUri, chunk, contentRange, ctx) {
225
232
  clearTimeout(timer);
226
233
  }
227
234
  if (response.status === 200 || response.status === 201) {
235
+ if (isResumeIncomplete(response)) {
236
+ await response.body?.cancel();
237
+ return { complete: false };
238
+ }
228
239
  const text = await response.text();
229
240
  let data;
230
241
  try {
@@ -280,11 +291,13 @@ async function fetchCompletionResponse(sessionUri, totalBytes, ctx) {
280
291
  headers: {
281
292
  Authorization: `Bearer ${token}`,
282
293
  "Content-Length": "0",
283
- "Content-Range": `bytes */${totalBytes}`
294
+ "Content-Range": `bytes */${totalBytes}`,
295
+ [GUPLOADER_NO_308_HEADER]: "yes"
284
296
  },
285
- signal: controller.signal
297
+ signal: controller.signal,
298
+ redirect: "manual"
286
299
  });
287
- if (response.status === 200 || response.status === 201) {
300
+ if ((response.status === 200 || response.status === 201) && !isResumeIncomplete(response)) {
288
301
  const text = await response.text();
289
302
  let data;
290
303
  try {
@@ -313,14 +326,16 @@ async function queryProgress(sessionUri, totalBytes, ctx) {
313
326
  headers: {
314
327
  Authorization: `Bearer ${token}`,
315
328
  "Content-Length": "0",
316
- "Content-Range": `bytes */${totalBytes}`
329
+ "Content-Range": `bytes */${totalBytes}`,
330
+ [GUPLOADER_NO_308_HEADER]: "yes"
317
331
  },
318
- signal: controller.signal
332
+ signal: controller.signal,
333
+ redirect: "manual"
319
334
  });
320
335
  } finally {
321
336
  clearTimeout(timer);
322
337
  }
323
- if (response.status === 308) {
338
+ if (response.status === 308 || isResumeIncomplete(response)) {
324
339
  await response.body?.cancel();
325
340
  const range = response.headers.get("Range");
326
341
  if (range) {
@@ -395,51 +410,183 @@ function envInt2(name) {
395
410
  function resolveOption(explicit, envName, fallback) {
396
411
  return explicit ?? envInt2(envName) ?? fallback;
397
412
  }
413
+ function enhanceApiError(status, body) {
414
+ let errorMsg = "";
415
+ try {
416
+ const parsed = JSON.parse(body);
417
+ errorMsg = parsed?.error?.message?.toLowerCase() ?? "";
418
+ } catch {
419
+ errorMsg = body.toLowerCase();
420
+ }
421
+ if ((status === 400 || status === 403) && errorMsg.includes("version code") && errorMsg.includes("already been used")) {
422
+ const match = errorMsg.match(/version code (\d+)/);
423
+ const vc = match?.[1] ?? "?";
424
+ return {
425
+ code: "API_DUPLICATE_VERSION_CODE",
426
+ message: `Version code ${vc} has already been uploaded to this app.`,
427
+ suggestion: [
428
+ `Increment versionCode in your build.gradle (or build.gradle.kts) and rebuild.`,
429
+ `Check the current version with: gpc releases status --track production`
430
+ ].join("\n")
431
+ };
432
+ }
433
+ if ((status === 400 || status === 403) && errorMsg.includes("version code") && (errorMsg.includes("lower") || errorMsg.includes("not allowed") || errorMsg.includes("not greater"))) {
434
+ return {
435
+ code: "API_VERSION_CODE_TOO_LOW",
436
+ message: "Version code is lower than the current version on the target track.",
437
+ suggestion: [
438
+ "Google Play requires version codes to increase with each upload.",
439
+ "Check the current version with: gpc releases status --track <track>",
440
+ "Then set a higher versionCode in your build.gradle and rebuild."
441
+ ].join("\n")
442
+ };
443
+ }
444
+ if ((status === 400 || status === 403) && (errorMsg.includes("package name") || errorMsg.includes("applicationid")) && errorMsg.includes("does not match")) {
445
+ return {
446
+ code: "API_PACKAGE_NAME_MISMATCH",
447
+ message: "The package name in the uploaded bundle does not match the target app.",
448
+ suggestion: [
449
+ "Verify your applicationId in build.gradle matches the app you're uploading to.",
450
+ "Check the configured package with: gpc config show",
451
+ "Or specify explicitly with: --app com.example.yourapp"
452
+ ].join("\n")
453
+ };
454
+ }
455
+ if (status === 404 && (errorMsg.includes("applicationnotfound") || errorMsg.includes("no application was found") || errorMsg.includes("application not found"))) {
456
+ return {
457
+ code: "API_APP_NOT_FOUND",
458
+ message: "This app was not found in your Google Play developer account.",
459
+ suggestion: [
460
+ "Verify the package name is correct.",
461
+ "Ensure the app has been created in the Google Play Console.",
462
+ "List available apps with: gpc apps list"
463
+ ].join("\n")
464
+ };
465
+ }
466
+ if (status === 403 && (errorMsg.includes("permission") || errorMsg.includes("insufficient") || errorMsg.includes("caller does not have"))) {
467
+ return {
468
+ code: "API_INSUFFICIENT_PERMISSIONS",
469
+ message: "The service account does not have permission for this operation.",
470
+ suggestion: [
471
+ "In Google Play Console \u2192 Users and permissions \u2192 find your service account email.",
472
+ "Grant the required permissions (e.g., 'Release to production' for uploads).",
473
+ "Run gpc doctor to verify your credentials and permissions."
474
+ ].join("\n")
475
+ };
476
+ }
477
+ if (status === 409) {
478
+ return {
479
+ code: "API_EDIT_CONFLICT",
480
+ message: "An edit conflict occurred \u2014 another edit session is open for this app.",
481
+ suggestion: [
482
+ "This usually means another process has an open edit (CI pipeline, Play Console, or another gpc instance).",
483
+ "Wait a few minutes and retry \u2014 GPC will auto-retry once.",
484
+ "Or discard the stale edit in the Google Play Console."
485
+ ].join("\n")
486
+ };
487
+ }
488
+ if (status === 413 || (status === 400 || status === 403) && (errorMsg.includes("too large") || errorMsg.includes("exceeds") && errorMsg.includes("size"))) {
489
+ return {
490
+ code: "API_BUNDLE_TOO_LARGE",
491
+ message: "The uploaded file exceeds Google Play's size limit.",
492
+ suggestion: [
493
+ "AAB files must be under 2 GB, APK files under 1 GB.",
494
+ "Use Android App Bundles (AAB) instead of APK for smaller file sizes.",
495
+ "Run gpc preflight <file> to check bundle size before uploading."
496
+ ].join("\n")
497
+ };
498
+ }
499
+ if (status === 400 && (errorMsg.includes("invalid bundle") || errorMsg.includes("invalid apk") || errorMsg.includes("unable to parse") || errorMsg.includes("malformed apk") || errorMsg.includes("malformed bundle"))) {
500
+ return {
501
+ code: "API_INVALID_BUNDLE",
502
+ message: "Google Play rejected the uploaded file as invalid or malformed.",
503
+ suggestion: [
504
+ "Ensure the file is a properly signed AAB or APK.",
505
+ "Common causes: corrupted file, unsigned bundle, wrong file format.",
506
+ "Run gpc preflight <file> for offline validation.",
507
+ "Rebuild with: ./gradlew bundleRelease"
508
+ ].join("\n")
509
+ };
510
+ }
511
+ if (status === 404 && errorMsg.includes("track") && (errorMsg.includes("not found") || errorMsg.includes("does not exist"))) {
512
+ return {
513
+ code: "API_TRACK_NOT_FOUND",
514
+ message: "The specified track does not exist for this app.",
515
+ suggestion: [
516
+ "Built-in tracks: internal, alpha, beta, production.",
517
+ "List custom tracks with: gpc tracks list",
518
+ "Create a custom track with: gpc tracks create <name>"
519
+ ].join("\n")
520
+ };
521
+ }
522
+ if (status === 400 && errorMsg.includes("release notes") && (errorMsg.includes("too long") || errorMsg.includes("character limit"))) {
523
+ return {
524
+ code: "API_RELEASE_NOTES_TOO_LONG",
525
+ message: "Release notes exceed the 500-character limit.",
526
+ suggestion: [
527
+ "Shorten the release notes to 500 characters or fewer per language.",
528
+ "Preview current notes with: gpc releases notes get --track <track>"
529
+ ].join("\n")
530
+ };
531
+ }
532
+ if (status === 400 && (errorMsg.includes("cannot change rollout") || errorMsg.includes("release") && errorMsg.includes("already completed"))) {
533
+ return {
534
+ code: "API_ROLLOUT_ALREADY_COMPLETED",
535
+ message: "The release is already at full rollout (100%) and cannot be changed.",
536
+ suggestion: [
537
+ "A completed release cannot have its rollout percentage modified.",
538
+ "To deploy a new version: gpc releases upload --track <track>"
539
+ ].join("\n")
540
+ };
541
+ }
542
+ if (status === 400 && errorMsg.includes("edit") && (errorMsg.includes("expired") || errorMsg.includes("failed_precondition"))) {
543
+ return {
544
+ code: "API_EDIT_EXPIRED",
545
+ message: "The edit session has expired.",
546
+ suggestion: [
547
+ "Edit sessions last about 1 hour.",
548
+ "Retry the operation \u2014 GPC will open a fresh edit automatically."
549
+ ].join("\n")
550
+ };
551
+ }
552
+ return void 0;
553
+ }
398
554
  function mapStatusToError(status, body) {
555
+ const enhanced = enhanceApiError(status, body);
556
+ if (enhanced) return enhanced;
399
557
  switch (status) {
400
- case 400: {
401
- try {
402
- const parsed = JSON.parse(body);
403
- if (parsed?.error?.status === "FAILED_PRECONDITION" && parsed.error.message?.toLowerCase().includes("edit")) {
404
- return {
405
- code: "API_EDIT_EXPIRED",
406
- suggestion: "The edit session has expired. Retry the operation to open a fresh edit."
407
- };
408
- }
409
- } catch {
410
- }
558
+ case 400:
411
559
  return { code: "API_HTTP_400", suggestion: "Check request parameters and try again." };
412
- }
413
560
  case 401:
414
561
  return {
415
562
  code: "API_UNAUTHORIZED",
416
- suggestion: "Check that your access token is valid and not expired."
563
+ suggestion: "Check that your access token is valid and not expired. Run: gpc doctor"
417
564
  };
418
565
  case 403:
419
566
  return {
420
567
  code: "API_FORBIDDEN",
421
- suggestion: "Ensure the service account has the required permissions for this operation."
568
+ suggestion: "Ensure the service account has the required permissions. Run: gpc doctor"
422
569
  };
423
570
  case 404:
424
571
  return {
425
572
  code: "API_NOT_FOUND",
426
- suggestion: "Verify the package name and resource IDs are correct."
573
+ suggestion: "Verify the package name and resource IDs are correct. Run: gpc apps list"
427
574
  };
428
- case 409:
575
+ case 413:
429
576
  return {
430
- code: "API_EDIT_CONFLICT",
431
- suggestion: "Another edit may be in progress. Delete the existing edit and retry."
577
+ code: "API_BUNDLE_TOO_LARGE",
578
+ suggestion: "The uploaded file is too large. AAB limit: 2 GB, APK limit: 1 GB."
432
579
  };
433
580
  case 429:
434
581
  return {
435
582
  code: "API_RATE_LIMITED",
436
- suggestion: "Too many requests. The client will retry automatically."
583
+ suggestion: "Too many requests. GPC will retry automatically."
437
584
  };
438
585
  default:
439
586
  if (status >= 500) {
440
587
  return {
441
588
  code: "API_SERVER_ERROR",
442
- suggestion: "Google Play API server error. The client will retry automatically."
589
+ suggestion: "Google Play API server error. GPC will retry automatically."
443
590
  };
444
591
  }
445
592
  return { code: `API_HTTP_${status}` };
@@ -498,12 +645,12 @@ function createHttpClient(options) {
498
645
  return { data, status: response.status };
499
646
  }
500
647
  const errorBody = await response.text();
501
- const { code, suggestion } = mapStatusToError(response.status, errorBody);
648
+ const mapped = mapStatusToError(response.status, errorBody);
502
649
  const err = new PlayApiError(
503
- `${method} ${path} failed with status ${response.status}: ${sanitizeErrorBody(errorBody)}`,
504
- code,
650
+ mapped.message ?? `${method} ${path} failed with status ${response.status}: ${sanitizeErrorBody(errorBody)}`,
651
+ mapped.code,
505
652
  response.status,
506
- suggestion
653
+ mapped.suggestion
507
654
  );
508
655
  if (isRetryable(response.status) && attempt < maxRetries) {
509
656
  lastError = err;
@@ -619,12 +766,12 @@ function createHttpClient(options) {
619
766
  return { data, status: response.status };
620
767
  }
621
768
  const errorBody = await response.text();
622
- const { code, suggestion } = mapStatusToError(response.status, errorBody);
769
+ const mapped = mapStatusToError(response.status, errorBody);
623
770
  const err = new PlayApiError(
624
- `POST upload ${path} failed with status ${response.status}: ${sanitizeErrorBody(errorBody)}`,
625
- code,
771
+ mapped.message ?? `Upload failed with status ${response.status}: ${sanitizeErrorBody(errorBody)}`,
772
+ mapped.code,
626
773
  response.status,
627
- suggestion
774
+ mapped.suggestion
628
775
  );
629
776
  if (isRetryable(response.status) && attempt < maxRetries) {
630
777
  lastError = err;
@@ -779,12 +926,12 @@ function createHttpClient(options) {
779
926
  });
780
927
  if (!response.ok) {
781
928
  const errorBody = await response.text();
782
- const { code, suggestion } = mapStatusToError(response.status, errorBody);
929
+ const mapped = mapStatusToError(response.status, errorBody);
783
930
  throw new PlayApiError(
784
- `GET ${path} failed with status ${response.status}: ${sanitizeErrorBody(errorBody)}`,
785
- code,
931
+ mapped.message ?? `GET ${path} failed with status ${response.status}: ${sanitizeErrorBody(errorBody)}`,
932
+ mapped.code,
786
933
  response.status,
787
- suggestion
934
+ mapped.suggestion
788
935
  );
789
936
  }
790
937
  return await response.arrayBuffer();
@@ -1235,6 +1382,25 @@ function createApiClient(options) {
1235
1382
  async refundSubscriptionV2(packageName, token) {
1236
1383
  await http.post(`/${packageName}/purchases/subscriptionsv2/tokens/${token}:refund`);
1237
1384
  },
1385
+ async cancelSubscriptionV2(packageName, token, body) {
1386
+ await http.post(
1387
+ `/${packageName}/purchases/subscriptionsv2/tokens/${token}:cancel`,
1388
+ body
1389
+ );
1390
+ },
1391
+ async deferSubscriptionV2(packageName, token, body) {
1392
+ const { data } = await http.post(
1393
+ `/${packageName}/purchases/subscriptionsv2/tokens/${token}:defer`,
1394
+ body
1395
+ );
1396
+ return data;
1397
+ },
1398
+ async getProductV2(packageName, token) {
1399
+ const { data } = await http.get(
1400
+ `/${packageName}/purchases/productsv2/tokens/${token}`
1401
+ );
1402
+ return data;
1403
+ },
1238
1404
  async listVoided(packageName, options2) {
1239
1405
  await rateLimit(limiter, "voidedBurst");
1240
1406
  await rateLimit(limiter, "voidedDaily");
@@ -1252,6 +1418,19 @@ function createApiClient(options) {
1252
1418
  }
1253
1419
  },
1254
1420
  orders: {
1421
+ async get(packageName, orderId) {
1422
+ const { data } = await http.get(
1423
+ `/${packageName}/orders/${orderId}`
1424
+ );
1425
+ return data;
1426
+ },
1427
+ async batchGet(packageName, orderIds) {
1428
+ const { data } = await http.post(
1429
+ `/${packageName}/orders:batchGet`,
1430
+ { orderIds }
1431
+ );
1432
+ return data.orders || [];
1433
+ },
1255
1434
  async refund(packageName, orderId, body) {
1256
1435
  await http.post(`/${packageName}/orders/${orderId}:refund`, body);
1257
1436
  }
@@ -1686,23 +1865,26 @@ function createUsersClient(options) {
1686
1865
  var GAMES_BASE_URL = "https://games.googleapis.com/games/v1";
1687
1866
  function createGamesClient(options) {
1688
1867
  const http = createHttpClient({ ...options, baseUrl: GAMES_BASE_URL });
1868
+ function qs(params) {
1869
+ return new URLSearchParams(params).toString();
1870
+ }
1689
1871
  return {
1690
1872
  leaderboards: {
1691
1873
  async list(packageName) {
1692
1874
  const { data } = await http.get(
1693
- `/leaderboards?applicationId=${packageName}`
1875
+ `/leaderboards?${qs({ applicationId: packageName })}`
1694
1876
  );
1695
1877
  return data;
1696
1878
  },
1697
1879
  async get(packageName, leaderboardId) {
1698
1880
  const { data } = await http.get(
1699
- `/leaderboards/${leaderboardId}?applicationId=${packageName}`
1881
+ `/leaderboards/${encodeURIComponent(leaderboardId)}?${qs({ applicationId: packageName })}`
1700
1882
  );
1701
1883
  return data;
1702
1884
  },
1703
1885
  async getScores(packageName, leaderboardId, collection, timeSpan) {
1704
1886
  const { data } = await http.get(
1705
- `/leaderboards/${leaderboardId}/scores/${collection}?timeSpan=${timeSpan}&applicationId=${packageName}`
1887
+ `/leaderboards/${encodeURIComponent(leaderboardId)}/scores/${encodeURIComponent(collection)}?${qs({ timeSpan, applicationId: packageName })}`
1706
1888
  );
1707
1889
  return data;
1708
1890
  }
@@ -1710,13 +1892,13 @@ function createGamesClient(options) {
1710
1892
  achievements: {
1711
1893
  async list(packageName) {
1712
1894
  const { data } = await http.get(
1713
- `/achievements?applicationId=${packageName}`
1895
+ `/achievements?${qs({ applicationId: packageName })}`
1714
1896
  );
1715
1897
  return data;
1716
1898
  },
1717
1899
  async reveal(packageName, achievementId) {
1718
1900
  const { data } = await http.post(
1719
- `/achievements/${achievementId}/reveal?applicationId=${packageName}`,
1901
+ `/achievements/${encodeURIComponent(achievementId)}/reveal?${qs({ applicationId: packageName })}`,
1720
1902
  {}
1721
1903
  );
1722
1904
  return data;
@@ -1725,7 +1907,7 @@ function createGamesClient(options) {
1725
1907
  events: {
1726
1908
  async list(packageName) {
1727
1909
  const { data } = await http.get(
1728
- `/events?applicationId=${packageName}`
1910
+ `/events?${qs({ applicationId: packageName })}`
1729
1911
  );
1730
1912
  return data;
1731
1913
  }