@gpc-cli/core 0.10.4 → 0.10.6

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) => {
@@ -4151,6 +4141,7 @@ function reportObjectNotFoundError(opts) {
4151
4141
  var GCS_API_BASE = "https://storage.googleapis.com/storage/v1/b";
4152
4142
  var LIST_TIMEOUT_MS = 6e4;
4153
4143
  var DOWNLOAD_TIMEOUT_MS = 12e4;
4144
+ var MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024;
4154
4145
  function networkError(bucket, cause) {
4155
4146
  const detail = cause instanceof Error ? cause.message : String(cause);
4156
4147
  return new GpcError(
@@ -4160,6 +4151,14 @@ function networkError(bucket, cause) {
4160
4151
  "Check your internet connection and retry. Google Cloud Storage may also be briefly unavailable."
4161
4152
  );
4162
4153
  }
4154
+ function tooLargeError(objectName, maxBytes) {
4155
+ return new GpcError(
4156
+ `Report object "${objectName}" is larger than the ${maxBytes}-byte download limit.`,
4157
+ "REPORT_DOWNLOAD_FAILED",
4158
+ 4,
4159
+ "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."
4160
+ );
4161
+ }
4163
4162
  function maxRetries() {
4164
4163
  const parsed = Number(process.env["GPC_MAX_RETRIES"]);
4165
4164
  return Number.isInteger(parsed) && parsed >= 0 && parsed <= 10 ? parsed : 3;
@@ -4226,7 +4225,8 @@ async function listReportObjects(auth, bucket, options) {
4226
4225
  }));
4227
4226
  return { objects, nextPageToken: body.nextPageToken };
4228
4227
  }
4229
- async function downloadReportObject(auth, bucket, objectName) {
4228
+ async function downloadReportObject(auth, bucket, objectName, options = {}) {
4229
+ const maxBytes = options.maxBytes ?? MAX_DOWNLOAD_BYTES;
4230
4230
  const url = `${GCS_API_BASE}/${encodeURIComponent(bucket)}/o/${encodeURIComponent(objectName)}?alt=media`;
4231
4231
  const res = await gcsFetch(auth, bucket, url, DOWNLOAD_TIMEOUT_MS);
4232
4232
  if (res.status === 404) {
@@ -4245,11 +4245,25 @@ async function downloadReportObject(auth, bucket, objectName) {
4245
4245
  "Retry in a moment. If the error persists, list the reports to confirm the object still exists."
4246
4246
  );
4247
4247
  }
4248
+ const declared = Number(res.headers.get("content-length"));
4249
+ if (Number.isFinite(declared) && declared > maxBytes) {
4250
+ throw tooLargeError(objectName, maxBytes);
4251
+ }
4252
+ const body = res.body;
4253
+ if (!body) return Buffer.alloc(0);
4254
+ const chunks = [];
4255
+ let read = 0;
4248
4256
  try {
4249
- return Buffer.from(await res.arrayBuffer());
4257
+ for await (const chunk of body) {
4258
+ read += chunk.length;
4259
+ if (read > maxBytes) throw tooLargeError(objectName, maxBytes);
4260
+ chunks.push(Buffer.from(chunk));
4261
+ }
4250
4262
  } catch (err) {
4263
+ if (err instanceof GpcError) throw err;
4251
4264
  throw networkError(bucket, err);
4252
4265
  }
4266
+ return Buffer.concat(chunks);
4253
4267
  }
4254
4268
 
4255
4269
  // src/reports/decode.ts
@@ -4285,13 +4299,15 @@ function decodeStatsCsv(buf) {
4285
4299
  return decodeCsvText(gunzipIfNeeded(buf));
4286
4300
  }
4287
4301
  var ZIP_READ_TIMEOUT_MS = 3e4;
4288
- function extractCsvEntriesFromZip(buffer) {
4302
+ var MAX_CSV_ENTRIES = 64;
4303
+ function extractCsvEntriesFromZip(buffer, options = {}) {
4304
+ const maxEntryBytes = options.maxEntryBytes ?? MAX_INFLATED_BYTES;
4305
+ const maxTotalBytes = options.maxTotalBytes ?? MAX_INFLATED_BYTES;
4289
4306
  return new Promise((resolve2, reject) => {
4290
4307
  let settled = false;
4291
4308
  const timer = setTimeout(() => {
4292
4309
  settleReject(new Error("Timed out reading report archive"));
4293
4310
  }, ZIP_READ_TIMEOUT_MS);
4294
- timer.unref?.();
4295
4311
  function settleResolve(v) {
4296
4312
  if (settled) return;
4297
4313
  settled = true;
@@ -4314,6 +4330,8 @@ function extractCsvEntriesFromZip(buffer) {
4314
4330
  const entries = [];
4315
4331
  let pending = 0;
4316
4332
  let entriesDone = false;
4333
+ let csvEntries = 0;
4334
+ let totalBytes = 0;
4317
4335
  function fail(e) {
4318
4336
  try {
4319
4337
  zipfile.close();
@@ -4329,6 +4347,10 @@ function extractCsvEntriesFromZip(buffer) {
4329
4347
  zipfile.readEntry();
4330
4348
  return;
4331
4349
  }
4350
+ if (++csvEntries > MAX_CSV_ENTRIES) {
4351
+ fail(new Error(`Report archive holds more than ${MAX_CSV_ENTRIES} CSV entries`));
4352
+ return;
4353
+ }
4332
4354
  pending++;
4333
4355
  zipfile.openReadStream(entry, (streamErr, stream) => {
4334
4356
  if (streamErr || !stream) {
@@ -4336,10 +4358,35 @@ function extractCsvEntriesFromZip(buffer) {
4336
4358
  return;
4337
4359
  }
4338
4360
  const chunks = [];
4339
- stream.on("data", (c) => chunks.push(c));
4361
+ let read = 0;
4362
+ stream.on("data", (c) => {
4363
+ read += c.length;
4364
+ if (read > maxEntryBytes) {
4365
+ stream.destroy();
4366
+ fail(
4367
+ new Error(
4368
+ `Entry "${entry.fileName}" exceeds the ${maxEntryBytes}-byte limit for a report CSV`
4369
+ )
4370
+ );
4371
+ return;
4372
+ }
4373
+ chunks.push(c);
4374
+ });
4340
4375
  stream.on("error", fail);
4341
4376
  stream.on("end", () => {
4342
- entries.push({ name: entry.fileName, text: decodeStatsCsv(Buffer.concat(chunks)) });
4377
+ let text;
4378
+ try {
4379
+ text = decodeStatsCsv(Buffer.concat(chunks));
4380
+ } catch (e) {
4381
+ fail(e instanceof Error ? e : new Error(String(e)));
4382
+ return;
4383
+ }
4384
+ totalBytes += text.length;
4385
+ if (totalBytes > maxTotalBytes) {
4386
+ fail(new Error(`Report archive exceeds the ${maxTotalBytes}-byte total limit`));
4387
+ return;
4388
+ }
4389
+ entries.push({ name: entry.fileName, text });
4343
4390
  pending--;
4344
4391
  zipfile.readEntry();
4345
4392
  maybeResolve();
@@ -4880,6 +4927,88 @@ async function getExternalTransaction(client, packageName, transactionId) {
4880
4927
  async function refundExternalTransaction(client, packageName, transactionId, refundData) {
4881
4928
  return client.externalTransactions.refund(packageName, transactionId, refundData);
4882
4929
  }
4930
+ var RFC3339_RE = /^(\d{4}-\d{2}-\d{2})T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/i;
4931
+ var MAX_INT64 = 2n ** 63n - 1n;
4932
+ var FUTURE_SKEW_MS = 5 * 60 * 1e3;
4933
+ function refundUsageError(message, suggestion) {
4934
+ return new GpcError(message, "EXT_TXN_REFUND_INVALID", 2, suggestion);
4935
+ }
4936
+ function buildExternalTransactionRefund(options, now = /* @__PURE__ */ new Date()) {
4937
+ const partialAmount = options.partialAmount?.trim();
4938
+ const currency = options.currency?.trim();
4939
+ const refundId = options.refundId?.trim();
4940
+ if (options.full && partialAmount) {
4941
+ throw refundUsageError(
4942
+ "Use --full or --partial-amount, not both",
4943
+ "Pass --full to refund the whole transaction, or --partial-amount for part of it."
4944
+ );
4945
+ }
4946
+ if (!options.full && !partialAmount) {
4947
+ throw refundUsageError(
4948
+ "Choose the refund type with --full or --partial-amount",
4949
+ "Pass --full, or --partial-amount <micros> --currency <code> --refund-id <id>."
4950
+ );
4951
+ }
4952
+ let refundTime = now.toISOString();
4953
+ if (options.refundTime !== void 0) {
4954
+ const raw = options.refundTime.trim();
4955
+ const match = RFC3339_RE.exec(raw);
4956
+ const parsed = new Date(raw);
4957
+ const offsetMs = parsed.getTime() - Date.parse(raw.replace(/(?:Z|[+-]\d{2}:\d{2})$/i, "Z"));
4958
+ const calendarDate = match ? new Date(parsed.getTime() - offsetMs).toISOString().slice(0, 10) : void 0;
4959
+ if (!match || Number.isNaN(parsed.getTime()) || calendarDate !== match[1]) {
4960
+ throw refundUsageError(
4961
+ `Invalid --refund-time "${options.refundTime}"`,
4962
+ "Use an RFC 3339 timestamp with a timezone, for example 2026-09-25T12:00:00Z or 2026-09-25T14:00:00+02:00."
4963
+ );
4964
+ }
4965
+ if (parsed.getTime() > now.getTime() + FUTURE_SKEW_MS) {
4966
+ throw refundUsageError(
4967
+ `--refund-time "${options.refundTime}" is in the future`,
4968
+ "Give the time the refund actually happened, or omit --refund-time to use now."
4969
+ );
4970
+ }
4971
+ refundTime = parsed.toISOString();
4972
+ }
4973
+ if (options.full) {
4974
+ if (currency || refundId) {
4975
+ throw refundUsageError(
4976
+ "--currency and --refund-id only apply to --partial-amount",
4977
+ "Remove them for a full refund, or use --partial-amount instead of --full."
4978
+ );
4979
+ }
4980
+ return { refundTime, fullRefund: {} };
4981
+ }
4982
+ if (!partialAmount || !/^\d{1,19}$/.test(partialAmount) || BigInt(partialAmount) <= 0n || BigInt(partialAmount) > MAX_INT64) {
4983
+ throw refundUsageError(
4984
+ `Invalid --partial-amount "${options.partialAmount}"`,
4985
+ "Give the pre-tax amount in micros as a positive whole number, for example 1990000 for 1.99."
4986
+ );
4987
+ }
4988
+ if (!currency || !/^[A-Za-z]{3}$/.test(currency)) {
4989
+ throw refundUsageError(
4990
+ currency ? `Invalid --currency "${currency}"` : "--currency is required with --partial-amount",
4991
+ "Use the transaction's ISO 4217 currency code, for example USD."
4992
+ );
4993
+ }
4994
+ if (!refundId) {
4995
+ throw refundUsageError(
4996
+ "--refund-id is required with --partial-amount",
4997
+ "Give each partial refund a unique ID; Google rejects a second refund with the same ID."
4998
+ );
4999
+ }
5000
+ return {
5001
+ refundTime,
5002
+ partialRefund: {
5003
+ refundId,
5004
+ // Normalized, so "007" is sent as "7".
5005
+ refundPreTaxAmount: {
5006
+ priceMicros: BigInt(partialAmount).toString(),
5007
+ currency: currency.toUpperCase()
5008
+ }
5009
+ }
5010
+ };
5011
+ }
4883
5012
 
4884
5013
  // src/commands/device-tiers.ts
4885
5014
  async function listDeviceTiers(client, packageName) {
@@ -5376,11 +5505,11 @@ async function getAchievementConfig(client, achievementId) {
5376
5505
  }
5377
5506
  async function createAchievementConfig(client, applicationId, data) {
5378
5507
  validateApplicationId(applicationId);
5379
- return client.achievements.insert(applicationId, data);
5508
+ return client.achievements.insert(applicationId, withoutRedactedToken(data));
5380
5509
  }
5381
5510
  async function updateAchievementConfig(client, achievementId, data) {
5382
5511
  validateResourceId(achievementId, "achievement");
5383
- return client.achievements.update(achievementId, data);
5512
+ return client.achievements.update(achievementId, withoutRedactedToken(data));
5384
5513
  }
5385
5514
  async function deleteAchievementConfig(client, achievementId) {
5386
5515
  validateResourceId(achievementId, "achievement");
@@ -5412,11 +5541,11 @@ async function getLeaderboardConfig(client, leaderboardId) {
5412
5541
  }
5413
5542
  async function createLeaderboardConfig(client, applicationId, data) {
5414
5543
  validateApplicationId(applicationId);
5415
- return client.leaderboards.insert(applicationId, data);
5544
+ return client.leaderboards.insert(applicationId, withoutRedactedToken(data));
5416
5545
  }
5417
5546
  async function updateLeaderboardConfig(client, leaderboardId, data) {
5418
5547
  validateResourceId(leaderboardId, "leaderboard");
5419
- return client.leaderboards.update(leaderboardId, data);
5548
+ return client.leaderboards.update(leaderboardId, withoutRedactedToken(data));
5420
5549
  }
5421
5550
  async function deleteLeaderboardConfig(client, leaderboardId) {
5422
5551
  validateResourceId(leaderboardId, "leaderboard");
@@ -5474,6 +5603,11 @@ async function setLeaderboardIcon(client, leaderboardId, filePath) {
5474
5603
  iconContentType(filePath)
5475
5604
  );
5476
5605
  }
5606
+ function withoutRedactedToken(config) {
5607
+ if (config.token !== REDACTED) return config;
5608
+ const { token: _token, ...rest } = config;
5609
+ return rest;
5610
+ }
5477
5611
  function safeConfigFilename(id) {
5478
5612
  return `${id.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`;
5479
5613
  }
@@ -5501,7 +5635,7 @@ async function readConfigDir(dir) {
5501
5635
  "Each file must contain a single achievement or leaderboard config object."
5502
5636
  );
5503
5637
  }
5504
- out.push({ file, config: parsed });
5638
+ out.push({ file, config: withoutRedactedToken(parsed) });
5505
5639
  }
5506
5640
  return out;
5507
5641
  }
@@ -5832,7 +5966,7 @@ async function initProject(options) {
5832
5966
  {
5833
5967
  failOn: "error",
5834
5968
  targetSdkMinimum: 36,
5835
- maxDownloadSizeMb: 150,
5969
+ maxDownloadSizeMb: 200,
5836
5970
  allowedPermissions: [],
5837
5971
  disabledRules: [],
5838
5972
  severityOverrides: {}
@@ -5950,7 +6084,7 @@ var SEVERITY_ORDER = {
5950
6084
  var DEFAULT_PREFLIGHT_CONFIG = {
5951
6085
  failOn: "error",
5952
6086
  targetSdkMinimum: 36,
5953
- maxDownloadSizeMb: 150,
6087
+ maxDownloadSizeMb: 200,
5954
6088
  allowedPermissions: [],
5955
6089
  disabledRules: [],
5956
6090
  severityOverrides: {}
@@ -6083,13 +6217,23 @@ function getSchema() {
6083
6217
  return cachedSchema;
6084
6218
  }
6085
6219
  function decodeManifest(buf) {
6220
+ return extractManifestData(decodeManifestElement(buf));
6221
+ }
6222
+ function isInstallTimeModule(buf) {
6223
+ const module = getChildren(decodeManifestElement(buf), "module")[0];
6224
+ if (!module) return true;
6225
+ const delivery = getChildren(module, "delivery")[0];
6226
+ if (delivery) return getChildren(delivery, "install-time").length > 0;
6227
+ return !getBoolByName(module.attribute || [], "onDemand", false);
6228
+ }
6229
+ function decodeManifestElement(buf) {
6086
6230
  const root = getSchema();
6087
6231
  const XmlNode = root.lookupType("aapt.pb.XmlNode");
6088
6232
  const decoded = XmlNode.decode(buf);
6089
6233
  if (!decoded.element || decoded.element.name !== "manifest") {
6090
6234
  throw new Error("Invalid AAB manifest: root element is not <manifest>");
6091
6235
  }
6092
- return extractManifestData(decoded.element);
6236
+ return decoded.element;
6093
6237
  }
6094
6238
  function getAttrByName(attrs, name) {
6095
6239
  const attr = attrs.find((a) => a.name === name || RESOURCE_IDS[a.resourceId] === name);
@@ -6198,6 +6342,10 @@ var AAB_MANIFEST_PATH = "base/manifest/AndroidManifest.xml";
6198
6342
  var APK_MANIFEST_PATH = "AndroidManifest.xml";
6199
6343
  var SO_HEADER_BYTES = 4096;
6200
6344
  var SO_PATH_RE = /(?:^|\/)lib\/[^/]+\/[^/]+\.so$/;
6345
+ var MODULE_MANIFEST_RE = /^([^/]+)\/manifest\/AndroidManifest\.xml$/;
6346
+ var MAX_MODULE_MANIFEST_BYTES = 1024 * 1024;
6347
+ var MAX_MODULE_MANIFESTS = 256;
6348
+ var MAX_SO_HEADER_READS = 512;
6201
6349
  var SCAN_TIMEOUT_MS = 6e4;
6202
6350
  function detectManifestPath(filePath) {
6203
6351
  return filePath.toLowerCase().endsWith(".apk") ? APK_MANIFEST_PATH : AAB_MANIFEST_PATH;
@@ -6211,7 +6359,10 @@ async function readAab(aabPath) {
6211
6359
  const msg = err instanceof Error ? err.message : String(err);
6212
6360
  throw new Error(`Could not read ${aabPath}: ${msg}`, { cause: err });
6213
6361
  }
6214
- const { zipfile, entries, manifestBuf, soHeaders } = await openAndScan(buffer, manifestPath);
6362
+ const { zipfile, entries, manifestBuf, soHeaders, moduleManifests } = await openAndScan(
6363
+ buffer,
6364
+ manifestPath
6365
+ );
6215
6366
  zipfile.close();
6216
6367
  if (!manifestBuf) {
6217
6368
  const fileType = aabPath.toLowerCase().endsWith(".apk") ? "APK" : "AAB";
@@ -6227,7 +6378,20 @@ async function readAab(aabPath) {
6227
6378
  manifest = createFallbackManifest();
6228
6379
  manifest._parseError = `Manifest could not be fully parsed: ${errMsg}. Manifest-dependent checks will be skipped.`;
6229
6380
  }
6230
- return { manifest, entries, nativeLibHeaders: soHeaders };
6381
+ const deferredModules = [...moduleManifests].filter(([, buf]) => {
6382
+ try {
6383
+ return !isInstallTimeModule(buf);
6384
+ } catch {
6385
+ return false;
6386
+ }
6387
+ }).map(([name]) => name);
6388
+ return {
6389
+ manifest,
6390
+ entries,
6391
+ nativeLibHeaders: soHeaders,
6392
+ isAppBundle: manifestPath === AAB_MANIFEST_PATH,
6393
+ deferredModules
6394
+ };
6231
6395
  }
6232
6396
  function createFallbackManifest() {
6233
6397
  return {
@@ -6277,6 +6441,9 @@ function openAndScan(buffer, manifestPath = AAB_MANIFEST_PATH) {
6277
6441
  }
6278
6442
  const entries = [];
6279
6443
  const soHeaders = /* @__PURE__ */ new Map();
6444
+ const moduleManifests = /* @__PURE__ */ new Map();
6445
+ let moduleManifestReads = 0;
6446
+ let soHeaderReads = 0;
6280
6447
  let manifestBuf = null;
6281
6448
  let pendingStreams = 0;
6282
6449
  let entrysDone = false;
@@ -6290,19 +6457,34 @@ function openAndScan(buffer, manifestPath = AAB_MANIFEST_PATH) {
6290
6457
  }
6291
6458
  function maybeResolve() {
6292
6459
  if (!settled && entrysDone && pendingStreams === 0) {
6293
- settleResolve({ zipfile, entries, manifestBuf, soHeaders });
6460
+ settleResolve({ zipfile, entries, manifestBuf, soHeaders, moduleManifests });
6294
6461
  }
6295
6462
  }
6296
- function readEntryStream(entry, onData, maxBytes) {
6463
+ function advance() {
6464
+ if (settled) return;
6465
+ zipfile.readEntry();
6466
+ }
6467
+ function readEntryStream(entry, onData, maxBytes, optional = false) {
6297
6468
  pendingStreams++;
6469
+ let done = false;
6470
+ const onError = (e) => {
6471
+ if (!optional) {
6472
+ fail(e);
6473
+ return;
6474
+ }
6475
+ if (done) return;
6476
+ done = true;
6477
+ pendingStreams--;
6478
+ advance();
6479
+ maybeResolve();
6480
+ };
6298
6481
  zipfile.openReadStream(entry, (streamErr, stream) => {
6299
6482
  if (streamErr || !stream) {
6300
- fail(streamErr ?? new Error(`Failed to read ${entry.fileName}`));
6483
+ onError(streamErr ?? new Error(`Failed to read ${entry.fileName}`));
6301
6484
  return;
6302
6485
  }
6303
6486
  const chunks = [];
6304
6487
  let totalBytes = 0;
6305
- let done = false;
6306
6488
  stream.on("data", (chunk) => {
6307
6489
  chunks.push(chunk);
6308
6490
  totalBytes += chunk.length;
@@ -6312,7 +6494,7 @@ function openAndScan(buffer, manifestPath = AAB_MANIFEST_PATH) {
6312
6494
  });
6313
6495
  stream.on("error", (e) => {
6314
6496
  if (maxBytes && totalBytes >= maxBytes) return;
6315
- fail(e);
6497
+ onError(e);
6316
6498
  });
6317
6499
  const finish = () => {
6318
6500
  if (done) return;
@@ -6320,6 +6502,7 @@ function openAndScan(buffer, manifestPath = AAB_MANIFEST_PATH) {
6320
6502
  const buf = Buffer.concat(chunks);
6321
6503
  onData(maxBytes ? buf.subarray(0, maxBytes) : buf);
6322
6504
  pendingStreams--;
6505
+ advance();
6323
6506
  maybeResolve();
6324
6507
  };
6325
6508
  stream.on("end", finish);
@@ -6340,11 +6523,24 @@ function openAndScan(buffer, manifestPath = AAB_MANIFEST_PATH) {
6340
6523
  uncompressedSize: entry.uncompressedSize
6341
6524
  });
6342
6525
  }
6526
+ const moduleName = manifestPath === AAB_MANIFEST_PATH ? MODULE_MANIFEST_RE.exec(path)?.[1] : void 0;
6527
+ const readModuleManifest = moduleName !== void 0 && entry.uncompressedSize <= MAX_MODULE_MANIFEST_BYTES && moduleManifestReads < MAX_MODULE_MANIFESTS;
6343
6528
  if (path === manifestPath) {
6344
6529
  readEntryStream(entry, (buf) => {
6345
6530
  manifestBuf = buf;
6346
6531
  });
6347
- } else if (SO_PATH_RE.test(path)) {
6532
+ } else if (moduleName !== void 0 && readModuleManifest) {
6533
+ moduleManifestReads++;
6534
+ readEntryStream(
6535
+ entry,
6536
+ (buf) => {
6537
+ moduleManifests.set(moduleName, buf);
6538
+ },
6539
+ MAX_MODULE_MANIFEST_BYTES,
6540
+ true
6541
+ );
6542
+ } else if (SO_PATH_RE.test(path) && soHeaderReads < MAX_SO_HEADER_READS) {
6543
+ soHeaderReads++;
6348
6544
  readEntryStream(
6349
6545
  entry,
6350
6546
  (buf) => {
@@ -6352,8 +6548,9 @@ function openAndScan(buffer, manifestPath = AAB_MANIFEST_PATH) {
6352
6548
  },
6353
6549
  SO_HEADER_BYTES
6354
6550
  );
6551
+ } else {
6552
+ advance();
6355
6553
  }
6356
- zipfile.readEntry();
6357
6554
  } catch (e) {
6358
6555
  fail(e instanceof Error ? e : new Error(String(e)));
6359
6556
  }
@@ -6457,20 +6654,18 @@ var manifestScanner = {
6457
6654
  }
6458
6655
  if (manifest.targetSdk >= 34) {
6459
6656
  const hasFgsPerm = manifest.permissions.includes("android.permission.FOREGROUND_SERVICE");
6460
- if (hasFgsPerm) {
6461
- for (const service of manifest.services) {
6462
- if (!service.foregroundServiceType) {
6463
- findings.push({
6464
- scanner: "manifest",
6465
- ruleId: "foreground-service-type-missing",
6466
- severity: "error",
6467
- title: `Missing foregroundServiceType on ${service.name}`,
6468
- message: `Service "${service.name}" does not declare android:foregroundServiceType. This is required for apps targeting API 34+.`,
6469
- suggestion: `Add android:foregroundServiceType to the <service> declaration. Valid types: camera, connectedDevice, dataSync, health, location, mediaPlayback, mediaProcessing, mediaProjection, microphone, phoneCall, remoteMessaging, shortService, specialUse, systemExempted.`,
6470
- policyUrl: "https://developer.android.com/about/versions/14/changes/fgs-types-required"
6471
- });
6472
- }
6473
- }
6657
+ const anyTyped = manifest.services.some((s) => s.foregroundServiceType);
6658
+ if (hasFgsPerm && !anyTyped && manifest.services.length > 0) {
6659
+ const typedPerms = manifest.permissions.filter((p) => p.startsWith("android.permission.FOREGROUND_SERVICE_")).map((p) => p.replace("android.permission.", ""));
6660
+ findings.push({
6661
+ scanner: "manifest",
6662
+ ruleId: "foreground-service-type-missing",
6663
+ severity: "warning",
6664
+ title: "No service declares a foregroundServiceType",
6665
+ message: `The app requests FOREGROUND_SERVICE${typedPerms.length > 0 ? ` (and ${typedPerms.join(", ")})` : ""} but none of its ${manifest.services.length} services declares android:foregroundServiceType. On API 34+, any service that calls startForeground() must declare a type or it crashes at runtime. Services that never run in the foreground do not need one.`,
6666
+ suggestion: 'Add android:foregroundServiceType to each service you start in the foreground (for example dataSync, mediaPlayback, location). If none do, remove the FOREGROUND_SERVICE permission or add this rule to "disabledRules".',
6667
+ policyUrl: "https://developer.android.com/about/versions/14/changes/fgs-types-required"
6668
+ });
6474
6669
  }
6475
6670
  }
6476
6671
  const hasBackgroundLocation = manifest.permissions.includes(
@@ -6788,9 +6983,36 @@ var permissionsScanner = {
6788
6983
  }
6789
6984
  };
6790
6985
 
6986
+ // src/preflight/bundle-layout.ts
6987
+ var LIB_PATH_RE = /^(?:[^/]+\/)?lib\/([^/]+)\/[^/]+\.so$/;
6988
+ function isBundleMetadata(path) {
6989
+ return path.startsWith("BUNDLE-METADATA/") || path.startsWith("META-INF/") || path === "BundleConfig.pb";
6990
+ }
6991
+ function nativeLibAbi(path) {
6992
+ return LIB_PATH_RE.exec(path)?.[1];
6993
+ }
6994
+ function firstInstallEntries(entries, isAppBundle, deferredModules = []) {
6995
+ if (!isAppBundle) return [...entries];
6996
+ const deferred = new Set(deferredModules);
6997
+ return entries.filter(
6998
+ (e) => !isBundleMetadata(e.path) && !deferred.has(e.path.split("/", 1)[0] ?? "")
6999
+ );
7000
+ }
7001
+ function largestAbi(entries, size) {
7002
+ const perAbi = /* @__PURE__ */ new Map();
7003
+ for (const entry of entries) {
7004
+ const abi = nativeLibAbi(entry.path);
7005
+ if (abi) perAbi.set(abi, (perAbi.get(abi) ?? 0) + size(entry));
7006
+ }
7007
+ let largest;
7008
+ for (const [abi, bytes] of perAbi) {
7009
+ if (!largest || bytes > largest.bytes) largest = { abi, bytes };
7010
+ }
7011
+ return largest;
7012
+ }
7013
+
6791
7014
  // src/preflight/scanners/native-libs-scanner.ts
6792
7015
  var KNOWN_ABIS = ["arm64-v8a", "armeabi-v7a", "x86", "x86_64"];
6793
- var LIB_PATH_RE = /^(?:[^/]+\/)?lib\/([^/]+)\/[^/]+\.so$/;
6794
7016
  var ELF_MAGIC = 2135247942;
6795
7017
  var PT_LOAD = 1;
6796
7018
  var PAGE_16KB = 16384;
@@ -6848,7 +7070,7 @@ var nativeLibsScanner = {
6848
7070
  const abisFound = /* @__PURE__ */ new Set();
6849
7071
  let totalNativeSize = 0;
6850
7072
  for (const entry of entries) {
6851
- const abi = LIB_PATH_RE.exec(entry.path)?.[1];
7073
+ const abi = nativeLibAbi(entry.path);
6852
7074
  if (abi) {
6853
7075
  abisFound.add(abi);
6854
7076
  totalNativeSize += entry.uncompressedSize;
@@ -6884,6 +7106,20 @@ var nativeLibsScanner = {
6884
7106
  });
6885
7107
  }
6886
7108
  check16KBAlignment(ctx.nativeLibHeaders, ctx.manifest, findings);
7109
+ if (ctx.nativeLibHeaders) {
7110
+ const soCount = entries.filter((e) => nativeLibAbi(e.path) && e.path.endsWith(".so")).length;
7111
+ const unchecked = soCount - ctx.nativeLibHeaders.size;
7112
+ if (unchecked > 0) {
7113
+ findings.push({
7114
+ scanner: "native-libs",
7115
+ ruleId: "native-libs-16kb-unchecked",
7116
+ severity: "warning",
7117
+ title: `${unchecked} of ${soCount} native libraries not checked for 16KB alignment`,
7118
+ 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.`,
7119
+ 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."
7120
+ });
7121
+ }
7122
+ }
6887
7123
  const detectedAbis = KNOWN_ABIS.filter((abi) => abisFound.has(abi));
6888
7124
  const unknownAbis = [...abisFound].filter(
6889
7125
  (abi) => !KNOWN_ABIS.includes(abi)
@@ -6897,14 +7133,21 @@ var nativeLibsScanner = {
6897
7133
  title: `Native libraries: ${abiList}`,
6898
7134
  message: `Found native libraries for ${abisFound.size} architecture(s): ${abiList}. Total uncompressed size: ${sizeMb} MB.`
6899
7135
  });
6900
- if (totalNativeSize > 150 * 1024 * 1024) {
7136
+ const bundle = ctx.isAppBundle ?? false;
7137
+ const perDevice = bundle ? largestAbi(
7138
+ firstInstallEntries(entries, true, ctx.deferredModules),
7139
+ (e) => e.uncompressedSize
7140
+ ) : void 0;
7141
+ const perDeviceSize = bundle ? perDevice?.bytes ?? 0 : totalNativeSize;
7142
+ if (perDeviceSize > 150 * 1024 * 1024) {
7143
+ const perDeviceMb = (perDeviceSize / (1024 * 1024)).toFixed(1);
6901
7144
  findings.push({
6902
7145
  scanner: "native-libs",
6903
7146
  ruleId: "native-libs-large",
6904
7147
  severity: "warning",
6905
7148
  title: "Large native libraries",
6906
- message: `Native libraries total ${sizeMb} MB (uncompressed). This significantly increases download size.`,
6907
- suggestion: "Consider using Android App Bundles to deliver only the required ABI per device. Review if all native libraries are necessary."
7149
+ message: perDevice ? `Native libraries for ${perDevice.abi} total ${perDeviceMb} MB (uncompressed). Each device on that ABI downloads all of them.` : `Native libraries total ${sizeMb} MB (uncompressed). This significantly increases download size.`,
7150
+ suggestion: perDevice ? "Review whether all native libraries are necessary, and move optional native code into on-demand feature modules." : "Consider using Android App Bundles to deliver only the required ABI per device. Review if all native libraries are necessary."
6908
7151
  });
6909
7152
  }
6910
7153
  return findings;
@@ -7559,6 +7802,9 @@ var policyScanner = {
7559
7802
  };
7560
7803
 
7561
7804
  // src/preflight/scanners/size-scanner.ts
7805
+ var MB = 1024 * 1024;
7806
+ var PLAY_MOBILE_DATA_DIALOG_MB = 200;
7807
+ var mb = (bytes) => (bytes / MB).toFixed(1);
7562
7808
  var sizeScanner = {
7563
7809
  name: "size",
7564
7810
  description: "Analyzes app bundle size and warns on large downloads",
@@ -7572,21 +7818,28 @@ var sizeScanner = {
7572
7818
  const maxMb = ctx.config.maxDownloadSizeMb;
7573
7819
  const totalCompressed = entries.reduce((sum, e) => sum + e.compressedSize, 0);
7574
7820
  const totalUncompressed = entries.reduce((sum, e) => sum + e.uncompressedSize, 0);
7575
- const compressedMb = totalCompressed / (1024 * 1024);
7576
- const uncompressedMb = totalUncompressed / (1024 * 1024);
7577
- if (compressedMb > maxMb) {
7821
+ const bundle = ctx.isAppBundle ?? false;
7822
+ const installed = firstInstallEntries(entries, bundle, ctx.deferredModules);
7823
+ const nativeAbi = bundle ? largestAbi(installed, (e) => e.compressedSize) : void 0;
7824
+ const delivered = bundle ? installed.filter((e) => {
7825
+ const abi = nativeLibAbi(e.path);
7826
+ return !abi || abi === nativeAbi?.abi;
7827
+ }) : installed;
7828
+ const downloadBytes = delivered.reduce((sum, e) => sum + e.compressedSize, 0);
7829
+ if (downloadBytes / MB > maxMb) {
7830
+ const size = bundle ? `Estimated download for one device is ${mb(downloadBytes)} MB${nativeAbi ? ` (with ${nativeAbi.abi} native libraries)` : ""}` : `The APK is ${mb(downloadBytes)} MB compressed`;
7578
7831
  findings.push({
7579
7832
  scanner: "size",
7580
7833
  ruleId: "size-over-limit",
7581
7834
  severity: "warning",
7582
7835
  title: `Download size exceeds ${maxMb} MB`,
7583
- message: `Compressed size is ${compressedMb.toFixed(1)} MB. Downloads over ${maxMb} MB show a mobile data warning to users, which reduces install rates.`,
7584
- suggestion: "Use Android App Bundles for split APKs, remove unused resources, enable R8/ProGuard, and compress assets.",
7585
- policyUrl: "https://developer.android.com/topic/performance/reduce-apk-size"
7836
+ message: `${size}, above your ${maxMb} MB threshold.` + (downloadBytes > PLAY_MOBILE_DATA_DIALOG_MB * MB ? ` Google Play shows users on mobile data a large-download dialog for apps over ${PLAY_MOBILE_DATA_DIALOG_MB} MB, which can reduce install rates.` : ""),
7837
+ suggestion: bundle ? "Remove unused resources, enable R8, and move large or optional content into fast-follow or on-demand asset packs or feature modules." : "Publish an Android App Bundle so each device downloads only its own ABI and screen density, remove unused resources, and enable R8.",
7838
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/9859372"
7586
7839
  });
7587
7840
  }
7588
7841
  const categories = /* @__PURE__ */ new Map();
7589
- for (const entry of entries) {
7842
+ for (const entry of delivered) {
7590
7843
  const cat = detectCategory(entry.path);
7591
7844
  const existing = categories.get(cat) ?? { compressed: 0, uncompressed: 0, count: 0 };
7592
7845
  existing.compressed += entry.compressedSize;
@@ -7595,13 +7848,13 @@ var sizeScanner = {
7595
7848
  categories.set(cat, existing);
7596
7849
  }
7597
7850
  const nativeLibs = categories.get("native-libs");
7598
- if (nativeLibs && nativeLibs.compressed > 50 * 1024 * 1024) {
7851
+ if (nativeLibs && nativeLibs.compressed > 50 * MB) {
7599
7852
  findings.push({
7600
7853
  scanner: "size",
7601
7854
  ruleId: "size-large-native",
7602
7855
  severity: "warning",
7603
7856
  title: "Large native libraries",
7604
- message: `Native libraries are ${(nativeLibs.compressed / (1024 * 1024)).toFixed(1)} MB (compressed). This is the largest contributor to download size.`,
7857
+ message: nativeAbi ? `Native libraries for ${nativeAbi.abi} are ${mb(nativeLibs.compressed)} MB (compressed), and each device on that ABI downloads all of them.` : `Native libraries are ${mb(nativeLibs.compressed)} MB (compressed) across every ABI in the APK.`,
7605
7858
  suggestion: "Review which native libraries are bundled. Consider using dynamic feature modules for optional native code."
7606
7859
  });
7607
7860
  }
@@ -7612,18 +7865,18 @@ var sizeScanner = {
7612
7865
  ruleId: "size-large-assets",
7613
7866
  severity: "info",
7614
7867
  title: "Large assets directory",
7615
- message: `Assets are ${(assets.compressed / (1024 * 1024)).toFixed(1)} MB (compressed). Consider using Play Asset Delivery for large assets.`,
7868
+ message: `Assets are ${mb(assets.compressed)} MB (compressed). Consider using Play Asset Delivery for large assets.`,
7616
7869
  suggestion: "Move large assets to Play Asset Delivery (install-time, fast-follow, or on-demand packs).",
7617
7870
  policyUrl: "https://developer.android.com/guide/playcore/asset-delivery"
7618
7871
  });
7619
7872
  }
7620
- const breakdown = [...categories.entries()].sort((a, b) => b[1].compressed - a[1].compressed).map(([cat, data]) => `${cat}: ${(data.compressed / (1024 * 1024)).toFixed(1)} MB`).join(", ");
7873
+ const breakdown = [...categories.entries()].sort((a, b) => b[1].compressed - a[1].compressed).map(([cat, data]) => `${cat}: ${mb(data.compressed)} MB`).join(", ");
7621
7874
  findings.push({
7622
7875
  scanner: "size",
7623
7876
  ruleId: "size-summary",
7624
7877
  severity: "info",
7625
- title: `Total size: ${compressedMb.toFixed(1)} MB compressed, ${uncompressedMb.toFixed(1)} MB uncompressed`,
7626
- message: `${entries.length} files. Breakdown: ${breakdown}`
7878
+ title: bundle ? `Estimated download: ${mb(downloadBytes)} MB per device (bundle file ${mb(totalCompressed)} MB compressed, ${mb(totalUncompressed)} MB uncompressed)` : `Total size: ${mb(totalCompressed)} MB compressed, ${mb(totalUncompressed)} MB uncompressed`,
7879
+ message: bundle ? `${delivered.length} of ${entries.length} files reach one device. Breakdown: ${breakdown}` : `${entries.length} files. Breakdown: ${breakdown}`
7627
7880
  });
7628
7881
  return findings;
7629
7882
  }
@@ -7631,10 +7884,10 @@ var sizeScanner = {
7631
7884
  function detectCategory(path) {
7632
7885
  const lower = path.toLowerCase();
7633
7886
  if (lower.endsWith(".dex") || /\/dex\//.test(lower)) return "dex";
7634
- if (/\/lib\/[^/]+\/[^/]+\.so$/.test(lower)) return "native-libs";
7635
- if (/\/res\//.test(lower) || lower.endsWith("/resources.pb") || lower.endsWith("/resources.arsc"))
7887
+ if (nativeLibAbi(path)) return "native-libs";
7888
+ if (/(?:^|\/)res\//.test(lower) || /(?:^|\/)resources\.(?:pb|arsc)$/.test(lower))
7636
7889
  return "resources";
7637
- if (/\/assets\//.test(lower)) return "assets";
7890
+ if (/(?:^|\/)assets\//.test(lower)) return "assets";
7638
7891
  if (lower.includes("androidmanifest.xml") || /\/manifest\//.test(lower)) return "manifest";
7639
7892
  if (lower.startsWith("meta-inf/")) return "signing";
7640
7893
  return "other";
@@ -7670,6 +7923,8 @@ async function runPreflight(options) {
7670
7923
  ctx.manifest = aab.manifest;
7671
7924
  ctx.zipEntries = aab.entries;
7672
7925
  ctx.nativeLibHeaders = aab.nativeLibHeaders;
7926
+ ctx.isAppBundle = aab.isAppBundle;
7927
+ ctx.deferredModules = aab.deferredModules;
7673
7928
  if (aab.manifest._parseError) {
7674
7929
  earlyFindings.push({
7675
7930
  scanner: "manifest-parser",
@@ -9628,7 +9883,7 @@ async function sha256File(filePath) {
9628
9883
  }
9629
9884
 
9630
9885
  // src/commands/image-sync.ts
9631
- import { readdir as readdir7, stat as stat10 } from "fs/promises";
9886
+ import { readdir as readdir7 } from "fs/promises";
9632
9887
  import { join as join13, extname as extname5 } from "path";
9633
9888
  import { PlayApiError } from "@gpc-cli/api";
9634
9889
  var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg"]);
@@ -9643,12 +9898,20 @@ var ALL_IMAGE_TYPES2 = [
9643
9898
  "wearScreenshots"
9644
9899
  ];
9645
9900
  async function scanLocalImages(dir) {
9901
+ let entries;
9646
9902
  try {
9647
- const entries = await readdir7(dir, { withFileTypes: true });
9648
- return entries.filter((e) => e.isFile() && IMAGE_EXTENSIONS.has(extname5(e.name).toLowerCase())).map((e) => e.name).sort();
9649
- } catch {
9650
- return [];
9903
+ entries = await readdir7(dir, { withFileTypes: true });
9904
+ } catch (error) {
9905
+ const code = error.code;
9906
+ if (code === "ENOENT" || code === "ENOTDIR") return null;
9907
+ throw new GpcError(
9908
+ `Cannot read image directory "${dir}": ${code ?? String(error)}`,
9909
+ "IMAGE_SYNC_DIR_UNREADABLE",
9910
+ 1,
9911
+ "Check the directory's permissions (and container volume ownership in CI). Nothing was changed on Google Play."
9912
+ );
9651
9913
  }
9914
+ return entries.filter((e) => e.isFile() && IMAGE_EXTENSIONS.has(extname5(e.name).toLowerCase())).map((e) => e.name).sort();
9652
9915
  }
9653
9916
  async function scanLanguages(dir) {
9654
9917
  try {
@@ -9658,13 +9921,6 @@ async function scanLanguages(dir) {
9658
9921
  return [];
9659
9922
  }
9660
9923
  }
9661
- async function dirExists(path) {
9662
- try {
9663
- return (await stat10(path)).isDirectory();
9664
- } catch {
9665
- return false;
9666
- }
9667
- }
9668
9924
  async function syncImages(client, packageName, dir, options) {
9669
9925
  const details = [];
9670
9926
  let uploaded = 0;
@@ -9685,7 +9941,8 @@ async function syncImages(client, packageName, dir, options) {
9685
9941
  for (const language of languages) {
9686
9942
  for (const imageType of imageTypes) {
9687
9943
  const localDir = join13(dir, language, imageType);
9688
- const localFiles = await scanLocalImages(localDir);
9944
+ const scanned = await scanLocalImages(localDir);
9945
+ const localFiles = scanned ?? [];
9689
9946
  let remoteImages;
9690
9947
  try {
9691
9948
  remoteImages = await client.images.list(packageName, edit.id, language, imageType);
@@ -9727,7 +9984,7 @@ async function syncImages(client, packageName, dir, options) {
9727
9984
  details.push({ language, imageType, file, action: "upload", reason });
9728
9985
  };
9729
9986
  if (options?.delete) {
9730
- if (localFiles.length === 0 && remoteImages.length > 0 && !await dirExists(localDir)) {
9987
+ if (scanned === null && remoteImages.length > 0) {
9731
9988
  for (const img of remoteImages) {
9732
9989
  details.push({
9733
9990
  language,
@@ -10151,6 +10408,7 @@ export {
10151
10408
  batchGetOrders,
10152
10409
  batchSyncInAppProducts,
10153
10410
  buildChecklist,
10411
+ buildExternalTransactionRefund,
10154
10412
  buildLocaleBundle,
10155
10413
  bundleToReleaseNotes,
10156
10414
  cancelRecoveryAction,