@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/README.md +26 -21
- package/dist/index.d.ts +54 -3
- package/dist/index.js +310 -103
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
-
|
|
2318
|
+
escapeCsvField(r.authorName),
|
|
2319
2319
|
uc?.starRating ?? "",
|
|
2320
|
-
|
|
2320
|
+
escapeCsvField(uc?.text ?? ""),
|
|
2321
2321
|
uc?.reviewerLanguage ?? "",
|
|
2322
2322
|
uc ? new Date(Number(uc.lastModified.seconds) * 1e3).toISOString() : "",
|
|
2323
|
-
|
|
2324
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
return
|
|
3564
|
-
} catch {
|
|
3565
|
-
|
|
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
|
|
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
|
|
3666
|
-
return { metric,
|
|
3767
|
+
const reading = await queryWatchMetric(reporting, config.packageName, metric);
|
|
3768
|
+
return { metric, ...reading };
|
|
3667
3769
|
})
|
|
3668
3770
|
);
|
|
3669
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
5368
|
-
|
|
5369
|
-
|
|
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
|
|
5385
|
-
|
|
5386
|
-
|
|
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
|
|
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
|
-
|
|
9827
|
-
|
|
9828
|
-
|
|
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
|
|
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 (
|
|
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
|