@gpc-cli/core 0.10.5 → 0.10.7

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.js CHANGED
@@ -2315,28 +2315,18 @@ function reviewsToCsv(reviews) {
2315
2315
  const uc = r.comments?.[0]?.userComment;
2316
2316
  const fields = [
2317
2317
  r.reviewId,
2318
- csvEscape(r.authorName),
2318
+ escapeCsvField(r.authorName),
2319
2319
  uc?.starRating ?? "",
2320
- csvEscape(uc?.text ?? ""),
2320
+ escapeCsvField(uc?.text ?? ""),
2321
2321
  uc?.reviewerLanguage ?? "",
2322
2322
  uc ? new Date(Number(uc.lastModified.seconds) * 1e3).toISOString() : "",
2323
- csvEscape(uc?.device ?? ""),
2324
- csvEscape(uc?.appVersionName ?? "")
2323
+ escapeCsvField(uc?.device ?? ""),
2324
+ escapeCsvField(uc?.appVersionName ?? "")
2325
2325
  ];
2326
2326
  return fields.join(",");
2327
2327
  });
2328
2328
  return [header, ...rows].join("\n");
2329
2329
  }
2330
- function csvEscape(value) {
2331
- let safe = value;
2332
- if (/^[=+\-@\t\r]/.test(safe)) {
2333
- safe = `'${safe}`;
2334
- }
2335
- if (safe.includes(",") || safe.includes('"') || safe.includes("\n")) {
2336
- return `"${safe.replace(/"/g, '""')}"`;
2337
- }
2338
- return safe;
2339
- }
2340
2330
  async function analyzeReviews2(client, packageName, options) {
2341
2331
  const { reviews } = await listReviews(client, packageName, options);
2342
2332
  const items = reviews.map((r) => {
@@ -2696,6 +2686,100 @@ var METRIC_SET_METRICS = {
2696
2686
  ],
2697
2687
  errorCountMetricSet: ["errorReportCount", "distinctUsers"]
2698
2688
  };
2689
+ var REQUIRED_DIMENSIONS = {
2690
+ slowStartRateMetricSet: ["startType"],
2691
+ errorCountMetricSet: ["reportType"]
2692
+ };
2693
+ function withRequiredDimensions(metricSet, query) {
2694
+ const required = REQUIRED_DIMENSIONS[metricSet];
2695
+ if (!required) return query;
2696
+ const dimensions = [...required];
2697
+ for (const dim2 of query.dimensions ?? []) {
2698
+ if (!dimensions.includes(dim2)) dimensions.push(dim2);
2699
+ }
2700
+ return { ...query, dimensions };
2701
+ }
2702
+ function readMetricValue(row, metric) {
2703
+ const metrics = row?.metrics;
2704
+ if (!metrics || typeof metrics !== "object") return void 0;
2705
+ let entry;
2706
+ if (Array.isArray(metrics)) {
2707
+ entry = metric ? metrics.find((m) => m?.metric === metric) : metrics[0];
2708
+ } else {
2709
+ const map = metrics;
2710
+ const key = metric ?? Object.keys(map)[0];
2711
+ entry = key ? map[key] : void 0;
2712
+ }
2713
+ const raw = entry?.decimalValue?.value;
2714
+ if (raw === void 0) return void 0;
2715
+ const value = Number(raw);
2716
+ return Number.isFinite(value) ? value : void 0;
2717
+ }
2718
+ function rowDateKey(row) {
2719
+ const t = row.startTime;
2720
+ if (!t) return "";
2721
+ const pad = (n) => String(n ?? 0).padStart(2, "0");
2722
+ return `${t.year ?? 0}-${pad(t.month)}-${pad(t.day)}T${pad(t.hours)}`;
2723
+ }
2724
+ function rowSliceKey(row) {
2725
+ const dims = row.dimensions;
2726
+ if (!dims || typeof dims !== "object") return "";
2727
+ const pairs = Array.isArray(dims) ? dims.map((d) => [d?.dimension ?? "", d?.stringValue ?? d?.int64Value ?? ""]) : Object.entries(dims).map(([k, d]) => [
2728
+ k,
2729
+ d?.stringValue ?? d?.int64Value ?? ""
2730
+ ]);
2731
+ return JSON.stringify(pairs.sort());
2732
+ }
2733
+ function latestWorstValue(rows, metric) {
2734
+ if (!rows || rows.length === 0) return void 0;
2735
+ const latest = rows.map(rowDateKey).reduce((a, b) => b > a ? b : a);
2736
+ let worst;
2737
+ for (const row of rows) {
2738
+ if (rowDateKey(row) !== latest) continue;
2739
+ const value = readMetricValue(row, metric);
2740
+ if (value !== void 0 && (worst === void 0 || value > worst)) worst = value;
2741
+ }
2742
+ return worst;
2743
+ }
2744
+ function worstSliceAverage(rows, metric) {
2745
+ const sums = /* @__PURE__ */ new Map();
2746
+ for (const row of rows ?? []) {
2747
+ const value = readMetricValue(row, metric);
2748
+ if (value === void 0) continue;
2749
+ const key = rowSliceKey(row);
2750
+ const entry = sums.get(key) ?? { total: 0, count: 0 };
2751
+ entry.total += value;
2752
+ entry.count++;
2753
+ sums.set(key, entry);
2754
+ }
2755
+ let worst;
2756
+ for (const { total, count } of sums.values()) {
2757
+ const avg = total / count;
2758
+ if (worst === void 0 || avg > worst) worst = avg;
2759
+ }
2760
+ return worst;
2761
+ }
2762
+ function assertRateThreshold(label, threshold) {
2763
+ if (!Number.isFinite(threshold) || threshold < 0) {
2764
+ throw new GpcError(
2765
+ `Invalid ${label} threshold: expected a fraction such as 0.02.`,
2766
+ "THRESHOLD_INVALID",
2767
+ 2,
2768
+ "Write rates as fractions: 0.02 means 2%."
2769
+ );
2770
+ }
2771
+ if (threshold >= 1) {
2772
+ throw new GpcError(
2773
+ `${label} threshold ${threshold} can never be reached: rates are between 0 and 1.`,
2774
+ "THRESHOLD_INVALID",
2775
+ 2,
2776
+ `Write rates as fractions: ${threshold / 100} means ${threshold}%. Check vitals.thresholds in your config too.`
2777
+ );
2778
+ }
2779
+ }
2780
+ function isGamesOnlyMetricError(err) {
2781
+ return err?.code === "API_METRIC_GAMES_ONLY";
2782
+ }
2699
2783
  async function getFreshnessEndDate(reporting, packageName, metricSet, aggregation = "DAILY") {
2700
2784
  try {
2701
2785
  const info = await reporting.getMetricSetFreshness(packageName, metricSet);
@@ -2709,12 +2793,16 @@ async function getFreshnessEndDate(reporting, packageName, metricSet, aggregatio
2709
2793
  }
2710
2794
  return void 0;
2711
2795
  }
2796
+ async function latestQueryableEndDate(reporting, packageName, metricSet) {
2797
+ const fallback = new Date(Date.now() - 2 * 24 * 60 * 60 * 1e3);
2798
+ const freshnessEnd = await getFreshnessEndDate(reporting, packageName, metricSet);
2799
+ return freshnessEnd ?? fallback;
2800
+ }
2712
2801
  function buildQuery(metricSet, options, freshnessEnd) {
2713
2802
  const metrics = METRIC_SET_METRICS[metricSet] ?? ["errorReportCount", "distinctUsers"];
2714
2803
  const days = options?.days ?? 30;
2715
2804
  const DAY_MS = 24 * 60 * 60 * 1e3;
2716
- const yesterday = new Date(Date.now() - DAY_MS);
2717
- const end = freshnessEnd && freshnessEnd < yesterday ? freshnessEnd : yesterday;
2805
+ const end = freshnessEnd ?? new Date(Date.now() - 2 * DAY_MS);
2718
2806
  const start = new Date(end.getTime() - days * DAY_MS);
2719
2807
  const query = {
2720
2808
  metrics,
@@ -2735,7 +2823,7 @@ function buildQuery(metricSet, options, freshnessEnd) {
2735
2823
  if (options?.dimension) {
2736
2824
  query.dimensions = [options.dimension];
2737
2825
  }
2738
- return query;
2826
+ return withRequiredDimensions(metricSet, query);
2739
2827
  }
2740
2828
  async function queryMetric(reporting, packageName, metricSet, options) {
2741
2829
  const freshnessEnd = await getFreshnessEndDate(
@@ -2771,16 +2859,29 @@ async function getVitalsOverview(reporting, packageName) {
2771
2859
  })
2772
2860
  );
2773
2861
  const overview = {};
2862
+ const unavailable = [];
2863
+ let firstFailure;
2864
+ let succeeded = 0;
2774
2865
  for (let i = 0; i < metricSets.length; i++) {
2775
2866
  const entry = metricSets[i];
2776
- if (!entry) continue;
2777
- const key = entry[1];
2778
2867
  const result = results[i];
2779
- if (!result) continue;
2868
+ if (!entry || !result) continue;
2869
+ const key = entry[1];
2780
2870
  if (result.status === "fulfilled") {
2781
2871
  overview[key] = result.value.rows || [];
2872
+ succeeded++;
2873
+ continue;
2782
2874
  }
2875
+ const err = result.reason;
2876
+ if (!isGamesOnlyMetricError(err)) firstFailure ??= result.reason;
2877
+ unavailable.push({
2878
+ metric: key,
2879
+ code: typeof err?.code === "string" ? err.code : "UNKNOWN_ERROR",
2880
+ reason: isGamesOnlyMetricError(err) ? "Only available for apps categorised as games." : typeof err?.message === "string" ? err.message : String(result.reason)
2881
+ });
2783
2882
  }
2883
+ if (succeeded === 0 && firstFailure !== void 0) throw firstFailure;
2884
+ if (unavailable.length > 0) overview.unavailable = unavailable;
2784
2885
  return overview;
2785
2886
  }
2786
2887
  async function getVitalsCrashes(reporting, packageName, options) {
@@ -2790,8 +2891,7 @@ async function getVitalsAnr(reporting, packageName, options) {
2790
2891
  return queryMetric(reporting, packageName, "anrRateMetricSet", options);
2791
2892
  }
2792
2893
  async function getVitalsStartup(reporting, packageName, options) {
2793
- const opts = options?.dimension ? options : { ...options, dimension: "startType" };
2794
- return queryMetric(reporting, packageName, "slowStartRateMetricSet", opts);
2894
+ return queryMetric(reporting, packageName, "slowStartRateMetricSet", options);
2795
2895
  }
2796
2896
  async function getVitalsRendering(reporting, packageName, options) {
2797
2897
  return queryMetric(reporting, packageName, "slowRenderingRateMetricSet", options);
@@ -2821,8 +2921,7 @@ async function getVitalsMemoryBitmap(reporting, packageName, options) {
2821
2921
  });
2822
2922
  }
2823
2923
  async function getVitalsErrorCount(reporting, packageName, options) {
2824
- const opts = options?.dimension ? options : { ...options, dimension: "reportType" };
2825
- return queryMetric(reporting, packageName, "errorCountMetricSet", opts);
2924
+ return queryMetric(reporting, packageName, "errorCountMetricSet", options);
2826
2925
  }
2827
2926
  async function getVitalsAnomalies(reporting, packageName) {
2828
2927
  return reporting.getAnomalies(packageName);
@@ -2846,7 +2945,7 @@ async function compareVitalsTrend(reporting, packageName, metricSet, days = 7) {
2846
2945
  month: d.getUTCMonth() + 1,
2847
2946
  day: d.getUTCDate()
2848
2947
  });
2849
- const makeQuery = (start, end) => ({
2948
+ const makeQuery = (start, end) => withRequiredDimensions(metricSet, {
2850
2949
  metrics,
2851
2950
  timelineSpec: {
2852
2951
  aggregationPeriod: "DAILY",
@@ -2950,7 +3049,8 @@ function watchVitalsWithAutoHalt(reporting, packageName, options) {
2950
3049
  threshold,
2951
3050
  metricSet = "crashRateMetricSet",
2952
3051
  onHalt,
2953
- onPoll
3052
+ onPoll,
3053
+ onError
2954
3054
  } = options;
2955
3055
  let stopped = false;
2956
3056
  let haltTriggered = false;
@@ -2958,16 +3058,15 @@ function watchVitalsWithAutoHalt(reporting, packageName, options) {
2958
3058
  if (stopped) return;
2959
3059
  try {
2960
3060
  const result = await queryMetric(reporting, packageName, metricSet, { days: 1 });
2961
- const latestRow = result.rows?.[result.rows.length - 1];
2962
- const firstMetric = latestRow?.metrics ? Object.keys(latestRow.metrics)[0] : void 0;
2963
- const value = firstMetric ? Number(latestRow?.metrics[firstMetric]?.decimalValue?.value) : void 0;
3061
+ const value = latestWorstValue(result.rows, METRIC_SET_METRICS[metricSet]?.[0]);
2964
3062
  const breached = value !== void 0 && value > threshold;
2965
3063
  onPoll?.(value, breached);
2966
3064
  if (breached && !haltTriggered && onHalt) {
2967
3065
  haltTriggered = true;
2968
3066
  await onHalt(value);
2969
3067
  }
2970
- } catch {
3068
+ } catch (err) {
3069
+ onError?.(err);
2971
3070
  }
2972
3071
  if (!stopped) {
2973
3072
  timerId = setTimeout(poll, intervalMs);
@@ -3033,33 +3132,28 @@ var WARN_MARGIN = 0.2;
3033
3132
  function toApiDate(d) {
3034
3133
  return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
3035
3134
  }
3036
- async function queryVitalForStatus(reporting, packageName, metricSet, days, offsetDays = 0) {
3135
+ async function queryVitalForStatus(reporting, packageName, metricSet, days, latestEnd, offsetDays = 0) {
3037
3136
  const DAY_MS = 24 * 60 * 60 * 1e3;
3038
- const baseMs = Date.now() - 2 * DAY_MS - offsetDays * DAY_MS;
3137
+ const baseMs = latestEnd.getTime() - offsetDays * DAY_MS;
3039
3138
  const end = new Date(baseMs);
3040
3139
  const start = new Date(baseMs - days * DAY_MS);
3041
3140
  const metrics = METRIC_SET_METRICS2[metricSet] ?? ["distinctUsers"];
3042
- const query = {
3141
+ const query = withRequiredDimensions(metricSet, {
3043
3142
  metrics,
3044
3143
  timelineSpec: {
3045
3144
  aggregationPeriod: "DAILY",
3046
3145
  startTime: toApiDate(start),
3047
3146
  endTime: toApiDate(end)
3048
3147
  }
3049
- };
3148
+ });
3050
3149
  const result = await reporting.queryMetricSet(packageName, metricSet, query);
3051
- if (!result.rows || result.rows.length === 0) return void 0;
3052
- const values = result.rows.map((row) => {
3053
- const firstKey = Object.keys(row.metrics)[0];
3054
- return firstKey ? Number(row.metrics[firstKey]?.decimalValue?.value) : NaN;
3055
- }).filter((v) => !isNaN(v));
3056
- if (values.length === 0) return void 0;
3057
- return values.reduce((a, b) => a + b, 0) / values.length;
3150
+ return worstSliceAverage(result.rows, metrics[0]);
3058
3151
  }
3059
3152
  async function queryVitalWithTrend(reporting, packageName, metricSet, days) {
3153
+ const latestEnd = await latestQueryableEndDate(reporting, packageName, metricSet);
3060
3154
  const [current, previous] = await Promise.all([
3061
- queryVitalForStatus(reporting, packageName, metricSet, days, 0),
3062
- queryVitalForStatus(reporting, packageName, metricSet, days, days)
3155
+ queryVitalForStatus(reporting, packageName, metricSet, days, latestEnd, 0),
3156
+ queryVitalForStatus(reporting, packageName, metricSet, days, latestEnd, days)
3063
3157
  ]);
3064
3158
  let trend = null;
3065
3159
  if (current !== void 0 && previous !== void 0) {
@@ -3157,7 +3251,7 @@ async function getAppStatus(client, reporting, packageName, options = {}) {
3157
3251
  if (slowStartResult.status === "rejected")
3158
3252
  sectionErrors.push(`vitals/slowStarts: ${String(slowStartResult.reason)}`);
3159
3253
  const slowRender = slowRenderResult.status === "fulfilled" ? slowRenderResult.value : SKIPPED_VITAL;
3160
- if (slowRenderResult.status === "rejected")
3254
+ if (slowRenderResult.status === "rejected" && !isGamesOnlyMetricError(slowRenderResult.reason))
3161
3255
  sectionErrors.push(`vitals/slowRender: ${String(slowRenderResult.reason)}`);
3162
3256
  const rawReviews = reviewsResult.status === "fulfilled" ? reviewsResult.value.reviews : [];
3163
3257
  if (reviewsResult.status === "rejected")
@@ -3538,31 +3632,34 @@ async function queryWatchMetric(reporting, packageName, metric) {
3538
3632
  const metricSet = METRIC_TO_SET[metric];
3539
3633
  const metricKey = METRIC_KEY[metric];
3540
3634
  const DAY_MS = 24 * 60 * 60 * 1e3;
3541
- const end = new Date(Date.now() - 2 * DAY_MS);
3542
- const start = new Date(end.getTime() - 7 * DAY_MS);
3543
3635
  const toDate = (d) => ({
3544
3636
  year: d.getUTCFullYear(),
3545
3637
  month: d.getUTCMonth() + 1,
3546
3638
  day: d.getUTCDate()
3547
3639
  });
3548
3640
  try {
3549
- const result = await reporting.queryMetricSet(packageName, metricSet, {
3550
- metrics: [metricKey, "distinctUsers"],
3551
- timelineSpec: {
3552
- aggregationPeriod: "DAILY",
3553
- startTime: toDate(start),
3554
- endTime: toDate(end)
3555
- }
3556
- });
3557
- if (!result.rows || result.rows.length === 0) return void 0;
3558
- const values = result.rows.map((row) => {
3559
- const val = row.metrics[metricKey]?.decimalValue?.value;
3560
- return val !== void 0 ? Number(val) : NaN;
3561
- }).filter((v) => !isNaN(v));
3562
- if (values.length === 0) return void 0;
3563
- return values.reduce((a, b) => a + b, 0) / values.length;
3564
- } catch {
3565
- return void 0;
3641
+ const end = await latestQueryableEndDate(reporting, packageName, metricSet);
3642
+ const start = new Date(end.getTime() - 7 * DAY_MS);
3643
+ const result = await reporting.queryMetricSet(
3644
+ packageName,
3645
+ metricSet,
3646
+ withRequiredDimensions(metricSet, {
3647
+ metrics: [metricKey, "distinctUsers"],
3648
+ timelineSpec: {
3649
+ aggregationPeriod: "DAILY",
3650
+ startTime: toDate(start),
3651
+ endTime: toDate(end)
3652
+ }
3653
+ })
3654
+ );
3655
+ return { value: latestWorstValue(result.rows, metricKey) };
3656
+ } catch (err) {
3657
+ const code = err?.code;
3658
+ return {
3659
+ value: void 0,
3660
+ error: typeof code === "string" ? code : err instanceof Error ? err.message : String(err),
3661
+ gamesOnly: isGamesOnlyMetricError(err)
3662
+ };
3566
3663
  }
3567
3664
  }
3568
3665
  async function fetchRollout(client, packageName, track) {
@@ -3641,12 +3738,17 @@ async function runWatch(client, reporting, config, callbacks) {
3641
3738
  "Watch interval must be at least 60 seconds",
3642
3739
  "WATCH_USAGE_ERROR",
3643
3740
  2,
3644
- "Use --interval 60 or higher. Google vitals data is delayed 24-48h, so frequent polling wastes API quota."
3741
+ "Use --interval 60 or higher. Google vitals data is delayed 2-4 days, so frequent polling wastes API quota."
3645
3742
  );
3646
3743
  }
3744
+ for (const [metric, threshold] of Object.entries(config.thresholds)) {
3745
+ if (metric !== "errorCount" && threshold !== void 0) assertRateThreshold(metric, threshold);
3746
+ }
3647
3747
  let running = true;
3648
3748
  const events = [];
3649
3749
  let breachCount = 0;
3750
+ let unreadableRounds = 0;
3751
+ let lastRoundUnreadable = false;
3650
3752
  let halted = false;
3651
3753
  const startTime = Date.now();
3652
3754
  const cleanup = () => {
@@ -3662,19 +3764,24 @@ async function runWatch(client, reporting, config, callbacks) {
3662
3764
  const vitals = {};
3663
3765
  const metricResults = await Promise.all(
3664
3766
  config.metrics.map(async (metric) => {
3665
- const value = await queryWatchMetric(reporting, config.packageName, metric);
3666
- return { metric, value };
3767
+ const reading = await queryWatchMetric(reporting, config.packageName, metric);
3768
+ return { metric, ...reading };
3667
3769
  })
3668
3770
  );
3669
- for (const { metric, value } of metricResults) {
3771
+ let unreadable = false;
3772
+ for (const { metric, value, error, gamesOnly } of metricResults) {
3670
3773
  const threshold = config.thresholds[metric];
3774
+ if (error && threshold !== void 0 && !gamesOnly) unreadable = true;
3671
3775
  const result = threshold !== void 0 ? checkThreshold(value, threshold) : { breached: false };
3672
3776
  vitals[metric] = {
3673
3777
  value,
3674
3778
  threshold,
3675
- breached: result.breached
3779
+ breached: result.breached,
3780
+ ...error ? { error } : {}
3676
3781
  };
3677
3782
  }
3783
+ if (unreadable) unreadableRounds++;
3784
+ lastRoundUnreadable = unreadable;
3678
3785
  const breaches = Object.entries(vitals).filter(([, v]) => v.breached).map(([k]) => k);
3679
3786
  const event = {
3680
3787
  round,
@@ -3726,6 +3833,8 @@ async function runWatch(client, reporting, config, callbacks) {
3726
3833
  durationMs: Date.now() - startTime,
3727
3834
  finalRollout: events[events.length - 1]?.rollout?.userFraction ?? null,
3728
3835
  breachCount,
3836
+ unreadableRounds,
3837
+ lastRoundUnreadable,
3729
3838
  halted,
3730
3839
  events
3731
3840
  };
@@ -4151,6 +4260,7 @@ function reportObjectNotFoundError(opts) {
4151
4260
  var GCS_API_BASE = "https://storage.googleapis.com/storage/v1/b";
4152
4261
  var LIST_TIMEOUT_MS = 6e4;
4153
4262
  var DOWNLOAD_TIMEOUT_MS = 12e4;
4263
+ var MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024;
4154
4264
  function networkError(bucket, cause) {
4155
4265
  const detail = cause instanceof Error ? cause.message : String(cause);
4156
4266
  return new GpcError(
@@ -4160,6 +4270,14 @@ function networkError(bucket, cause) {
4160
4270
  "Check your internet connection and retry. Google Cloud Storage may also be briefly unavailable."
4161
4271
  );
4162
4272
  }
4273
+ function tooLargeError(objectName, maxBytes) {
4274
+ return new GpcError(
4275
+ `Report object "${objectName}" is larger than the ${maxBytes}-byte download limit.`,
4276
+ "REPORT_DOWNLOAD_FAILED",
4277
+ 4,
4278
+ "Play bulk reports are far smaller than this; confirm the bucket and object name, then download the file from Play Console -> Download reports if you really need it."
4279
+ );
4280
+ }
4163
4281
  function maxRetries() {
4164
4282
  const parsed = Number(process.env["GPC_MAX_RETRIES"]);
4165
4283
  return Number.isInteger(parsed) && parsed >= 0 && parsed <= 10 ? parsed : 3;
@@ -4226,7 +4344,8 @@ async function listReportObjects(auth, bucket, options) {
4226
4344
  }));
4227
4345
  return { objects, nextPageToken: body.nextPageToken };
4228
4346
  }
4229
- async function downloadReportObject(auth, bucket, objectName) {
4347
+ async function downloadReportObject(auth, bucket, objectName, options = {}) {
4348
+ const maxBytes = options.maxBytes ?? MAX_DOWNLOAD_BYTES;
4230
4349
  const url = `${GCS_API_BASE}/${encodeURIComponent(bucket)}/o/${encodeURIComponent(objectName)}?alt=media`;
4231
4350
  const res = await gcsFetch(auth, bucket, url, DOWNLOAD_TIMEOUT_MS);
4232
4351
  if (res.status === 404) {
@@ -4245,11 +4364,25 @@ async function downloadReportObject(auth, bucket, objectName) {
4245
4364
  "Retry in a moment. If the error persists, list the reports to confirm the object still exists."
4246
4365
  );
4247
4366
  }
4367
+ const declared = Number(res.headers.get("content-length"));
4368
+ if (Number.isFinite(declared) && declared > maxBytes) {
4369
+ throw tooLargeError(objectName, maxBytes);
4370
+ }
4371
+ const body = res.body;
4372
+ if (!body) return Buffer.alloc(0);
4373
+ const chunks = [];
4374
+ let read = 0;
4248
4375
  try {
4249
- return Buffer.from(await res.arrayBuffer());
4376
+ for await (const chunk of body) {
4377
+ read += chunk.length;
4378
+ if (read > maxBytes) throw tooLargeError(objectName, maxBytes);
4379
+ chunks.push(Buffer.from(chunk));
4380
+ }
4250
4381
  } catch (err) {
4382
+ if (err instanceof GpcError) throw err;
4251
4383
  throw networkError(bucket, err);
4252
4384
  }
4385
+ return Buffer.concat(chunks);
4253
4386
  }
4254
4387
 
4255
4388
  // src/reports/decode.ts
@@ -4285,13 +4418,15 @@ function decodeStatsCsv(buf) {
4285
4418
  return decodeCsvText(gunzipIfNeeded(buf));
4286
4419
  }
4287
4420
  var ZIP_READ_TIMEOUT_MS = 3e4;
4288
- function extractCsvEntriesFromZip(buffer) {
4421
+ var MAX_CSV_ENTRIES = 64;
4422
+ function extractCsvEntriesFromZip(buffer, options = {}) {
4423
+ const maxEntryBytes = options.maxEntryBytes ?? MAX_INFLATED_BYTES;
4424
+ const maxTotalBytes = options.maxTotalBytes ?? MAX_INFLATED_BYTES;
4289
4425
  return new Promise((resolve2, reject) => {
4290
4426
  let settled = false;
4291
4427
  const timer = setTimeout(() => {
4292
4428
  settleReject(new Error("Timed out reading report archive"));
4293
4429
  }, ZIP_READ_TIMEOUT_MS);
4294
- timer.unref?.();
4295
4430
  function settleResolve(v) {
4296
4431
  if (settled) return;
4297
4432
  settled = true;
@@ -4314,6 +4449,8 @@ function extractCsvEntriesFromZip(buffer) {
4314
4449
  const entries = [];
4315
4450
  let pending = 0;
4316
4451
  let entriesDone = false;
4452
+ let csvEntries = 0;
4453
+ let totalBytes = 0;
4317
4454
  function fail(e) {
4318
4455
  try {
4319
4456
  zipfile.close();
@@ -4329,6 +4466,10 @@ function extractCsvEntriesFromZip(buffer) {
4329
4466
  zipfile.readEntry();
4330
4467
  return;
4331
4468
  }
4469
+ if (++csvEntries > MAX_CSV_ENTRIES) {
4470
+ fail(new Error(`Report archive holds more than ${MAX_CSV_ENTRIES} CSV entries`));
4471
+ return;
4472
+ }
4332
4473
  pending++;
4333
4474
  zipfile.openReadStream(entry, (streamErr, stream) => {
4334
4475
  if (streamErr || !stream) {
@@ -4336,10 +4477,35 @@ function extractCsvEntriesFromZip(buffer) {
4336
4477
  return;
4337
4478
  }
4338
4479
  const chunks = [];
4339
- stream.on("data", (c) => chunks.push(c));
4480
+ let read = 0;
4481
+ stream.on("data", (c) => {
4482
+ read += c.length;
4483
+ if (read > maxEntryBytes) {
4484
+ stream.destroy();
4485
+ fail(
4486
+ new Error(
4487
+ `Entry "${entry.fileName}" exceeds the ${maxEntryBytes}-byte limit for a report CSV`
4488
+ )
4489
+ );
4490
+ return;
4491
+ }
4492
+ chunks.push(c);
4493
+ });
4340
4494
  stream.on("error", fail);
4341
4495
  stream.on("end", () => {
4342
- entries.push({ name: entry.fileName, text: decodeStatsCsv(Buffer.concat(chunks)) });
4496
+ let text;
4497
+ try {
4498
+ text = decodeStatsCsv(Buffer.concat(chunks));
4499
+ } catch (e) {
4500
+ fail(e instanceof Error ? e : new Error(String(e)));
4501
+ return;
4502
+ }
4503
+ totalBytes += text.length;
4504
+ if (totalBytes > maxTotalBytes) {
4505
+ fail(new Error(`Report archive exceeds the ${maxTotalBytes}-byte total limit`));
4506
+ return;
4507
+ }
4508
+ entries.push({ name: entry.fileName, text });
4343
4509
  pending--;
4344
4510
  zipfile.readEntry();
4345
4511
  maybeResolve();
@@ -5364,10 +5530,9 @@ async function advanceTrain(apiClient, reportingClient, packageName) {
5364
5530
  if (state.gates) {
5365
5531
  if (state.gates.crashes?.max !== void 0) {
5366
5532
  const result = await getVitalsCrashes(reportingClient, packageName, { days: 1 });
5367
- const latestRow = result.rows?.[result.rows.length - 1];
5368
- const firstMetric = latestRow?.metrics ? Object.keys(latestRow.metrics)[0] : void 0;
5369
- const value = firstMetric ? Number(latestRow?.metrics[firstMetric]?.decimalValue?.value) : void 0;
5370
- if (value !== void 0 && value > state.gates.crashes.max / 100) {
5533
+ const value = latestWorstValue(result.rows, "crashRate");
5534
+ if (value === void 0) throw trainGateNoData("Crash", "crashes");
5535
+ if (value > state.gates.crashes.max / 100) {
5371
5536
  state.status = "paused";
5372
5537
  state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
5373
5538
  await writeTrainState(packageName, state);
@@ -5381,10 +5546,9 @@ async function advanceTrain(apiClient, reportingClient, packageName) {
5381
5546
  }
5382
5547
  if (state.gates.anr?.max !== void 0) {
5383
5548
  const result = await getVitalsAnr(reportingClient, packageName, { days: 1 });
5384
- const latestRow = result.rows?.[result.rows.length - 1];
5385
- const firstMetric = latestRow?.metrics ? Object.keys(latestRow.metrics)[0] : void 0;
5386
- const value = firstMetric ? Number(latestRow?.metrics[firstMetric]?.decimalValue?.value) : void 0;
5387
- if (value !== void 0 && value > state.gates.anr.max / 100) {
5549
+ const value = latestWorstValue(result.rows, "anrRate");
5550
+ if (value === void 0) throw trainGateNoData("ANR", "anr");
5551
+ if (value > state.gates.anr.max / 100) {
5388
5552
  state.status = "paused";
5389
5553
  state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
5390
5554
  await writeTrainState(packageName, state);
@@ -5416,6 +5580,14 @@ async function executeStage(apiClient, packageName, state, stageIndex) {
5416
5580
  state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
5417
5581
  await writeTrainState(packageName, state);
5418
5582
  }
5583
+ function trainGateNoData(label, command) {
5584
+ return new GpcError(
5585
+ `${label} gate has no data to check, so the train was not advanced.`,
5586
+ "TRAIN_GATE_NO_DATA",
5587
+ 6,
5588
+ `Vitals data often lags 2-4 days. Run the train again later, or check with: gpc vitals ${command} --days 3`
5589
+ );
5590
+ }
5419
5591
 
5420
5592
  // src/commands/games.ts
5421
5593
  import { mkdir as mkdir5, readdir as readdir4, readFile as readFile9, writeFile as writeFile5 } from "fs/promises";
@@ -5458,11 +5630,11 @@ async function getAchievementConfig(client, achievementId) {
5458
5630
  }
5459
5631
  async function createAchievementConfig(client, applicationId, data) {
5460
5632
  validateApplicationId(applicationId);
5461
- return client.achievements.insert(applicationId, data);
5633
+ return client.achievements.insert(applicationId, withoutRedactedToken(data));
5462
5634
  }
5463
5635
  async function updateAchievementConfig(client, achievementId, data) {
5464
5636
  validateResourceId(achievementId, "achievement");
5465
- return client.achievements.update(achievementId, data);
5637
+ return client.achievements.update(achievementId, withoutRedactedToken(data));
5466
5638
  }
5467
5639
  async function deleteAchievementConfig(client, achievementId) {
5468
5640
  validateResourceId(achievementId, "achievement");
@@ -5494,11 +5666,11 @@ async function getLeaderboardConfig(client, leaderboardId) {
5494
5666
  }
5495
5667
  async function createLeaderboardConfig(client, applicationId, data) {
5496
5668
  validateApplicationId(applicationId);
5497
- return client.leaderboards.insert(applicationId, data);
5669
+ return client.leaderboards.insert(applicationId, withoutRedactedToken(data));
5498
5670
  }
5499
5671
  async function updateLeaderboardConfig(client, leaderboardId, data) {
5500
5672
  validateResourceId(leaderboardId, "leaderboard");
5501
- return client.leaderboards.update(leaderboardId, data);
5673
+ return client.leaderboards.update(leaderboardId, withoutRedactedToken(data));
5502
5674
  }
5503
5675
  async function deleteLeaderboardConfig(client, leaderboardId) {
5504
5676
  validateResourceId(leaderboardId, "leaderboard");
@@ -5556,6 +5728,11 @@ async function setLeaderboardIcon(client, leaderboardId, filePath) {
5556
5728
  iconContentType(filePath)
5557
5729
  );
5558
5730
  }
5731
+ function withoutRedactedToken(config) {
5732
+ if (config.token !== REDACTED) return config;
5733
+ const { token: _token, ...rest } = config;
5734
+ return rest;
5735
+ }
5559
5736
  function safeConfigFilename(id) {
5560
5737
  return `${id.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`;
5561
5738
  }
@@ -5583,7 +5760,7 @@ async function readConfigDir(dir) {
5583
5760
  "Each file must contain a single achievement or leaderboard config object."
5584
5761
  );
5585
5762
  }
5586
- out.push({ file, config: parsed });
5763
+ out.push({ file, config: withoutRedactedToken(parsed) });
5587
5764
  }
5588
5765
  return out;
5589
5766
  }
@@ -6293,6 +6470,7 @@ var SO_PATH_RE = /(?:^|\/)lib\/[^/]+\/[^/]+\.so$/;
6293
6470
  var MODULE_MANIFEST_RE = /^([^/]+)\/manifest\/AndroidManifest\.xml$/;
6294
6471
  var MAX_MODULE_MANIFEST_BYTES = 1024 * 1024;
6295
6472
  var MAX_MODULE_MANIFESTS = 256;
6473
+ var MAX_SO_HEADER_READS = 512;
6296
6474
  var SCAN_TIMEOUT_MS = 6e4;
6297
6475
  function detectManifestPath(filePath) {
6298
6476
  return filePath.toLowerCase().endsWith(".apk") ? APK_MANIFEST_PATH : AAB_MANIFEST_PATH;
@@ -6390,6 +6568,7 @@ function openAndScan(buffer, manifestPath = AAB_MANIFEST_PATH) {
6390
6568
  const soHeaders = /* @__PURE__ */ new Map();
6391
6569
  const moduleManifests = /* @__PURE__ */ new Map();
6392
6570
  let moduleManifestReads = 0;
6571
+ let soHeaderReads = 0;
6393
6572
  let manifestBuf = null;
6394
6573
  let pendingStreams = 0;
6395
6574
  let entrysDone = false;
@@ -6406,6 +6585,10 @@ function openAndScan(buffer, manifestPath = AAB_MANIFEST_PATH) {
6406
6585
  settleResolve({ zipfile, entries, manifestBuf, soHeaders, moduleManifests });
6407
6586
  }
6408
6587
  }
6588
+ function advance() {
6589
+ if (settled) return;
6590
+ zipfile.readEntry();
6591
+ }
6409
6592
  function readEntryStream(entry, onData, maxBytes, optional = false) {
6410
6593
  pendingStreams++;
6411
6594
  let done = false;
@@ -6417,6 +6600,7 @@ function openAndScan(buffer, manifestPath = AAB_MANIFEST_PATH) {
6417
6600
  if (done) return;
6418
6601
  done = true;
6419
6602
  pendingStreams--;
6603
+ advance();
6420
6604
  maybeResolve();
6421
6605
  };
6422
6606
  zipfile.openReadStream(entry, (streamErr, stream) => {
@@ -6443,6 +6627,7 @@ function openAndScan(buffer, manifestPath = AAB_MANIFEST_PATH) {
6443
6627
  const buf = Buffer.concat(chunks);
6444
6628
  onData(maxBytes ? buf.subarray(0, maxBytes) : buf);
6445
6629
  pendingStreams--;
6630
+ advance();
6446
6631
  maybeResolve();
6447
6632
  };
6448
6633
  stream.on("end", finish);
@@ -6479,7 +6664,8 @@ function openAndScan(buffer, manifestPath = AAB_MANIFEST_PATH) {
6479
6664
  MAX_MODULE_MANIFEST_BYTES,
6480
6665
  true
6481
6666
  );
6482
- } else if (SO_PATH_RE.test(path)) {
6667
+ } else if (SO_PATH_RE.test(path) && soHeaderReads < MAX_SO_HEADER_READS) {
6668
+ soHeaderReads++;
6483
6669
  readEntryStream(
6484
6670
  entry,
6485
6671
  (buf) => {
@@ -6487,8 +6673,9 @@ function openAndScan(buffer, manifestPath = AAB_MANIFEST_PATH) {
6487
6673
  },
6488
6674
  SO_HEADER_BYTES
6489
6675
  );
6676
+ } else {
6677
+ advance();
6490
6678
  }
6491
- zipfile.readEntry();
6492
6679
  } catch (e) {
6493
6680
  fail(e instanceof Error ? e : new Error(String(e)));
6494
6681
  }
@@ -7044,6 +7231,20 @@ var nativeLibsScanner = {
7044
7231
  });
7045
7232
  }
7046
7233
  check16KBAlignment(ctx.nativeLibHeaders, ctx.manifest, findings);
7234
+ if (ctx.nativeLibHeaders) {
7235
+ const soCount = entries.filter((e) => nativeLibAbi(e.path) && e.path.endsWith(".so")).length;
7236
+ const unchecked = soCount - ctx.nativeLibHeaders.size;
7237
+ if (unchecked > 0) {
7238
+ findings.push({
7239
+ scanner: "native-libs",
7240
+ ruleId: "native-libs-16kb-unchecked",
7241
+ severity: "warning",
7242
+ title: `${unchecked} of ${soCount} native libraries not checked for 16KB alignment`,
7243
+ message: `Preflight checked the first ${ctx.nativeLibHeaders.size} native libraries in this bundle; the remaining ${unchecked} were not inspected, so a misaligned library among them would not be reported.`,
7244
+ suggestion: "Verify alignment of the full bundle with Android Studio's APK Analyzer or `zipalign -c -P 16 -v 4 app.apk` on a universal APK."
7245
+ });
7246
+ }
7247
+ }
7047
7248
  const detectedAbis = KNOWN_ABIS.filter((abi) => abisFound.has(abi));
7048
7249
  const unknownAbis = [...abisFound].filter(
7049
7250
  (abi) => !KNOWN_ABIS.includes(abi)
@@ -9807,7 +10008,7 @@ async function sha256File(filePath) {
9807
10008
  }
9808
10009
 
9809
10010
  // src/commands/image-sync.ts
9810
- import { readdir as readdir7, stat as stat10 } from "fs/promises";
10011
+ import { readdir as readdir7 } from "fs/promises";
9811
10012
  import { join as join13, extname as extname5 } from "path";
9812
10013
  import { PlayApiError } from "@gpc-cli/api";
9813
10014
  var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg"]);
@@ -9822,12 +10023,20 @@ var ALL_IMAGE_TYPES2 = [
9822
10023
  "wearScreenshots"
9823
10024
  ];
9824
10025
  async function scanLocalImages(dir) {
10026
+ let entries;
9825
10027
  try {
9826
- const entries = await readdir7(dir, { withFileTypes: true });
9827
- return entries.filter((e) => e.isFile() && IMAGE_EXTENSIONS.has(extname5(e.name).toLowerCase())).map((e) => e.name).sort();
9828
- } catch {
9829
- return [];
10028
+ entries = await readdir7(dir, { withFileTypes: true });
10029
+ } catch (error) {
10030
+ const code = error.code;
10031
+ if (code === "ENOENT" || code === "ENOTDIR") return null;
10032
+ throw new GpcError(
10033
+ `Cannot read image directory "${dir}": ${code ?? String(error)}`,
10034
+ "IMAGE_SYNC_DIR_UNREADABLE",
10035
+ 1,
10036
+ "Check the directory's permissions (and container volume ownership in CI). Nothing was changed on Google Play."
10037
+ );
9830
10038
  }
10039
+ return entries.filter((e) => e.isFile() && IMAGE_EXTENSIONS.has(extname5(e.name).toLowerCase())).map((e) => e.name).sort();
9831
10040
  }
9832
10041
  async function scanLanguages(dir) {
9833
10042
  try {
@@ -9837,13 +10046,6 @@ async function scanLanguages(dir) {
9837
10046
  return [];
9838
10047
  }
9839
10048
  }
9840
- async function dirExists(path) {
9841
- try {
9842
- return (await stat10(path)).isDirectory();
9843
- } catch {
9844
- return false;
9845
- }
9846
- }
9847
10049
  async function syncImages(client, packageName, dir, options) {
9848
10050
  const details = [];
9849
10051
  let uploaded = 0;
@@ -9864,7 +10066,8 @@ async function syncImages(client, packageName, dir, options) {
9864
10066
  for (const language of languages) {
9865
10067
  for (const imageType of imageTypes) {
9866
10068
  const localDir = join13(dir, language, imageType);
9867
- const localFiles = await scanLocalImages(localDir);
10069
+ const scanned = await scanLocalImages(localDir);
10070
+ const localFiles = scanned ?? [];
9868
10071
  let remoteImages;
9869
10072
  try {
9870
10073
  remoteImages = await client.images.list(packageName, edit.id, language, imageType);
@@ -9906,7 +10109,7 @@ async function syncImages(client, packageName, dir, options) {
9906
10109
  details.push({ language, imageType, file, action: "upload", reason });
9907
10110
  };
9908
10111
  if (options?.delete) {
9909
- if (localFiles.length === 0 && remoteImages.length > 0 && !await dirExists(localDir)) {
10112
+ if (scanned === null && remoteImages.length > 0) {
9910
10113
  for (const img of remoteImages) {
9911
10114
  details.push({
9912
10115
  language,
@@ -10326,6 +10529,7 @@ export {
10326
10529
  analyzeReviews2 as analyzeReviews,
10327
10530
  annotateListResult,
10328
10531
  applyReleaseNotes,
10532
+ assertRateThreshold,
10329
10533
  assignRelease,
10330
10534
  batchGetOrders,
10331
10535
  batchSyncInAppProducts,
@@ -10480,6 +10684,7 @@ export {
10480
10684
  isValidStatsDimension,
10481
10685
  isVersionedNotesDir,
10482
10686
  isZip,
10687
+ latestWorstValue,
10483
10688
  lintListing,
10484
10689
  lintListings,
10485
10690
  lintLocalListings,
@@ -10531,6 +10736,7 @@ export {
10531
10736
  pushLeaderboardConfigs,
10532
10737
  pushListings,
10533
10738
  readListingsFromDir,
10739
+ readMetricValue,
10534
10740
  readReleaseNotesForVersion,
10535
10741
  readReleaseNotesFromDir,
10536
10742
  readinessBadgeColor,
@@ -10620,6 +10826,7 @@ export {
10620
10826
  waitForBundleProcessing,
10621
10827
  watchVitalsWithAutoHalt,
10622
10828
  wordDiff,
10829
+ worstSliceAverage,
10623
10830
  writeAuditLog,
10624
10831
  writeListingsToDir,
10625
10832
  writeMigrationOutput