@gpc-cli/core 0.9.76 → 0.9.78
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 +1 -1
- package/dist/index.d.ts +141 -4
- package/dist/index.js +443 -40
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -3843,6 +3843,255 @@ async function convertRegionPrices(client, packageName, currencyCode, amount) {
|
|
|
3843
3843
|
});
|
|
3844
3844
|
}
|
|
3845
3845
|
|
|
3846
|
+
// src/reports/errors.ts
|
|
3847
|
+
function reportAccessDeniedError(bucket) {
|
|
3848
|
+
return new GpcError(
|
|
3849
|
+
`Access denied reading the Play reports bucket "${bucket}".`,
|
|
3850
|
+
"REPORT_ACCESS_DENIED",
|
|
3851
|
+
4,
|
|
3852
|
+
"Grant the service account access: Play Console -> Users & permissions -> the service account -> Account permissions -> enable 'View app information and download bulk reports (read-only)', then wait a few minutes for it to propagate."
|
|
3853
|
+
);
|
|
3854
|
+
}
|
|
3855
|
+
function reportAuthRejectedError(bucket) {
|
|
3856
|
+
return new GpcError(
|
|
3857
|
+
`Authentication was rejected reading the Play reports bucket "${bucket}".`,
|
|
3858
|
+
"REPORT_AUTH_REJECTED",
|
|
3859
|
+
3,
|
|
3860
|
+
"The access token was not accepted. Check the service account key is valid (gpc auth status) and that your system clock is accurate, then retry."
|
|
3861
|
+
);
|
|
3862
|
+
}
|
|
3863
|
+
function reportBucketNotFoundError(bucket) {
|
|
3864
|
+
return new GpcError(
|
|
3865
|
+
`Play reports bucket "${bucket}" was not found.`,
|
|
3866
|
+
"REPORT_BUCKET_NOT_FOUND",
|
|
3867
|
+
4,
|
|
3868
|
+
"Confirm the bucket name. Copy the exact Cloud Storage URI from Play Console -> Download reports, then set it with --bucket or the 'reports.bucket' config key. The default is pubsite_prod_<developerId>."
|
|
3869
|
+
);
|
|
3870
|
+
}
|
|
3871
|
+
function reportObjectNotFoundError(opts) {
|
|
3872
|
+
const { reportType, month, dimension, available } = opts;
|
|
3873
|
+
const dimPart = dimension ? ` dimension "${dimension}"` : "";
|
|
3874
|
+
const availPart = available && available.length ? ` Available: ${available.join(", ")}.` : " No matching report objects were found for that month.";
|
|
3875
|
+
return new GpcError(
|
|
3876
|
+
`No "${reportType}" report found for ${month}${dimPart}.`,
|
|
3877
|
+
"REPORT_OBJECT_NOT_FOUND",
|
|
3878
|
+
4,
|
|
3879
|
+
`Check the month is complete and the report type/dimension exist.${availPart} Reports for the current month may not be published until it ends.`
|
|
3880
|
+
);
|
|
3881
|
+
}
|
|
3882
|
+
|
|
3883
|
+
// src/reports/gcs.ts
|
|
3884
|
+
var GCS_API_BASE = "https://storage.googleapis.com/storage/v1/b";
|
|
3885
|
+
var LIST_TIMEOUT_MS = 6e4;
|
|
3886
|
+
var DOWNLOAD_TIMEOUT_MS = 12e4;
|
|
3887
|
+
function networkError(bucket, cause) {
|
|
3888
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
3889
|
+
return new GpcError(
|
|
3890
|
+
`Network error reading the Play reports bucket "${bucket}": ${detail}`,
|
|
3891
|
+
"NETWORK_ERROR",
|
|
3892
|
+
5,
|
|
3893
|
+
"Check your internet connection and retry. Google Cloud Storage may also be briefly unavailable."
|
|
3894
|
+
);
|
|
3895
|
+
}
|
|
3896
|
+
function maxRetries() {
|
|
3897
|
+
const parsed = Number(process.env["GPC_MAX_RETRIES"]);
|
|
3898
|
+
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 10 ? parsed : 3;
|
|
3899
|
+
}
|
|
3900
|
+
var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
|
|
3901
|
+
async function gcsFetch(auth, bucket, url, timeoutMs) {
|
|
3902
|
+
const retries = maxRetries();
|
|
3903
|
+
let lastError;
|
|
3904
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
3905
|
+
if (attempt > 0) {
|
|
3906
|
+
await new Promise((r) => setTimeout(r, Math.min(1e3 * 2 ** (attempt - 1), 8e3)));
|
|
3907
|
+
}
|
|
3908
|
+
const token = await auth.getAccessToken();
|
|
3909
|
+
let res;
|
|
3910
|
+
try {
|
|
3911
|
+
res = await fetch(url, {
|
|
3912
|
+
headers: { authorization: `Bearer ${token}` },
|
|
3913
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
3914
|
+
});
|
|
3915
|
+
} catch (err) {
|
|
3916
|
+
lastError = err;
|
|
3917
|
+
continue;
|
|
3918
|
+
}
|
|
3919
|
+
if (RETRYABLE_STATUSES.has(res.status) && attempt < retries) {
|
|
3920
|
+
continue;
|
|
3921
|
+
}
|
|
3922
|
+
if (res.status === 401) {
|
|
3923
|
+
throw reportAuthRejectedError(bucket);
|
|
3924
|
+
}
|
|
3925
|
+
if (res.status === 403) {
|
|
3926
|
+
throw reportAccessDeniedError(bucket);
|
|
3927
|
+
}
|
|
3928
|
+
return res;
|
|
3929
|
+
}
|
|
3930
|
+
throw networkError(bucket, lastError ?? new Error("retries exhausted"));
|
|
3931
|
+
}
|
|
3932
|
+
async function listReportObjects(auth, bucket, options) {
|
|
3933
|
+
const params = new URLSearchParams({ prefix: options.prefix });
|
|
3934
|
+
if (options.maxResults) params.set("maxResults", String(options.maxResults));
|
|
3935
|
+
if (options.pageToken) params.set("pageToken", options.pageToken);
|
|
3936
|
+
const url = `${GCS_API_BASE}/${encodeURIComponent(bucket)}/o?${params.toString()}`;
|
|
3937
|
+
const res = await gcsFetch(auth, bucket, url, LIST_TIMEOUT_MS);
|
|
3938
|
+
if (res.status === 404) {
|
|
3939
|
+
throw reportBucketNotFoundError(bucket);
|
|
3940
|
+
}
|
|
3941
|
+
if (!res.ok) {
|
|
3942
|
+
throw new GpcError(
|
|
3943
|
+
`Google Cloud Storage returned HTTP ${res.status} listing the reports bucket "${bucket}".`,
|
|
3944
|
+
"REPORT_LIST_FAILED",
|
|
3945
|
+
4,
|
|
3946
|
+
"Retry in a moment. If the error persists, verify the bucket name and the service account's report access."
|
|
3947
|
+
);
|
|
3948
|
+
}
|
|
3949
|
+
let body;
|
|
3950
|
+
try {
|
|
3951
|
+
body = await res.json();
|
|
3952
|
+
} catch (err) {
|
|
3953
|
+
throw networkError(bucket, err);
|
|
3954
|
+
}
|
|
3955
|
+
const objects = (body.items ?? []).filter((item) => typeof item.name === "string").map((item) => ({
|
|
3956
|
+
name: item.name,
|
|
3957
|
+
size: Number(item.size ?? 0),
|
|
3958
|
+
updated: item.updated
|
|
3959
|
+
}));
|
|
3960
|
+
return { objects, nextPageToken: body.nextPageToken };
|
|
3961
|
+
}
|
|
3962
|
+
async function downloadReportObject(auth, bucket, objectName) {
|
|
3963
|
+
const url = `${GCS_API_BASE}/${encodeURIComponent(bucket)}/o/${encodeURIComponent(objectName)}?alt=media`;
|
|
3964
|
+
const res = await gcsFetch(auth, bucket, url, DOWNLOAD_TIMEOUT_MS);
|
|
3965
|
+
if (res.status === 404) {
|
|
3966
|
+
throw new GpcError(
|
|
3967
|
+
`Report object "${objectName}" was not found in bucket "${bucket}".`,
|
|
3968
|
+
"REPORT_OBJECT_NOT_FOUND",
|
|
3969
|
+
4,
|
|
3970
|
+
"List available reports first: gpc reports list <report-type> --month <YYYY-MM>"
|
|
3971
|
+
);
|
|
3972
|
+
}
|
|
3973
|
+
if (!res.ok) {
|
|
3974
|
+
throw new GpcError(
|
|
3975
|
+
`Google Cloud Storage returned HTTP ${res.status} downloading "${objectName}".`,
|
|
3976
|
+
"REPORT_DOWNLOAD_FAILED",
|
|
3977
|
+
4,
|
|
3978
|
+
"Retry in a moment. If the error persists, list the reports to confirm the object still exists."
|
|
3979
|
+
);
|
|
3980
|
+
}
|
|
3981
|
+
try {
|
|
3982
|
+
return Buffer.from(await res.arrayBuffer());
|
|
3983
|
+
} catch (err) {
|
|
3984
|
+
throw networkError(bucket, err);
|
|
3985
|
+
}
|
|
3986
|
+
}
|
|
3987
|
+
|
|
3988
|
+
// src/reports/decode.ts
|
|
3989
|
+
import { gunzipSync } from "zlib";
|
|
3990
|
+
import { fromBuffer } from "yauzl";
|
|
3991
|
+
var GZIP_MAGIC = [31, 139];
|
|
3992
|
+
var ZIP_MAGIC = [80, 75];
|
|
3993
|
+
function isGzip(buf) {
|
|
3994
|
+
return buf.length >= 2 && buf[0] === GZIP_MAGIC[0] && buf[1] === GZIP_MAGIC[1];
|
|
3995
|
+
}
|
|
3996
|
+
function isZip(buf) {
|
|
3997
|
+
return buf.length >= 2 && buf[0] === ZIP_MAGIC[0] && buf[1] === ZIP_MAGIC[1];
|
|
3998
|
+
}
|
|
3999
|
+
var MAX_INFLATED_BYTES = 512 * 1024 * 1024;
|
|
4000
|
+
function gunzipIfNeeded(buf) {
|
|
4001
|
+
return isGzip(buf) ? gunzipSync(buf, { maxOutputLength: MAX_INFLATED_BYTES }) : buf;
|
|
4002
|
+
}
|
|
4003
|
+
function decodeCsvText(buf) {
|
|
4004
|
+
if (buf.length >= 2 && buf[0] === 255 && buf[1] === 254) {
|
|
4005
|
+
return buf.subarray(2).toString("utf16le");
|
|
4006
|
+
}
|
|
4007
|
+
if (buf.length >= 2 && buf[0] === 254 && buf[1] === 255) {
|
|
4008
|
+
const swapped = Buffer.from(buf.subarray(2));
|
|
4009
|
+
swapped.swap16();
|
|
4010
|
+
return swapped.toString("utf16le");
|
|
4011
|
+
}
|
|
4012
|
+
if (buf.length >= 3 && buf[0] === 239 && buf[1] === 187 && buf[2] === 191) {
|
|
4013
|
+
return buf.subarray(3).toString("utf8");
|
|
4014
|
+
}
|
|
4015
|
+
return buf.toString("utf8");
|
|
4016
|
+
}
|
|
4017
|
+
function decodeStatsCsv(buf) {
|
|
4018
|
+
return decodeCsvText(gunzipIfNeeded(buf));
|
|
4019
|
+
}
|
|
4020
|
+
var ZIP_READ_TIMEOUT_MS = 3e4;
|
|
4021
|
+
function extractCsvEntriesFromZip(buffer) {
|
|
4022
|
+
return new Promise((resolve2, reject) => {
|
|
4023
|
+
let settled = false;
|
|
4024
|
+
const timer = setTimeout(() => {
|
|
4025
|
+
settleReject(new Error("Timed out reading report archive"));
|
|
4026
|
+
}, ZIP_READ_TIMEOUT_MS);
|
|
4027
|
+
timer.unref?.();
|
|
4028
|
+
function settleResolve(v) {
|
|
4029
|
+
if (settled) return;
|
|
4030
|
+
settled = true;
|
|
4031
|
+
clearTimeout(timer);
|
|
4032
|
+
resolve2(v);
|
|
4033
|
+
}
|
|
4034
|
+
function settleReject(e) {
|
|
4035
|
+
if (settled) return;
|
|
4036
|
+
settled = true;
|
|
4037
|
+
clearTimeout(timer);
|
|
4038
|
+
reject(e);
|
|
4039
|
+
}
|
|
4040
|
+
try {
|
|
4041
|
+
fromBuffer(buffer, { lazyEntries: true }, (err, openedZip) => {
|
|
4042
|
+
if (err || !openedZip) {
|
|
4043
|
+
settleReject(err ?? new Error("Failed to open report archive"));
|
|
4044
|
+
return;
|
|
4045
|
+
}
|
|
4046
|
+
const zipfile = openedZip;
|
|
4047
|
+
const entries = [];
|
|
4048
|
+
let pending = 0;
|
|
4049
|
+
let entriesDone = false;
|
|
4050
|
+
function fail(e) {
|
|
4051
|
+
try {
|
|
4052
|
+
zipfile.close();
|
|
4053
|
+
} catch {
|
|
4054
|
+
}
|
|
4055
|
+
settleReject(e);
|
|
4056
|
+
}
|
|
4057
|
+
function maybeResolve() {
|
|
4058
|
+
if (!settled && entriesDone && pending === 0) settleResolve(entries);
|
|
4059
|
+
}
|
|
4060
|
+
zipfile.on("entry", (entry) => {
|
|
4061
|
+
if (/\/$/.test(entry.fileName) || !/\.csv$/i.test(entry.fileName)) {
|
|
4062
|
+
zipfile.readEntry();
|
|
4063
|
+
return;
|
|
4064
|
+
}
|
|
4065
|
+
pending++;
|
|
4066
|
+
zipfile.openReadStream(entry, (streamErr, stream) => {
|
|
4067
|
+
if (streamErr || !stream) {
|
|
4068
|
+
fail(streamErr ?? new Error(`Failed to read ${entry.fileName}`));
|
|
4069
|
+
return;
|
|
4070
|
+
}
|
|
4071
|
+
const chunks = [];
|
|
4072
|
+
stream.on("data", (c) => chunks.push(c));
|
|
4073
|
+
stream.on("error", fail);
|
|
4074
|
+
stream.on("end", () => {
|
|
4075
|
+
entries.push({ name: entry.fileName, text: decodeStatsCsv(Buffer.concat(chunks)) });
|
|
4076
|
+
pending--;
|
|
4077
|
+
zipfile.readEntry();
|
|
4078
|
+
maybeResolve();
|
|
4079
|
+
});
|
|
4080
|
+
});
|
|
4081
|
+
});
|
|
4082
|
+
zipfile.on("end", () => {
|
|
4083
|
+
entriesDone = true;
|
|
4084
|
+
maybeResolve();
|
|
4085
|
+
});
|
|
4086
|
+
zipfile.on("error", fail);
|
|
4087
|
+
zipfile.readEntry();
|
|
4088
|
+
});
|
|
4089
|
+
} catch (e) {
|
|
4090
|
+
settleReject(e instanceof Error ? e : new Error(String(e)));
|
|
4091
|
+
}
|
|
4092
|
+
});
|
|
4093
|
+
}
|
|
4094
|
+
|
|
3846
4095
|
// src/commands/reports.ts
|
|
3847
4096
|
var FINANCIAL_REPORT_TYPES = /* @__PURE__ */ new Set([
|
|
3848
4097
|
"earnings",
|
|
@@ -3865,6 +4114,7 @@ var VALID_DIMENSIONS = /* @__PURE__ */ new Set([
|
|
|
3865
4114
|
"device",
|
|
3866
4115
|
"app_version",
|
|
3867
4116
|
"carrier",
|
|
4117
|
+
"traffic_source",
|
|
3868
4118
|
"overview"
|
|
3869
4119
|
]);
|
|
3870
4120
|
function isFinancialReportType(type) {
|
|
@@ -3901,22 +4151,131 @@ function parseMonth(monthStr) {
|
|
|
3901
4151
|
}
|
|
3902
4152
|
return { year, month };
|
|
3903
4153
|
}
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
isFinancialReportType(reportType) ? "Financial reports are delivered via Google Cloud Storage. Access them from Play Console \u2192 Download reports \u2192 Financial." : "Stats reports are delivered via Google Cloud Storage. For real-time metrics, use 'gpc vitals' commands."
|
|
3910
|
-
);
|
|
4154
|
+
function monthToken(parsed) {
|
|
4155
|
+
return `${parsed.year}${String(parsed.month).padStart(2, "0")}`;
|
|
4156
|
+
}
|
|
4157
|
+
function nameHasMonth(name, token) {
|
|
4158
|
+
return new RegExp(`(?<![0-9])${token}(?![0-9])`).test(name);
|
|
3911
4159
|
}
|
|
3912
|
-
|
|
4160
|
+
function resolveReportsBucket(config) {
|
|
4161
|
+
const explicit = config.reports?.bucket?.trim();
|
|
4162
|
+
if (explicit) {
|
|
4163
|
+
const reduced = explicit.replace(/^gs:\/\//, "").replace(/\/.*$/, "");
|
|
4164
|
+
if (!/^[a-z0-9][a-z0-9._-]{1,221}$/.test(reduced)) {
|
|
4165
|
+
throw new GpcError(
|
|
4166
|
+
`Invalid reports bucket name "${reduced}".`,
|
|
4167
|
+
"REPORT_BUCKET_INVALID",
|
|
4168
|
+
2,
|
|
4169
|
+
"Copy the exact Cloud Storage URI from Play Console -> Download reports. The bucket name looks like pubsite_prod_<developerId>."
|
|
4170
|
+
);
|
|
4171
|
+
}
|
|
4172
|
+
return reduced;
|
|
4173
|
+
}
|
|
4174
|
+
if (config.developerId) {
|
|
4175
|
+
return `pubsite_prod_${config.developerId}`;
|
|
4176
|
+
}
|
|
3913
4177
|
throw new GpcError(
|
|
3914
|
-
|
|
3915
|
-
"
|
|
3916
|
-
|
|
3917
|
-
|
|
4178
|
+
"Cannot determine the Play reports bucket: no bucket configured and no developer id set.",
|
|
4179
|
+
"REPORT_BUCKET_UNKNOWN",
|
|
4180
|
+
2,
|
|
4181
|
+
"Set your developer id (gpc config set developerId <id> or GPC_DEVELOPER_ID) so the default pubsite_prod_<developerId> bucket can be derived, or pass the bucket directly with --bucket / GPC_REPORTS_BUCKET / the reports.bucket config key. The exact URI is shown in Play Console -> Download reports."
|
|
3918
4182
|
);
|
|
3919
4183
|
}
|
|
4184
|
+
function reportPrefix(reportType, packageName) {
|
|
4185
|
+
switch (reportType) {
|
|
4186
|
+
case "earnings":
|
|
4187
|
+
return "earnings/";
|
|
4188
|
+
case "sales":
|
|
4189
|
+
case "estimated_sales":
|
|
4190
|
+
return "sales/";
|
|
4191
|
+
case "play_balance":
|
|
4192
|
+
return "play_balance/";
|
|
4193
|
+
case "reviews":
|
|
4194
|
+
return packageName ? `reviews/reviews_${packageName}_` : "reviews/";
|
|
4195
|
+
case "subscriptions":
|
|
4196
|
+
return packageName ? `financial-stats/subscriptions/subscriptions_${packageName}_` : "financial-stats/subscriptions/";
|
|
4197
|
+
default:
|
|
4198
|
+
return packageName ? `stats/${reportType}/${reportType}_${packageName}_` : `stats/${reportType}/`;
|
|
4199
|
+
}
|
|
4200
|
+
}
|
|
4201
|
+
async function listReports(auth, bucket, reportType, options = {}) {
|
|
4202
|
+
const packageName = isStatsReportType(reportType) ? options.packageName : void 0;
|
|
4203
|
+
const basePrefix = reportPrefix(reportType, packageName);
|
|
4204
|
+
const serverSideMonth = Boolean(packageName && options.month);
|
|
4205
|
+
const prefix = serverSideMonth ? `${basePrefix}${monthToken(options.month)}` : basePrefix;
|
|
4206
|
+
const { objects, nextPageToken } = await listReportObjects(auth, bucket, {
|
|
4207
|
+
prefix,
|
|
4208
|
+
maxResults: options.maxResults,
|
|
4209
|
+
pageToken: options.pageToken
|
|
4210
|
+
});
|
|
4211
|
+
const reports = options.month && !serverSideMonth ? objects.filter((o) => nameHasMonth(o.name, monthToken(options.month))) : objects;
|
|
4212
|
+
return { reports, nextPageToken };
|
|
4213
|
+
}
|
|
4214
|
+
async function downloadStatsReport(auth, bucket, packageName, reportType, month, dimension) {
|
|
4215
|
+
const token = monthToken(month);
|
|
4216
|
+
const prefix = `${reportPrefix(reportType, packageName)}${token}`;
|
|
4217
|
+
const { objects } = await listReportObjects(auth, bucket, { prefix });
|
|
4218
|
+
const wanted = reportType === "reviews" ? objects.find((o) => o.name.endsWith(`${token}.csv`)) : objects.find((o) => o.name.endsWith(`${token}_${dimension}.csv`));
|
|
4219
|
+
if (!wanted) {
|
|
4220
|
+
const available = objects.map((o) => new RegExp(`(?<![0-9])${token}_([a-z_]+)\\.csv$`).exec(o.name)?.[1]).filter((d) => Boolean(d));
|
|
4221
|
+
throw reportObjectNotFoundError({
|
|
4222
|
+
reportType,
|
|
4223
|
+
month: `${month.year}-${String(month.month).padStart(2, "0")}`,
|
|
4224
|
+
dimension: reportType === "reviews" ? void 0 : dimension,
|
|
4225
|
+
available: [...new Set(available)]
|
|
4226
|
+
});
|
|
4227
|
+
}
|
|
4228
|
+
const bytes = await downloadReportObject(auth, bucket, wanted.name);
|
|
4229
|
+
try {
|
|
4230
|
+
return { objectName: wanted.name, csv: decodeStatsCsv(bytes) };
|
|
4231
|
+
} catch (err) {
|
|
4232
|
+
throw new GpcError(
|
|
4233
|
+
`Could not decode the report "${wanted.name}": ${err instanceof Error ? err.message : String(err)}`,
|
|
4234
|
+
"REPORT_DECODE_FAILED",
|
|
4235
|
+
4,
|
|
4236
|
+
"Re-download in case the transfer was corrupted. If the error persists, the object may be malformed; download it from Play Console -> Download reports to compare."
|
|
4237
|
+
);
|
|
4238
|
+
}
|
|
4239
|
+
}
|
|
4240
|
+
async function downloadFinancialReport(auth, bucket, reportType, month, options = {}) {
|
|
4241
|
+
const token = monthToken(month);
|
|
4242
|
+
const { objects } = await listReportObjects(auth, bucket, {
|
|
4243
|
+
prefix: reportPrefix(reportType)
|
|
4244
|
+
});
|
|
4245
|
+
const matches = objects.filter((o) => nameHasMonth(o.name, token));
|
|
4246
|
+
if (matches.length === 0) {
|
|
4247
|
+
const availableMonths = [
|
|
4248
|
+
...new Set(
|
|
4249
|
+
objects.map((o) => /(?<![0-9])(\d{4})(\d{2})(?![0-9])/.exec(o.name)).filter((m) => m !== null).map((m) => `${m[1]}-${m[2]}`)
|
|
4250
|
+
)
|
|
4251
|
+
].slice(-6);
|
|
4252
|
+
throw reportObjectNotFoundError({
|
|
4253
|
+
reportType,
|
|
4254
|
+
month: `${month.year}-${String(month.month).padStart(2, "0")}`,
|
|
4255
|
+
available: availableMonths
|
|
4256
|
+
});
|
|
4257
|
+
}
|
|
4258
|
+
const chosen = matches[matches.length - 1];
|
|
4259
|
+
const bytes = await downloadReportObject(auth, bucket, chosen.name);
|
|
4260
|
+
if (isZip(bytes)) {
|
|
4261
|
+
if (options.extractEntries === false) {
|
|
4262
|
+
return { objectName: chosen.name, kind: "zip", entries: [], raw: bytes };
|
|
4263
|
+
}
|
|
4264
|
+
let entries;
|
|
4265
|
+
try {
|
|
4266
|
+
entries = await extractCsvEntriesFromZip(bytes);
|
|
4267
|
+
} catch (err) {
|
|
4268
|
+
throw new GpcError(
|
|
4269
|
+
`Could not read the report archive "${chosen.name}": ${err instanceof Error ? err.message : String(err)}`,
|
|
4270
|
+
"REPORT_ARCHIVE_UNREADABLE",
|
|
4271
|
+
4,
|
|
4272
|
+
"Save the raw archive instead (--output-file report.zip) and open it locally, or re-download in case the transfer was corrupted."
|
|
4273
|
+
);
|
|
4274
|
+
}
|
|
4275
|
+
return { objectName: chosen.name, kind: "zip", entries, raw: bytes };
|
|
4276
|
+
}
|
|
4277
|
+
return { objectName: chosen.name, kind: "csv", text: decodeStatsCsv(bytes) };
|
|
4278
|
+
}
|
|
3920
4279
|
|
|
3921
4280
|
// src/commands/users.ts
|
|
3922
4281
|
import { paginateAll as paginateAll5 } from "@gpc-cli/api";
|
|
@@ -5376,7 +5735,7 @@ async function loadPreflightConfig(configPath) {
|
|
|
5376
5735
|
|
|
5377
5736
|
// src/preflight/aab-reader.ts
|
|
5378
5737
|
import { readFile as readFile11 } from "fs/promises";
|
|
5379
|
-
import { fromBuffer } from "yauzl";
|
|
5738
|
+
import { fromBuffer as fromBuffer2 } from "yauzl";
|
|
5380
5739
|
|
|
5381
5740
|
// src/preflight/manifest-parser.ts
|
|
5382
5741
|
import protobuf from "protobufjs";
|
|
@@ -5637,7 +5996,7 @@ function openAndScan(buffer, manifestPath = AAB_MANIFEST_PATH) {
|
|
|
5637
5996
|
reject(error);
|
|
5638
5997
|
}
|
|
5639
5998
|
try {
|
|
5640
|
-
|
|
5999
|
+
fromBuffer2(buffer, { lazyEntries: true }, (err, zipfile) => {
|
|
5641
6000
|
if (err || !zipfile) {
|
|
5642
6001
|
settleReject(err ?? new Error("Failed to open AAB file"));
|
|
5643
6002
|
return;
|
|
@@ -8925,7 +9284,7 @@ async function sha256File(filePath) {
|
|
|
8925
9284
|
}
|
|
8926
9285
|
|
|
8927
9286
|
// src/commands/image-sync.ts
|
|
8928
|
-
import { readdir as readdir7 } from "fs/promises";
|
|
9287
|
+
import { readdir as readdir7, stat as stat10 } from "fs/promises";
|
|
8929
9288
|
import { join as join12, extname as extname5 } from "path";
|
|
8930
9289
|
import { PlayApiError } from "@gpc-cli/api";
|
|
8931
9290
|
var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg"]);
|
|
@@ -8955,6 +9314,13 @@ async function scanLanguages(dir) {
|
|
|
8955
9314
|
return [];
|
|
8956
9315
|
}
|
|
8957
9316
|
}
|
|
9317
|
+
async function dirExists(path) {
|
|
9318
|
+
try {
|
|
9319
|
+
return (await stat10(path)).isDirectory();
|
|
9320
|
+
} catch {
|
|
9321
|
+
return false;
|
|
9322
|
+
}
|
|
9323
|
+
}
|
|
8958
9324
|
async function syncImages(client, packageName, dir, options) {
|
|
8959
9325
|
const details = [];
|
|
8960
9326
|
let uploaded = 0;
|
|
@@ -8992,23 +9358,59 @@ async function syncImages(client, packageName, dir, options) {
|
|
|
8992
9358
|
const hash = await sha256File(join12(localDir, file));
|
|
8993
9359
|
localHashes.set(file, hash);
|
|
8994
9360
|
}
|
|
8995
|
-
const
|
|
9361
|
+
const uploadLocal = async (file, reason) => {
|
|
9362
|
+
if (!options?.dryRun) {
|
|
9363
|
+
const filePath = join12(localDir, file);
|
|
9364
|
+
const check = await validateImage(filePath, imageType);
|
|
9365
|
+
if (!check.valid) {
|
|
9366
|
+
throw new GpcError(
|
|
9367
|
+
`Image validation failed for ${language}/${imageType}/${file}: ${check.errors.join("; ")}`,
|
|
9368
|
+
"IMAGE_INVALID",
|
|
9369
|
+
2,
|
|
9370
|
+
"Check image dimensions, file size, and format."
|
|
9371
|
+
);
|
|
9372
|
+
}
|
|
9373
|
+
await client.images.upload(packageName, edit.id, language, imageType, filePath);
|
|
9374
|
+
}
|
|
9375
|
+
uploaded++;
|
|
9376
|
+
details.push({ language, imageType, file, action: "upload", reason });
|
|
9377
|
+
};
|
|
8996
9378
|
if (options?.delete) {
|
|
8997
|
-
|
|
8998
|
-
|
|
8999
|
-
if (!options?.dryRun) {
|
|
9000
|
-
await client.images.delete(packageName, edit.id, language, imageType, img.id);
|
|
9001
|
-
}
|
|
9002
|
-
deleted++;
|
|
9379
|
+
if (localFiles.length === 0 && remoteImages.length > 0 && !await dirExists(localDir)) {
|
|
9380
|
+
for (const img of remoteImages) {
|
|
9003
9381
|
details.push({
|
|
9004
9382
|
language,
|
|
9005
9383
|
imageType,
|
|
9006
9384
|
file: img.id,
|
|
9007
|
-
action: "
|
|
9008
|
-
reason: "
|
|
9385
|
+
action: "skip",
|
|
9386
|
+
reason: "no local directory; remote left untouched"
|
|
9009
9387
|
});
|
|
9010
9388
|
}
|
|
9389
|
+
continue;
|
|
9390
|
+
}
|
|
9391
|
+
const localSeq = localFiles.map((file) => localHashes.get(file));
|
|
9392
|
+
const remoteSeq = remoteImages.map((img) => img.sha256.toLowerCase());
|
|
9393
|
+
const inOrder = localSeq.length === remoteSeq.length && localSeq.every((hash, i) => hash === remoteSeq[i]);
|
|
9394
|
+
if (inOrder) {
|
|
9395
|
+
for (const file of localFiles) {
|
|
9396
|
+
skipped++;
|
|
9397
|
+
details.push({ language, imageType, file, action: "skip", reason: "already in order" });
|
|
9398
|
+
}
|
|
9399
|
+
continue;
|
|
9011
9400
|
}
|
|
9401
|
+
if (remoteImages.length > 0) {
|
|
9402
|
+
if (!options?.dryRun) {
|
|
9403
|
+
await client.images.deleteAll(packageName, edit.id, language, imageType);
|
|
9404
|
+
}
|
|
9405
|
+
for (const img of remoteImages) {
|
|
9406
|
+
deleted++;
|
|
9407
|
+
details.push({ language, imageType, file: img.id, action: "delete", reason: "reordering" });
|
|
9408
|
+
}
|
|
9409
|
+
}
|
|
9410
|
+
for (const file of localFiles) {
|
|
9411
|
+
await uploadLocal(file, "reordering");
|
|
9412
|
+
}
|
|
9413
|
+
continue;
|
|
9012
9414
|
}
|
|
9013
9415
|
for (const file of localFiles) {
|
|
9014
9416
|
const hash = localHashes.get(file);
|
|
@@ -9016,21 +9418,7 @@ async function syncImages(client, packageName, dir, options) {
|
|
|
9016
9418
|
skipped++;
|
|
9017
9419
|
details.push({ language, imageType, file, action: "skip", reason: "sha256 match" });
|
|
9018
9420
|
} else {
|
|
9019
|
-
|
|
9020
|
-
const filePath = join12(localDir, file);
|
|
9021
|
-
const check = await validateImage(filePath, imageType);
|
|
9022
|
-
if (!check.valid) {
|
|
9023
|
-
throw new GpcError(
|
|
9024
|
-
`Image validation failed for ${language}/${imageType}/${file}: ${check.errors.join("; ")}`,
|
|
9025
|
-
"IMAGE_INVALID",
|
|
9026
|
-
2,
|
|
9027
|
-
"Check image dimensions, file size, and format."
|
|
9028
|
-
);
|
|
9029
|
-
}
|
|
9030
|
-
await client.images.upload(packageName, edit.id, language, imageType, filePath);
|
|
9031
|
-
}
|
|
9032
|
-
uploaded++;
|
|
9033
|
-
details.push({ language, imageType, file, action: "upload", reason: "new or changed" });
|
|
9421
|
+
await uploadLocal(file, "new or changed");
|
|
9034
9422
|
}
|
|
9035
9423
|
}
|
|
9036
9424
|
}
|
|
@@ -9415,7 +9803,9 @@ export {
|
|
|
9415
9803
|
createTranslator,
|
|
9416
9804
|
deactivateBasePlan,
|
|
9417
9805
|
deactivateOffer,
|
|
9806
|
+
decodeCsvText,
|
|
9418
9807
|
decodeNotification,
|
|
9808
|
+
decodeStatsCsv,
|
|
9419
9809
|
defaultGitRunner,
|
|
9420
9810
|
deferSubscriptionPurchase,
|
|
9421
9811
|
deferSubscriptionV2,
|
|
@@ -9443,10 +9833,13 @@ export {
|
|
|
9443
9833
|
diffSubscription,
|
|
9444
9834
|
discoverPlugins,
|
|
9445
9835
|
dispatchWebhook,
|
|
9836
|
+
downloadFinancialReport,
|
|
9446
9837
|
downloadGeneratedApk,
|
|
9447
|
-
|
|
9838
|
+
downloadReportObject,
|
|
9839
|
+
downloadStatsReport,
|
|
9448
9840
|
exportImages,
|
|
9449
9841
|
exportReviews,
|
|
9842
|
+
extractCsvEntriesFromZip,
|
|
9450
9843
|
fetchAggregateCost,
|
|
9451
9844
|
fetchChangelog,
|
|
9452
9845
|
fetchReleaseNotes,
|
|
@@ -9505,6 +9898,7 @@ export {
|
|
|
9505
9898
|
getVitalsRendering,
|
|
9506
9899
|
getVitalsStartup,
|
|
9507
9900
|
gradeFromPercent,
|
|
9901
|
+
gunzipIfNeeded,
|
|
9508
9902
|
handleBreach,
|
|
9509
9903
|
importDataSafety,
|
|
9510
9904
|
importTestersFromCsv,
|
|
@@ -9512,11 +9906,13 @@ export {
|
|
|
9512
9906
|
initProject,
|
|
9513
9907
|
inviteUser,
|
|
9514
9908
|
isFinancialReportType,
|
|
9909
|
+
isGzip,
|
|
9515
9910
|
isStatsReportType,
|
|
9516
9911
|
isValidBcp47,
|
|
9517
9912
|
isValidReportType,
|
|
9518
9913
|
isValidStatsDimension,
|
|
9519
9914
|
isVersionedNotesDir,
|
|
9915
|
+
isZip,
|
|
9520
9916
|
lintListing,
|
|
9521
9917
|
lintListings,
|
|
9522
9918
|
lintLocalListings,
|
|
@@ -9535,6 +9931,7 @@ export {
|
|
|
9535
9931
|
listOneTimeOffers,
|
|
9536
9932
|
listOneTimeProducts,
|
|
9537
9933
|
listRecoveryActions,
|
|
9934
|
+
listReportObjects,
|
|
9538
9935
|
listReports,
|
|
9539
9936
|
listReviews,
|
|
9540
9937
|
listSubscriptions,
|
|
@@ -9546,6 +9943,7 @@ export {
|
|
|
9546
9943
|
loadStatusCache,
|
|
9547
9944
|
maybePaginate,
|
|
9548
9945
|
migratePrices,
|
|
9946
|
+
monthToken,
|
|
9549
9947
|
moreResultsFooter,
|
|
9550
9948
|
normalizeFingerprint,
|
|
9551
9949
|
parseAppfile,
|
|
@@ -9587,8 +9985,13 @@ export {
|
|
|
9587
9985
|
renderPlayStorePrompt,
|
|
9588
9986
|
renderPrompt,
|
|
9589
9987
|
replyToReview,
|
|
9988
|
+
reportAccessDeniedError,
|
|
9989
|
+
reportBucketNotFoundError,
|
|
9990
|
+
reportObjectNotFoundError,
|
|
9991
|
+
reportPrefix,
|
|
9590
9992
|
resolveAiConfig,
|
|
9591
9993
|
resolveLocales,
|
|
9994
|
+
resolveReportsBucket,
|
|
9592
9995
|
retryOnUploadNotComplete,
|
|
9593
9996
|
revokeSubscriptionPurchase,
|
|
9594
9997
|
runPreflight,
|