@gpc-cli/core 0.9.32 → 0.9.34

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
@@ -644,6 +644,29 @@ function formatSize(bytes) {
644
644
  }
645
645
 
646
646
  // src/commands/releases.ts
647
+ async function withRetryOnConflict(client, packageName, operation) {
648
+ const edit = await client.edits.insert(packageName);
649
+ try {
650
+ return await operation(edit);
651
+ } catch (error) {
652
+ const isConflict = error instanceof PlayApiError && error.statusCode === 409;
653
+ if (!isConflict) {
654
+ await client.edits.delete(packageName, edit.id).catch(() => {
655
+ });
656
+ throw error;
657
+ }
658
+ await client.edits.delete(packageName, edit.id).catch(() => {
659
+ });
660
+ const freshEdit = await client.edits.insert(packageName);
661
+ try {
662
+ return await operation(freshEdit);
663
+ } catch (retryError) {
664
+ await client.edits.delete(packageName, freshEdit.id).catch(() => {
665
+ });
666
+ throw retryError;
667
+ }
668
+ }
669
+ }
647
670
  function warnIfEditExpiring(edit) {
648
671
  if (!edit.expiryTimeSeconds) return;
649
672
  const expiryMs = Number(edit.expiryTimeSeconds) * 1e3;
@@ -770,8 +793,15 @@ async function getReleasesStatus(client, packageName, trackFilter) {
770
793
  }
771
794
  }
772
795
  async function promoteRelease(client, packageName, fromTrack, toTrack, options) {
773
- const edit = await client.edits.insert(packageName);
774
- try {
796
+ if (options?.userFraction && (options.userFraction <= 0 || options.userFraction > 1)) {
797
+ throw new GpcError(
798
+ "Rollout percentage must be between 0 and 1 (e.g., 0.1 for 10%)",
799
+ "RELEASE_INVALID_FRACTION",
800
+ 2,
801
+ "Use a decimal value like 0.1 for 10%, 0.5 for 50%, or 1.0 for 100%."
802
+ );
803
+ }
804
+ return withRetryOnConflict(client, packageName, async (edit) => {
775
805
  const sourceTrack = await client.tracks.get(packageName, edit.id, fromTrack);
776
806
  const currentRelease = sourceTrack.releases?.find(
777
807
  (r) => r.status === "completed" || r.status === "inProgress"
@@ -784,14 +814,6 @@ async function promoteRelease(client, packageName, fromTrack, toTrack, options)
784
814
  `Ensure there is a completed or in-progress release on the "${fromTrack}" track before promoting.`
785
815
  );
786
816
  }
787
- if (options?.userFraction && (options.userFraction <= 0 || options.userFraction > 1)) {
788
- throw new GpcError(
789
- "Rollout percentage must be between 0 and 1 (e.g., 0.1 for 10%)",
790
- "RELEASE_INVALID_FRACTION",
791
- 2,
792
- "Use a decimal value like 0.1 for 10%, 0.5 for 50%, or 1.0 for 100%."
793
- );
794
- }
795
817
  const release = {
796
818
  versionCodes: currentRelease.versionCodes,
797
819
  status: options?.userFraction ? "inProgress" : "completed",
@@ -807,11 +829,7 @@ async function promoteRelease(client, packageName, fromTrack, toTrack, options)
807
829
  versionCodes: release.versionCodes,
808
830
  userFraction: release.userFraction
809
831
  };
810
- } catch (error) {
811
- await client.edits.delete(packageName, edit.id).catch(() => {
812
- });
813
- throw error;
814
- }
832
+ });
815
833
  }
816
834
  async function updateRollout(client, packageName, track, action, userFraction) {
817
835
  const edit = await client.edits.insert(packageName);
@@ -951,6 +969,25 @@ async function updateTrackConfig(client, packageName, trackName, config) {
951
969
  throw error;
952
970
  }
953
971
  }
972
+ async function fetchReleaseNotes(client, packageName, track) {
973
+ const edit = await client.edits.insert(packageName);
974
+ try {
975
+ const trackData = await client.tracks.get(packageName, edit.id, track);
976
+ const release = trackData.releases?.find((r) => r.status === "completed" || r.status === "inProgress") ?? trackData.releases?.[0];
977
+ if (!release) {
978
+ throw new GpcError(
979
+ `No release found on track "${track}" to copy notes from`,
980
+ "RELEASE_NOT_FOUND",
981
+ 1,
982
+ `Ensure there is a release on the "${track}" track.`
983
+ );
984
+ }
985
+ return release.releaseNotes ?? [];
986
+ } finally {
987
+ await client.edits.delete(packageName, edit.id).catch(() => {
988
+ });
989
+ }
990
+ }
954
991
  async function diffReleases(client, packageName, fromTrack, toTrack) {
955
992
  const edit = await client.edits.insert(packageName);
956
993
  try {
@@ -1177,9 +1214,9 @@ async function readListingsFromDir(dir) {
1177
1214
  const listings = [];
1178
1215
  if (!await exists(dir)) return listings;
1179
1216
  const entries = await readdir(dir);
1180
- const SAFE_LANG = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/;
1217
+ const SAFE_LANG2 = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/;
1181
1218
  for (const lang of entries) {
1182
- if (!SAFE_LANG.test(lang)) continue;
1219
+ if (!SAFE_LANG2.test(lang)) continue;
1183
1220
  const langDir = join(dir, lang);
1184
1221
  const langStat = await stat4(langDir);
1185
1222
  if (!langStat.isDirectory()) continue;
@@ -1522,10 +1559,8 @@ async function uploadImage(client, packageName, language, imageType, filePath) {
1522
1559
  "Check image dimensions, file size, and format. Google Play requires PNG or JPEG images within specific size limits per image type."
1523
1560
  );
1524
1561
  }
1525
- if (imageCheck.warnings.length > 0) {
1526
- for (const w of imageCheck.warnings) {
1527
- console.warn(`Warning: ${w}`);
1528
- }
1562
+ for (const w of imageCheck.warnings) {
1563
+ process.emitWarning?.(w, "ImageUploadWarning");
1529
1564
  }
1530
1565
  const edit = await client.edits.insert(packageName);
1531
1566
  try {
@@ -1588,8 +1623,8 @@ var ALL_IMAGE_TYPES = [
1588
1623
  "tvBanner"
1589
1624
  ];
1590
1625
  async function exportImages(client, packageName, dir, options) {
1591
- const { mkdir: mkdir7, writeFile: writeFile9 } = await import("fs/promises");
1592
- const { join: join9 } = await import("path");
1626
+ const { mkdir: mkdir8, writeFile: writeFile10 } = await import("fs/promises");
1627
+ const { join: join12 } = await import("path");
1593
1628
  const edit = await client.edits.insert(packageName);
1594
1629
  try {
1595
1630
  let languages;
@@ -1620,12 +1655,12 @@ async function exportImages(client, packageName, dir, options) {
1620
1655
  const batch = tasks.slice(i, i + concurrency);
1621
1656
  const results = await Promise.all(
1622
1657
  batch.map(async (task) => {
1623
- const dirPath = join9(dir, task.language, task.imageType);
1624
- await mkdir7(dirPath, { recursive: true });
1658
+ const dirPath = join12(dir, task.language, task.imageType);
1659
+ await mkdir8(dirPath, { recursive: true });
1625
1660
  const response = await fetch(task.url);
1626
1661
  const buffer = Buffer.from(await response.arrayBuffer());
1627
- const filePath = join9(dirPath, `${task.index}.png`);
1628
- await writeFile9(filePath, buffer);
1662
+ const filePath = join12(dirPath, `${task.index}.png`);
1663
+ await writeFile10(filePath, buffer);
1629
1664
  return buffer.length;
1630
1665
  })
1631
1666
  );
@@ -2793,9 +2828,9 @@ var METRIC_SET_METRICS = {
2793
2828
  slowRenderingRateMetricSet: ["slowRenderingRate", "distinctUsers"],
2794
2829
  excessiveWakeupRateMetricSet: ["excessiveWakeupRate", "distinctUsers"],
2795
2830
  stuckBackgroundWakelockRateMetricSet: [
2796
- "stuckBackgroundWakelockRate",
2797
- "stuckBackgroundWakelockRate7dUserWeighted",
2798
- "stuckBackgroundWakelockRate28dUserWeighted",
2831
+ "stuckBgWakelockRate",
2832
+ "stuckBgWakelockRate7dUserWeighted",
2833
+ "stuckBgWakelockRate28dUserWeighted",
2799
2834
  "distinctUsers"
2800
2835
  ],
2801
2836
  errorCountMetricSet: ["errorReportCount", "distinctUsers"]
@@ -4301,6 +4336,1599 @@ function safePathWithin(userPath, baseDir) {
4301
4336
  return resolved;
4302
4337
  }
4303
4338
 
4339
+ // src/commands/init.ts
4340
+ import { mkdir as mkdir5, writeFile as writeFile6, stat as stat7 } from "fs/promises";
4341
+ import { join as join7 } from "path";
4342
+ async function exists2(path) {
4343
+ try {
4344
+ await stat7(path);
4345
+ return true;
4346
+ } catch {
4347
+ return false;
4348
+ }
4349
+ }
4350
+ async function safeWrite(filePath, content, created, skipped, skipExisting) {
4351
+ if (await exists2(filePath)) {
4352
+ if (skipExisting) {
4353
+ skipped.push(filePath);
4354
+ return;
4355
+ }
4356
+ }
4357
+ const dir = filePath.substring(0, filePath.lastIndexOf("/"));
4358
+ await mkdir5(dir, { recursive: true });
4359
+ await writeFile6(filePath, content, "utf-8");
4360
+ created.push(filePath);
4361
+ }
4362
+ async function initProject(options) {
4363
+ const { dir, app, ci } = options;
4364
+ const skipExisting = options.skipExisting !== false;
4365
+ const created = [];
4366
+ const skipped = [];
4367
+ const pkg = app || "com.example.app";
4368
+ const gpcrc = JSON.stringify(
4369
+ {
4370
+ app: pkg,
4371
+ output: "table"
4372
+ },
4373
+ null,
4374
+ 2
4375
+ );
4376
+ await safeWrite(join7(dir, ".gpcrc.json"), gpcrc + "\n", created, skipped, skipExisting);
4377
+ const preflightrc = JSON.stringify(
4378
+ {
4379
+ failOn: "error",
4380
+ targetSdkMinimum: 35,
4381
+ maxDownloadSizeMb: 150,
4382
+ allowedPermissions: [],
4383
+ disabledRules: [],
4384
+ severityOverrides: {}
4385
+ },
4386
+ null,
4387
+ 2
4388
+ );
4389
+ await safeWrite(
4390
+ join7(dir, ".preflightrc.json"),
4391
+ preflightrc + "\n",
4392
+ created,
4393
+ skipped,
4394
+ skipExisting
4395
+ );
4396
+ const metaDir = join7(dir, "metadata", "android", "en-US");
4397
+ await safeWrite(join7(metaDir, "title.txt"), "", created, skipped, skipExisting);
4398
+ await safeWrite(join7(metaDir, "short_description.txt"), "", created, skipped, skipExisting);
4399
+ await safeWrite(join7(metaDir, "full_description.txt"), "", created, skipped, skipExisting);
4400
+ await safeWrite(join7(metaDir, "video.txt"), "", created, skipped, skipExisting);
4401
+ const ssDir = join7(metaDir, "images", "phoneScreenshots");
4402
+ await mkdir5(ssDir, { recursive: true });
4403
+ await safeWrite(join7(ssDir, ".gitkeep"), "", created, skipped, skipExisting);
4404
+ if (ci === "github") {
4405
+ const workflow = githubActionsTemplate(pkg);
4406
+ const workflowDir = join7(dir, ".github", "workflows");
4407
+ await safeWrite(join7(workflowDir, "gpc-release.yml"), workflow, created, skipped, skipExisting);
4408
+ } else if (ci === "gitlab") {
4409
+ const pipeline = gitlabCiTemplate(pkg);
4410
+ await safeWrite(join7(dir, ".gitlab-ci-gpc.yml"), pipeline, created, skipped, skipExisting);
4411
+ }
4412
+ return { created, skipped };
4413
+ }
4414
+ function githubActionsTemplate(pkg) {
4415
+ return `name: GPC Release
4416
+ on:
4417
+ push:
4418
+ tags: ['v*']
4419
+
4420
+ jobs:
4421
+ release:
4422
+ runs-on: ubuntu-latest
4423
+ env:
4424
+ GPC_SERVICE_ACCOUNT: \${{ secrets.GPC_SERVICE_ACCOUNT }}
4425
+ GPC_APP: ${pkg}
4426
+ steps:
4427
+ - uses: actions/checkout@v4
4428
+
4429
+ - uses: actions/setup-node@v4
4430
+ with:
4431
+ node-version: 20
4432
+
4433
+ - name: Build
4434
+ run: ./gradlew bundleRelease
4435
+
4436
+ - name: Install GPC
4437
+ run: npm install -g @gpc-cli/cli
4438
+
4439
+ - name: Preflight compliance check
4440
+ run: gpc preflight app/build/outputs/bundle/release/app-release.aab --fail-on error
4441
+
4442
+ - name: Upload to internal track
4443
+ run: |
4444
+ gpc releases upload app/build/outputs/bundle/release/app-release.aab \\
4445
+ --track internal \\
4446
+ --json
4447
+ `;
4448
+ }
4449
+ function gitlabCiTemplate(pkg) {
4450
+ return `# GPC Release Pipeline
4451
+ # Add GPC_SERVICE_ACCOUNT as a CI/CD variable (masked, protected)
4452
+
4453
+ stages:
4454
+ - build
4455
+ - preflight
4456
+ - release
4457
+
4458
+ variables:
4459
+ GPC_APP: ${pkg}
4460
+
4461
+ build:
4462
+ stage: build
4463
+ image: gradle:jdk17
4464
+ script:
4465
+ - ./gradlew bundleRelease
4466
+ artifacts:
4467
+ paths:
4468
+ - app/build/outputs/bundle/release/app-release.aab
4469
+
4470
+ preflight:
4471
+ stage: preflight
4472
+ image: node:20
4473
+ script:
4474
+ - npm install -g @gpc-cli/cli
4475
+ - gpc preflight app/build/outputs/bundle/release/app-release.aab --fail-on error --json
4476
+
4477
+ release:
4478
+ stage: release
4479
+ image: node:20
4480
+ script:
4481
+ - npm install -g @gpc-cli/cli
4482
+ - gpc releases upload app/build/outputs/bundle/release/app-release.aab --track internal
4483
+ only:
4484
+ - tags
4485
+ `;
4486
+ }
4487
+
4488
+ // src/preflight/types.ts
4489
+ var SEVERITY_ORDER = {
4490
+ info: 0,
4491
+ warning: 1,
4492
+ error: 2,
4493
+ critical: 3
4494
+ };
4495
+ var DEFAULT_PREFLIGHT_CONFIG = {
4496
+ failOn: "error",
4497
+ targetSdkMinimum: 35,
4498
+ maxDownloadSizeMb: 150,
4499
+ allowedPermissions: [],
4500
+ disabledRules: [],
4501
+ severityOverrides: {}
4502
+ };
4503
+
4504
+ // src/preflight/config.ts
4505
+ import { readFile as readFile9 } from "fs/promises";
4506
+ var VALID_SEVERITIES = /* @__PURE__ */ new Set(["critical", "error", "warning", "info"]);
4507
+ async function loadPreflightConfig(configPath) {
4508
+ const path = configPath || ".preflightrc.json";
4509
+ let raw;
4510
+ try {
4511
+ raw = await readFile9(path, "utf-8");
4512
+ } catch {
4513
+ return { ...DEFAULT_PREFLIGHT_CONFIG };
4514
+ }
4515
+ let parsed;
4516
+ try {
4517
+ parsed = JSON.parse(raw);
4518
+ } catch {
4519
+ throw new Error(`Invalid JSON in ${path}. Check the file for syntax errors.`);
4520
+ }
4521
+ const config = { ...DEFAULT_PREFLIGHT_CONFIG };
4522
+ if (typeof parsed["failOn"] === "string" && VALID_SEVERITIES.has(parsed["failOn"])) {
4523
+ config.failOn = parsed["failOn"];
4524
+ }
4525
+ if (typeof parsed["targetSdkMinimum"] === "number" && parsed["targetSdkMinimum"] > 0) {
4526
+ config.targetSdkMinimum = parsed["targetSdkMinimum"];
4527
+ }
4528
+ if (typeof parsed["maxDownloadSizeMb"] === "number" && parsed["maxDownloadSizeMb"] > 0) {
4529
+ config.maxDownloadSizeMb = parsed["maxDownloadSizeMb"];
4530
+ }
4531
+ if (Array.isArray(parsed["allowedPermissions"])) {
4532
+ config.allowedPermissions = parsed["allowedPermissions"].filter(
4533
+ (v) => typeof v === "string"
4534
+ );
4535
+ }
4536
+ if (Array.isArray(parsed["disabledRules"])) {
4537
+ config.disabledRules = parsed["disabledRules"].filter(
4538
+ (v) => typeof v === "string"
4539
+ );
4540
+ }
4541
+ if (typeof parsed["severityOverrides"] === "object" && parsed["severityOverrides"] !== null) {
4542
+ const overrides = {};
4543
+ for (const [key, val] of Object.entries(
4544
+ parsed["severityOverrides"]
4545
+ )) {
4546
+ if (typeof val === "string" && VALID_SEVERITIES.has(val)) {
4547
+ overrides[key] = val;
4548
+ }
4549
+ }
4550
+ config.severityOverrides = overrides;
4551
+ }
4552
+ return config;
4553
+ }
4554
+
4555
+ // src/preflight/aab-reader.ts
4556
+ import { open as yauzlOpen } from "yauzl";
4557
+
4558
+ // src/preflight/manifest-parser.ts
4559
+ import * as protobuf from "protobufjs";
4560
+ var RESOURCE_IDS = {
4561
+ 16842752: "theme",
4562
+ 16842753: "label",
4563
+ 16842754: "icon",
4564
+ 16842755: "name",
4565
+ 16842767: "versionCode",
4566
+ 16842768: "versionName",
4567
+ 16843276: "minSdkVersion",
4568
+ 16843376: "targetSdkVersion",
4569
+ 16842782: "debuggable",
4570
+ 16842786: "permission",
4571
+ 16842795: "exported",
4572
+ 16843378: "testOnly",
4573
+ 16843760: "usesCleartextTraffic",
4574
+ 16844010: "extractNativeLibs",
4575
+ 16843985: "foregroundServiceType",
4576
+ 16843402: "required",
4577
+ 16843393: "allowBackup"
4578
+ };
4579
+ function buildXmlSchema() {
4580
+ const root = new protobuf.Root();
4581
+ const ns = root.define("aapt.pb");
4582
+ const Source = new protobuf.Type("Source").add(new protobuf.Field("pathIdx", 1, "uint32")).add(new protobuf.Field("position", 2, "Position"));
4583
+ const Position = new protobuf.Type("Position").add(new protobuf.Field("lineNumber", 1, "uint32")).add(new protobuf.Field("columnNumber", 2, "uint32"));
4584
+ const Primitive = new protobuf.Type("Primitive").add(
4585
+ new protobuf.OneOf("oneofValue").add(new protobuf.Field("intDecimalValue", 6, "int32")).add(new protobuf.Field("intHexadecimalValue", 7, "uint32")).add(new protobuf.Field("booleanValue", 8, "bool")).add(new protobuf.Field("floatValue", 11, "float"))
4586
+ );
4587
+ const Reference = new protobuf.Type("Reference").add(new protobuf.Field("id", 1, "uint32")).add(new protobuf.Field("name", 2, "string"));
4588
+ const Item = new protobuf.Type("Item").add(
4589
+ new protobuf.OneOf("value").add(new protobuf.Field("ref", 1, "Reference")).add(new protobuf.Field("str", 2, "String")).add(new protobuf.Field("prim", 4, "Primitive"))
4590
+ );
4591
+ const StringMsg = new protobuf.Type("String").add(new protobuf.Field("value", 1, "string"));
4592
+ const XmlAttribute = new protobuf.Type("XmlAttribute").add(new protobuf.Field("namespaceUri", 1, "string")).add(new protobuf.Field("name", 2, "string")).add(new protobuf.Field("value", 3, "string")).add(new protobuf.Field("source", 4, "Source")).add(new protobuf.Field("resourceId", 5, "uint32")).add(new protobuf.Field("compiledItem", 6, "Item"));
4593
+ const XmlNamespace = new protobuf.Type("XmlNamespace").add(new protobuf.Field("prefix", 1, "string")).add(new protobuf.Field("uri", 2, "string")).add(new protobuf.Field("source", 3, "Source"));
4594
+ const XmlElement = new protobuf.Type("XmlElement").add(new protobuf.Field("namespaceDeclaration", 1, "XmlNamespace", "repeated")).add(new protobuf.Field("namespaceUri", 2, "string")).add(new protobuf.Field("name", 3, "string")).add(new protobuf.Field("attribute", 4, "XmlAttribute", "repeated")).add(new protobuf.Field("child", 5, "XmlNode", "repeated"));
4595
+ const XmlNode = new protobuf.Type("XmlNode").add(
4596
+ new protobuf.OneOf("node").add(new protobuf.Field("element", 1, "XmlElement")).add(new protobuf.Field("text", 2, "string"))
4597
+ ).add(new protobuf.Field("source", 3, "Source"));
4598
+ ns.add(Position);
4599
+ ns.add(Source);
4600
+ ns.add(Primitive);
4601
+ ns.add(Reference);
4602
+ ns.add(StringMsg);
4603
+ ns.add(Item);
4604
+ ns.add(XmlAttribute);
4605
+ ns.add(XmlNamespace);
4606
+ ns.add(XmlElement);
4607
+ ns.add(XmlNode);
4608
+ return root;
4609
+ }
4610
+ var cachedSchema;
4611
+ function getSchema() {
4612
+ if (!cachedSchema) cachedSchema = buildXmlSchema();
4613
+ return cachedSchema;
4614
+ }
4615
+ function decodeManifest(buf) {
4616
+ const root = getSchema();
4617
+ const XmlNode = root.lookupType("aapt.pb.XmlNode");
4618
+ const decoded = XmlNode.decode(buf);
4619
+ if (!decoded.element || decoded.element.name !== "manifest") {
4620
+ throw new Error("Invalid AAB manifest: root element is not <manifest>");
4621
+ }
4622
+ return extractManifestData(decoded.element);
4623
+ }
4624
+ function getAttrValue(attrs, resId) {
4625
+ const attr = attrs.find((a) => a.resourceId === resId);
4626
+ if (!attr) return void 0;
4627
+ const ci = attr.compiledItem;
4628
+ if (ci?.str?.value !== void 0) return ci.str.value;
4629
+ if (ci?.ref?.name !== void 0) return ci.ref.name;
4630
+ return attr.value || void 0;
4631
+ }
4632
+ function getAttrByName(attrs, name) {
4633
+ const attr = attrs.find((a) => a.name === name || RESOURCE_IDS[a.resourceId] === name);
4634
+ if (!attr) return void 0;
4635
+ const ci = attr.compiledItem;
4636
+ if (ci?.str?.value !== void 0) return ci.str.value;
4637
+ if (ci?.ref?.name !== void 0) return ci.ref.name;
4638
+ return attr.value || void 0;
4639
+ }
4640
+ function getBoolAttr(attrs, resId, defaultVal) {
4641
+ const val = getAttrValue(attrs, resId);
4642
+ if (val === void 0) return defaultVal;
4643
+ return val === "true" || val === "1";
4644
+ }
4645
+ function getIntAttr(attrs, resId, defaultVal) {
4646
+ const val = getAttrValue(attrs, resId);
4647
+ if (val === void 0) return defaultVal;
4648
+ const n = parseInt(val, 10);
4649
+ return isNaN(n) ? defaultVal : n;
4650
+ }
4651
+ function getChildren(elem, tagName) {
4652
+ return (elem.child || []).filter((c) => c.element?.name === tagName).map((c) => c.element);
4653
+ }
4654
+ function extractManifestData(manifest) {
4655
+ const attrs = manifest.attribute || [];
4656
+ const packageName = getAttrByName(attrs, "package") || "";
4657
+ const versionCode = getIntAttr(attrs, 16842767, 0);
4658
+ const versionName = getAttrValue(attrs, 16842768) || "";
4659
+ const usesSdkElements = getChildren(manifest, "uses-sdk");
4660
+ const usesSdk = usesSdkElements[0];
4661
+ const minSdk = usesSdk ? getIntAttr(usesSdk.attribute || [], 16843276, 1) : 1;
4662
+ const targetSdk = usesSdk ? getIntAttr(usesSdk.attribute || [], 16843376, minSdk) : minSdk;
4663
+ const permissions = getChildren(manifest, "uses-permission").map((el) => getAttrValue(el.attribute || [], 16842755)).filter((p) => p !== void 0);
4664
+ const features = getChildren(manifest, "uses-feature").map((el) => ({
4665
+ name: getAttrValue(el.attribute || [], 16842755) || "",
4666
+ required: getBoolAttr(el.attribute || [], 16843402, true)
4667
+ }));
4668
+ const appElements = getChildren(manifest, "application");
4669
+ const app = appElements[0];
4670
+ const debuggable = app ? getBoolAttr(app.attribute || [], 16842782, false) : false;
4671
+ const testOnly = getBoolAttr(attrs, 16843378, false);
4672
+ const usesCleartextTraffic = app ? getBoolAttr(app.attribute || [], 16843760, true) : true;
4673
+ const extractNativeLibs = app ? getBoolAttr(app.attribute || [], 16844010, true) : true;
4674
+ const activities = app ? extractComponents(app, "activity") : [];
4675
+ const services = app ? extractComponents(app, "service") : [];
4676
+ const receivers = app ? extractComponents(app, "receiver") : [];
4677
+ const providers = app ? extractComponents(app, "provider") : [];
4678
+ return {
4679
+ packageName,
4680
+ versionCode,
4681
+ versionName,
4682
+ minSdk,
4683
+ targetSdk,
4684
+ debuggable,
4685
+ testOnly,
4686
+ usesCleartextTraffic,
4687
+ extractNativeLibs,
4688
+ permissions,
4689
+ features,
4690
+ activities,
4691
+ services,
4692
+ receivers,
4693
+ providers
4694
+ };
4695
+ }
4696
+ function extractComponents(appElement, tagName) {
4697
+ return getChildren(appElement, tagName).map((el) => {
4698
+ const compAttrs = el.attribute || [];
4699
+ const exportedVal = getAttrValue(compAttrs, 16842795);
4700
+ const hasIntentFilter = getChildren(el, "intent-filter").length > 0;
4701
+ return {
4702
+ name: getAttrValue(compAttrs, 16842755) || "",
4703
+ exported: exportedVal === void 0 ? void 0 : exportedVal === "true" || exportedVal === "1",
4704
+ foregroundServiceType: tagName === "service" ? getAttrValue(compAttrs, 16843985) : void 0,
4705
+ hasIntentFilter
4706
+ };
4707
+ });
4708
+ }
4709
+
4710
+ // src/preflight/aab-reader.ts
4711
+ var MANIFEST_PATH = "base/manifest/AndroidManifest.xml";
4712
+ async function readAab(aabPath) {
4713
+ const { zipfile, entries, manifestBuf } = await openAndScan(aabPath);
4714
+ zipfile.close();
4715
+ if (!manifestBuf) {
4716
+ throw new Error(
4717
+ `AAB is missing ${MANIFEST_PATH}. This does not appear to be a valid Android App Bundle.`
4718
+ );
4719
+ }
4720
+ const manifest = decodeManifest(manifestBuf);
4721
+ return { manifest, entries };
4722
+ }
4723
+ function openAndScan(aabPath) {
4724
+ return new Promise((resolve2, reject) => {
4725
+ yauzlOpen(aabPath, { lazyEntries: true, autoClose: false }, (err, zipfile) => {
4726
+ if (err || !zipfile) {
4727
+ reject(err ?? new Error("Failed to open AAB file"));
4728
+ return;
4729
+ }
4730
+ const entries = [];
4731
+ let manifestBuf = null;
4732
+ let rejected = false;
4733
+ function fail(error) {
4734
+ if (!rejected) {
4735
+ rejected = true;
4736
+ zipfile.close();
4737
+ reject(error);
4738
+ }
4739
+ }
4740
+ zipfile.on("error", fail);
4741
+ zipfile.on("entry", (entry) => {
4742
+ if (rejected) return;
4743
+ const path = entry.fileName;
4744
+ if (!path.endsWith("/")) {
4745
+ entries.push({
4746
+ path,
4747
+ compressedSize: entry.compressedSize,
4748
+ uncompressedSize: entry.uncompressedSize
4749
+ });
4750
+ }
4751
+ if (path === MANIFEST_PATH) {
4752
+ zipfile.openReadStream(entry, (streamErr, stream) => {
4753
+ if (streamErr || !stream) {
4754
+ fail(streamErr ?? new Error("Failed to read manifest entry"));
4755
+ return;
4756
+ }
4757
+ const chunks = [];
4758
+ stream.on("data", (chunk) => chunks.push(chunk));
4759
+ stream.on("error", (e) => fail(e));
4760
+ stream.on("end", () => {
4761
+ manifestBuf = Buffer.concat(chunks);
4762
+ zipfile.readEntry();
4763
+ });
4764
+ });
4765
+ } else {
4766
+ zipfile.readEntry();
4767
+ }
4768
+ });
4769
+ zipfile.on("end", () => {
4770
+ if (!rejected) {
4771
+ resolve2({ zipfile, entries, manifestBuf });
4772
+ }
4773
+ });
4774
+ zipfile.readEntry();
4775
+ });
4776
+ });
4777
+ }
4778
+
4779
+ // src/preflight/scanners/manifest-scanner.ts
4780
+ var manifestScanner = {
4781
+ name: "manifest",
4782
+ description: "Checks AndroidManifest.xml for target SDK, debug flags, and component declarations",
4783
+ requires: ["manifest"],
4784
+ async scan(ctx) {
4785
+ const manifest = ctx.manifest;
4786
+ const findings = [];
4787
+ const minTargetSdk = ctx.config.targetSdkMinimum;
4788
+ if (manifest.targetSdk < minTargetSdk) {
4789
+ findings.push({
4790
+ scanner: "manifest",
4791
+ ruleId: "targetSdk-below-minimum",
4792
+ severity: "critical",
4793
+ title: `targetSdkVersion ${manifest.targetSdk} is below the required ${minTargetSdk}`,
4794
+ message: `Google Play requires targetSdkVersion >= ${minTargetSdk} for new apps and updates. Your app targets API ${manifest.targetSdk}.`,
4795
+ suggestion: `Update targetSdkVersion to ${minTargetSdk} or higher in your build.gradle file.`,
4796
+ policyUrl: "https://developer.android.com/google/play/requirements/target-sdk"
4797
+ });
4798
+ }
4799
+ if (manifest.debuggable) {
4800
+ findings.push({
4801
+ scanner: "manifest",
4802
+ ruleId: "debuggable-true",
4803
+ severity: "critical",
4804
+ title: "App is marked as debuggable",
4805
+ message: 'android:debuggable="true" is set in the manifest. Google Play rejects debuggable release builds.',
4806
+ suggestion: "Remove android:debuggable from your manifest or set it to false. Release builds should never be debuggable."
4807
+ });
4808
+ }
4809
+ if (manifest.testOnly) {
4810
+ findings.push({
4811
+ scanner: "manifest",
4812
+ ruleId: "testOnly-true",
4813
+ severity: "critical",
4814
+ title: "App is marked as testOnly",
4815
+ message: 'android:testOnly="true" is set in the manifest. Google Play rejects testOnly builds.',
4816
+ suggestion: "Remove android:testOnly from your manifest. This flag is only for development builds."
4817
+ });
4818
+ }
4819
+ if (manifest.versionCode <= 0) {
4820
+ findings.push({
4821
+ scanner: "manifest",
4822
+ ruleId: "versionCode-invalid",
4823
+ severity: "error",
4824
+ title: "Invalid versionCode",
4825
+ message: `versionCode is ${manifest.versionCode}. It must be a positive integer.`,
4826
+ suggestion: "Set a valid versionCode in your build.gradle file."
4827
+ });
4828
+ }
4829
+ if (manifest.usesCleartextTraffic && manifest.targetSdk >= 28) {
4830
+ findings.push({
4831
+ scanner: "manifest",
4832
+ ruleId: "cleartext-traffic",
4833
+ severity: "warning",
4834
+ title: "Cleartext HTTP traffic is allowed",
4835
+ message: 'android:usesCleartextTraffic="true" allows unencrypted HTTP connections. This is a security risk.',
4836
+ suggestion: 'Set android:usesCleartextTraffic="false" and use HTTPS. If specific domains need HTTP, use a network security config.',
4837
+ policyUrl: "https://developer.android.com/privacy-and-security/security-config"
4838
+ });
4839
+ }
4840
+ if (manifest.targetSdk >= 31) {
4841
+ const allComponents = [
4842
+ ...manifest.activities,
4843
+ ...manifest.services,
4844
+ ...manifest.receivers,
4845
+ ...manifest.providers
4846
+ ];
4847
+ for (const comp of allComponents) {
4848
+ if (comp.hasIntentFilter && comp.exported === void 0) {
4849
+ findings.push({
4850
+ scanner: "manifest",
4851
+ ruleId: "missing-exported",
4852
+ severity: "error",
4853
+ title: `Missing android:exported on ${comp.name}`,
4854
+ message: `Component "${comp.name}" has an intent-filter but no android:exported attribute. This is required for apps targeting API 31+.`,
4855
+ suggestion: `Add android:exported="true" or android:exported="false" to the <activity>, <service>, <receiver>, or <provider> declaration for "${comp.name}".`,
4856
+ policyUrl: "https://developer.android.com/about/versions/12/behavior-changes-12#exported"
4857
+ });
4858
+ }
4859
+ }
4860
+ }
4861
+ if (manifest.targetSdk >= 34) {
4862
+ const hasFgsPerm = manifest.permissions.includes("android.permission.FOREGROUND_SERVICE");
4863
+ if (hasFgsPerm) {
4864
+ for (const service of manifest.services) {
4865
+ if (!service.foregroundServiceType) {
4866
+ findings.push({
4867
+ scanner: "manifest",
4868
+ ruleId: "foreground-service-type-missing",
4869
+ severity: "error",
4870
+ title: `Missing foregroundServiceType on ${service.name}`,
4871
+ message: `Service "${service.name}" does not declare android:foregroundServiceType. This is required for apps targeting API 34+.`,
4872
+ suggestion: `Add android:foregroundServiceType to the <service> declaration. Valid types: camera, connectedDevice, dataSync, health, location, mediaPlayback, mediaProcessing, mediaProjection, microphone, phoneCall, remoteMessaging, shortService, specialUse, systemExempted.`,
4873
+ policyUrl: "https://developer.android.com/about/versions/14/changes/fgs-types-required"
4874
+ });
4875
+ }
4876
+ }
4877
+ }
4878
+ }
4879
+ if (manifest.minSdk < 21) {
4880
+ findings.push({
4881
+ scanner: "manifest",
4882
+ ruleId: "minSdk-below-21",
4883
+ severity: "info",
4884
+ title: `minSdkVersion ${manifest.minSdk} is very low`,
4885
+ message: `minSdkVersion ${manifest.minSdk} means your app supports very old devices (pre-Lollipop). This limits split APK support and modern features.`,
4886
+ suggestion: "Consider raising minSdkVersion to 21 or higher to take advantage of modern Android features and better app size optimization."
4887
+ });
4888
+ }
4889
+ return findings;
4890
+ }
4891
+ };
4892
+
4893
+ // src/preflight/scanners/permissions-scanner.ts
4894
+ var RESTRICTED_PERMISSIONS = [
4895
+ // SMS permissions — only for default SMS handler
4896
+ {
4897
+ permission: "android.permission.READ_SMS",
4898
+ severity: "critical",
4899
+ title: "READ_SMS requires declaration form",
4900
+ message: "READ_SMS is restricted to default SMS/phone/assistant handler apps. Google Play requires a Permissions Declaration Form and may reject apps using this permission without approval.",
4901
+ suggestion: "Remove READ_SMS unless your app is a default SMS handler. Use the SMS Retriever API or one-tap SMS consent for OTP verification.",
4902
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/10208820"
4903
+ },
4904
+ {
4905
+ permission: "android.permission.SEND_SMS",
4906
+ severity: "critical",
4907
+ title: "SEND_SMS requires declaration form",
4908
+ message: "SEND_SMS is restricted to default SMS handler apps.",
4909
+ suggestion: "Remove SEND_SMS unless your app is a default SMS handler.",
4910
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/10208820"
4911
+ },
4912
+ {
4913
+ permission: "android.permission.RECEIVE_SMS",
4914
+ severity: "critical",
4915
+ title: "RECEIVE_SMS requires declaration form",
4916
+ message: "RECEIVE_SMS is restricted to default SMS handler apps.",
4917
+ suggestion: "Remove RECEIVE_SMS. Use the SMS Retriever API for OTP verification instead.",
4918
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/10208820"
4919
+ },
4920
+ {
4921
+ permission: "android.permission.RECEIVE_MMS",
4922
+ severity: "critical",
4923
+ title: "RECEIVE_MMS requires declaration form",
4924
+ message: "RECEIVE_MMS is restricted to default SMS handler apps.",
4925
+ suggestion: "Remove RECEIVE_MMS unless your app is a default SMS handler.",
4926
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/10208820"
4927
+ },
4928
+ {
4929
+ permission: "android.permission.RECEIVE_WAP_PUSH",
4930
+ severity: "critical",
4931
+ title: "RECEIVE_WAP_PUSH requires declaration form",
4932
+ message: "RECEIVE_WAP_PUSH is restricted to default SMS handler apps.",
4933
+ suggestion: "Remove RECEIVE_WAP_PUSH unless your app is a default SMS handler.",
4934
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/10208820"
4935
+ },
4936
+ // Call log permissions
4937
+ {
4938
+ permission: "android.permission.READ_CALL_LOG",
4939
+ severity: "critical",
4940
+ title: "READ_CALL_LOG requires declaration form",
4941
+ message: "READ_CALL_LOG is restricted to default phone/assistant handler apps.",
4942
+ suggestion: "Remove READ_CALL_LOG unless your app is a default phone or assistant handler.",
4943
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/10208820"
4944
+ },
4945
+ {
4946
+ permission: "android.permission.WRITE_CALL_LOG",
4947
+ severity: "critical",
4948
+ title: "WRITE_CALL_LOG requires declaration form",
4949
+ message: "WRITE_CALL_LOG is restricted to default phone handler apps.",
4950
+ suggestion: "Remove WRITE_CALL_LOG unless your app is a default phone handler.",
4951
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/10208820"
4952
+ },
4953
+ {
4954
+ permission: "android.permission.PROCESS_OUTGOING_CALLS",
4955
+ severity: "critical",
4956
+ title: "PROCESS_OUTGOING_CALLS requires declaration form",
4957
+ message: "PROCESS_OUTGOING_CALLS is restricted to default phone handler apps.",
4958
+ suggestion: "Remove PROCESS_OUTGOING_CALLS unless your app is a default phone handler.",
4959
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/10208820"
4960
+ },
4961
+ // Broad visibility
4962
+ {
4963
+ permission: "android.permission.QUERY_ALL_PACKAGES",
4964
+ severity: "error",
4965
+ title: "QUERY_ALL_PACKAGES requires justification",
4966
+ message: "QUERY_ALL_PACKAGES grants broad package visibility. Google Play requires justification and may reject apps using this without approval.",
4967
+ suggestion: "Replace with targeted <queries> elements in your manifest to declare specific packages you need to interact with.",
4968
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/10158779"
4969
+ },
4970
+ // All files access
4971
+ {
4972
+ permission: "android.permission.MANAGE_EXTERNAL_STORAGE",
4973
+ severity: "error",
4974
+ title: "MANAGE_EXTERNAL_STORAGE (All Files Access) requires declaration form",
4975
+ message: "All Files Access is restricted to file managers, backup apps, antivirus, and document management apps.",
4976
+ suggestion: "Use scoped storage APIs or the Storage Access Framework (SAF) instead. Only use MANAGE_EXTERNAL_STORAGE if your app's core functionality requires broad file access.",
4977
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/10467955"
4978
+ },
4979
+ // Background location
4980
+ {
4981
+ permission: "android.permission.ACCESS_BACKGROUND_LOCATION",
4982
+ severity: "error",
4983
+ title: "ACCESS_BACKGROUND_LOCATION requires declaration and review",
4984
+ message: "Background location access requires a Permissions Declaration Form, privacy policy, and video demonstration. Extended review times apply.",
4985
+ suggestion: "Use foreground location with a foreground service instead. Only use background location if it is essential to your app's core functionality.",
4986
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/9799150"
4987
+ },
4988
+ // Photo/video permissions (May 2025 enforcement)
4989
+ {
4990
+ permission: "android.permission.READ_MEDIA_IMAGES",
4991
+ severity: "error",
4992
+ title: "READ_MEDIA_IMAGES requires declaration or photo picker",
4993
+ message: "Photo/Video Permissions policy requires either an approved declaration form or use of the Android photo picker for one-time image access.",
4994
+ suggestion: "Use the Android photo picker (ACTION_PICK_IMAGES) for profile pictures and one-time use. Only declare READ_MEDIA_IMAGES if your app's core functionality requires broad gallery access.",
4995
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/14115180"
4996
+ },
4997
+ {
4998
+ permission: "android.permission.READ_MEDIA_VIDEO",
4999
+ severity: "error",
5000
+ title: "READ_MEDIA_VIDEO requires declaration or photo picker",
5001
+ message: "Photo/Video Permissions policy requires either an approved declaration form or use of the Android photo picker for one-time video access.",
5002
+ suggestion: "Use the Android photo picker for one-time video selection. Only declare READ_MEDIA_VIDEO if your app's core functionality requires broad video access.",
5003
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/14115180"
5004
+ },
5005
+ // Install packages
5006
+ {
5007
+ permission: "android.permission.REQUEST_INSTALL_PACKAGES",
5008
+ severity: "error",
5009
+ title: "REQUEST_INSTALL_PACKAGES requires justification",
5010
+ message: "REQUEST_INSTALL_PACKAGES is restricted to apps whose core purpose is installing other packages.",
5011
+ suggestion: "Remove REQUEST_INSTALL_PACKAGES unless your app is an app store, package manager, or OTA updater.",
5012
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/12085295"
5013
+ },
5014
+ // Exact alarm
5015
+ {
5016
+ permission: "android.permission.USE_EXACT_ALARM",
5017
+ severity: "warning",
5018
+ title: "USE_EXACT_ALARM is restricted",
5019
+ message: "USE_EXACT_ALARM is only for alarm, timer, and calendar apps. Google Play may reject apps using this without justification.",
5020
+ suggestion: "Use SCHEDULE_EXACT_ALARM instead if possible, or remove exact alarm usage if your app does not need precise timing.",
5021
+ policyUrl: "https://developer.android.com/about/versions/14/changes/schedule-exact-alarms"
5022
+ },
5023
+ // Full-screen intent
5024
+ {
5025
+ permission: "android.permission.USE_FULL_SCREEN_INTENT",
5026
+ severity: "warning",
5027
+ title: "USE_FULL_SCREEN_INTENT requires declaration",
5028
+ message: "Full-screen intents are restricted to alarm and calling apps on Android 14+. A declaration form is required.",
5029
+ suggestion: "Remove USE_FULL_SCREEN_INTENT unless your app is an alarm clock or calling app.",
5030
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/13392821"
5031
+ },
5032
+ // Accessibility service
5033
+ {
5034
+ permission: "android.permission.BIND_ACCESSIBILITY_SERVICE",
5035
+ severity: "error",
5036
+ title: "BIND_ACCESSIBILITY_SERVICE requires declaration and justification",
5037
+ message: "Accessibility services must support users with disabilities. A declaration form and detailed justification are required.",
5038
+ suggestion: "Only use BIND_ACCESSIBILITY_SERVICE if your app genuinely assists users with disabilities. Misuse leads to rejection.",
5039
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/10964491"
5040
+ },
5041
+ // VPN
5042
+ {
5043
+ permission: "android.permission.BIND_VPN_SERVICE",
5044
+ severity: "error",
5045
+ title: "BIND_VPN_SERVICE is restricted to VPN apps",
5046
+ message: "BIND_VPN_SERVICE is only for apps whose core functionality is providing VPN services.",
5047
+ suggestion: "Remove BIND_VPN_SERVICE unless your app is a VPN provider.",
5048
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/9888170"
5049
+ }
5050
+ ];
5051
+ var RESTRICTED_MAP = new Map(RESTRICTED_PERMISSIONS.map((r) => [r.permission, r]));
5052
+ var permissionsScanner = {
5053
+ name: "permissions",
5054
+ description: "Audits declared permissions against Google Play restricted permissions policies",
5055
+ requires: ["manifest"],
5056
+ async scan(ctx) {
5057
+ const manifest = ctx.manifest;
5058
+ const findings = [];
5059
+ const allowed = new Set(ctx.config.allowedPermissions);
5060
+ for (const perm of manifest.permissions) {
5061
+ if (allowed.has(perm)) continue;
5062
+ const restriction = RESTRICTED_MAP.get(perm);
5063
+ if (restriction) {
5064
+ findings.push({
5065
+ scanner: "permissions",
5066
+ ruleId: `restricted-${perm.split(".").pop()?.toLowerCase() || perm}`,
5067
+ severity: restriction.severity,
5068
+ title: restriction.title,
5069
+ message: restriction.message,
5070
+ suggestion: restriction.suggestion,
5071
+ policyUrl: restriction.policyUrl
5072
+ });
5073
+ }
5074
+ }
5075
+ const dataPermissions = [
5076
+ { perm: "android.permission.ACCESS_FINE_LOCATION", data: "precise location" },
5077
+ { perm: "android.permission.ACCESS_COARSE_LOCATION", data: "approximate location" },
5078
+ { perm: "android.permission.READ_CONTACTS", data: "contacts" },
5079
+ { perm: "android.permission.CAMERA", data: "photos/videos via camera" },
5080
+ { perm: "android.permission.RECORD_AUDIO", data: "audio recordings" },
5081
+ { perm: "android.permission.READ_CALENDAR", data: "calendar events" },
5082
+ { perm: "android.permission.BODY_SENSORS", data: "health/fitness data" },
5083
+ { perm: "android.permission.ACTIVITY_RECOGNITION", data: "physical activity" }
5084
+ ];
5085
+ const collectedData = [];
5086
+ for (const { perm, data } of dataPermissions) {
5087
+ if (manifest.permissions.includes(perm)) {
5088
+ collectedData.push(data);
5089
+ }
5090
+ }
5091
+ if (collectedData.length > 0) {
5092
+ findings.push({
5093
+ scanner: "permissions",
5094
+ ruleId: "data-safety-reminder",
5095
+ severity: "info",
5096
+ title: "Data Safety declaration reminder",
5097
+ message: `Your app declares permissions that imply collecting: ${collectedData.join(", ")}. Ensure your Data Safety form in Play Console accurately reflects this data collection.`,
5098
+ suggestion: "Review your Data Safety declaration at Play Console > Policy > App content > Data safety.",
5099
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/10787469"
5100
+ });
5101
+ }
5102
+ return findings;
5103
+ }
5104
+ };
5105
+
5106
+ // src/preflight/scanners/native-libs-scanner.ts
5107
+ var KNOWN_ABIS = ["arm64-v8a", "armeabi-v7a", "x86", "x86_64"];
5108
+ var LIB_PATH_RE = /^(?:[^/]+\/)?lib\/([^/]+)\/[^/]+\.so$/;
5109
+ var nativeLibsScanner = {
5110
+ name: "native-libs",
5111
+ description: "Checks native library architectures for 64-bit compliance",
5112
+ requires: ["zipEntries"],
5113
+ async scan(ctx) {
5114
+ const entries = ctx.zipEntries;
5115
+ const findings = [];
5116
+ const abisFound = /* @__PURE__ */ new Set();
5117
+ let totalNativeSize = 0;
5118
+ for (const entry of entries) {
5119
+ const match = LIB_PATH_RE.exec(entry.path);
5120
+ if (match) {
5121
+ abisFound.add(match[1]);
5122
+ totalNativeSize += entry.uncompressedSize;
5123
+ }
5124
+ }
5125
+ if (abisFound.size === 0) {
5126
+ return findings;
5127
+ }
5128
+ const has32Arm = abisFound.has("armeabi-v7a");
5129
+ const has64Arm = abisFound.has("arm64-v8a");
5130
+ const has32x86 = abisFound.has("x86");
5131
+ const has64x86 = abisFound.has("x86_64");
5132
+ if (has32Arm && !has64Arm) {
5133
+ findings.push({
5134
+ scanner: "native-libs",
5135
+ ruleId: "missing-arm64",
5136
+ severity: "critical",
5137
+ title: "Missing arm64-v8a native libraries",
5138
+ message: "App includes armeabi-v7a (32-bit ARM) native libraries but is missing arm64-v8a (64-bit ARM). Google Play requires 64-bit support for all apps with native code.",
5139
+ suggestion: "Build your native libraries for arm64-v8a. In build.gradle: ndk { abiFilters 'armeabi-v7a', 'arm64-v8a' }",
5140
+ policyUrl: "https://developer.android.com/google/play/requirements/64-bit"
5141
+ });
5142
+ }
5143
+ if (has32x86 && !has64x86) {
5144
+ findings.push({
5145
+ scanner: "native-libs",
5146
+ ruleId: "missing-x86_64",
5147
+ severity: "warning",
5148
+ title: "Missing x86_64 native libraries",
5149
+ message: "App includes x86 (32-bit) native libraries but is missing x86_64 (64-bit). While ARM is required, x86_64 is recommended for emulator and Chromebook support.",
5150
+ suggestion: "Add x86_64 to your ABI filters if you support x86: ndk { abiFilters 'x86', 'x86_64' }",
5151
+ policyUrl: "https://developer.android.com/google/play/requirements/64-bit"
5152
+ });
5153
+ }
5154
+ const detectedAbis = KNOWN_ABIS.filter((abi) => abisFound.has(abi));
5155
+ const unknownAbis = [...abisFound].filter(
5156
+ (abi) => !KNOWN_ABIS.includes(abi)
5157
+ );
5158
+ const abiList = [...detectedAbis, ...unknownAbis].join(", ");
5159
+ const sizeMb = (totalNativeSize / (1024 * 1024)).toFixed(1);
5160
+ findings.push({
5161
+ scanner: "native-libs",
5162
+ ruleId: "native-libs-summary",
5163
+ severity: "info",
5164
+ title: `Native libraries: ${abiList}`,
5165
+ message: `Found native libraries for ${abisFound.size} architecture(s): ${abiList}. Total uncompressed size: ${sizeMb} MB.`
5166
+ });
5167
+ if (totalNativeSize > 150 * 1024 * 1024) {
5168
+ findings.push({
5169
+ scanner: "native-libs",
5170
+ ruleId: "native-libs-large",
5171
+ severity: "warning",
5172
+ title: "Large native libraries",
5173
+ message: `Native libraries total ${sizeMb} MB (uncompressed). This significantly increases download size.`,
5174
+ suggestion: "Consider using Android App Bundles to deliver only the required ABI per device. Review if all native libraries are necessary."
5175
+ });
5176
+ }
5177
+ return findings;
5178
+ }
5179
+ };
5180
+
5181
+ // src/preflight/scanners/metadata-scanner.ts
5182
+ import { readdir as readdir5, stat as stat8, readFile as readFile10 } from "fs/promises";
5183
+ import { join as join8 } from "path";
5184
+ var SAFE_LANG = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/;
5185
+ var FILE_MAP2 = {
5186
+ "title.txt": "title",
5187
+ "short_description.txt": "shortDescription",
5188
+ "full_description.txt": "fullDescription",
5189
+ "video.txt": "video"
5190
+ };
5191
+ var SCREENSHOT_DIRS = [
5192
+ "phoneScreenshots",
5193
+ "sevenInchScreenshots",
5194
+ "tenInchScreenshots",
5195
+ "tvScreenshots",
5196
+ "wearScreenshots"
5197
+ ];
5198
+ var MIN_PHONE_SCREENSHOTS = 2;
5199
+ var RECOMMENDED_PHONE_SCREENSHOTS = 4;
5200
+ var metadataScanner = {
5201
+ name: "metadata",
5202
+ description: "Checks store listing metadata for character limits, required fields, and screenshots",
5203
+ requires: ["metadataDir"],
5204
+ async scan(ctx) {
5205
+ const dir = ctx.metadataDir;
5206
+ const findings = [];
5207
+ let entries;
5208
+ try {
5209
+ entries = await readdir5(dir);
5210
+ } catch {
5211
+ findings.push({
5212
+ scanner: "metadata",
5213
+ ruleId: "metadata-dir-not-found",
5214
+ severity: "error",
5215
+ title: "Metadata directory not found",
5216
+ message: `Cannot read metadata directory: ${dir}`,
5217
+ suggestion: "Check the path to your metadata directory. Expected Fastlane format: <dir>/<lang>/title.txt, short_description.txt, etc."
5218
+ });
5219
+ return findings;
5220
+ }
5221
+ const locales = entries.filter((e) => SAFE_LANG.test(e));
5222
+ if (locales.length === 0) {
5223
+ findings.push({
5224
+ scanner: "metadata",
5225
+ ruleId: "no-locales-found",
5226
+ severity: "error",
5227
+ title: "No locale directories found",
5228
+ message: `No valid locale directories found in ${dir}. Expected subdirectories like en-US/, fr-FR/, etc.`,
5229
+ suggestion: "Create locale directories with listing files: <dir>/en-US/title.txt"
5230
+ });
5231
+ return findings;
5232
+ }
5233
+ for (const lang of locales) {
5234
+ const langDir = join8(dir, lang);
5235
+ const langStat = await stat8(langDir).catch(() => null);
5236
+ if (!langStat?.isDirectory()) continue;
5237
+ const fields = {};
5238
+ for (const [fileName, field] of Object.entries(FILE_MAP2)) {
5239
+ const filePath = join8(langDir, fileName);
5240
+ try {
5241
+ const content = await readFile10(filePath, "utf-8");
5242
+ fields[field] = content.trimEnd();
5243
+ } catch {
5244
+ }
5245
+ }
5246
+ const lintResult = lintListing(lang, fields, DEFAULT_LIMITS);
5247
+ for (const field of lintResult.fields) {
5248
+ if (field.status === "over") {
5249
+ findings.push({
5250
+ scanner: "metadata",
5251
+ ruleId: `listing-${field.field}-over-limit`,
5252
+ severity: "error",
5253
+ title: `${lang}: ${field.field} exceeds ${field.limit} character limit`,
5254
+ message: `${field.field} is ${field.chars} characters (limit: ${field.limit}). Google Play will reject this listing.`,
5255
+ suggestion: `Shorten ${field.field} to ${field.limit} characters or fewer.`
5256
+ });
5257
+ } else if (field.status === "warn") {
5258
+ findings.push({
5259
+ scanner: "metadata",
5260
+ ruleId: `listing-${field.field}-near-limit`,
5261
+ severity: "info",
5262
+ title: `${lang}: ${field.field} is ${field.pct}% of limit`,
5263
+ message: `${field.field} is ${field.chars}/${field.limit} characters (${field.pct}%).`
5264
+ });
5265
+ }
5266
+ }
5267
+ if (!fields["title"]?.trim()) {
5268
+ findings.push({
5269
+ scanner: "metadata",
5270
+ ruleId: "listing-missing-title",
5271
+ severity: "error",
5272
+ title: `${lang}: Missing title`,
5273
+ message: `No title.txt found or file is empty for locale ${lang}.`,
5274
+ suggestion: "Create a title.txt file with your app name (max 30 characters)."
5275
+ });
5276
+ }
5277
+ let totalScreenshots = 0;
5278
+ let phoneScreenshots = 0;
5279
+ for (const ssDir of SCREENSHOT_DIRS) {
5280
+ const ssPath = join8(langDir, "images", ssDir);
5281
+ try {
5282
+ const ssEntries = await readdir5(ssPath);
5283
+ const imageFiles = ssEntries.filter((f) => /\.(png|jpe?g|webp)$/i.test(f));
5284
+ totalScreenshots += imageFiles.length;
5285
+ if (ssDir === "phoneScreenshots") {
5286
+ phoneScreenshots = imageFiles.length;
5287
+ }
5288
+ } catch {
5289
+ }
5290
+ }
5291
+ if (phoneScreenshots < MIN_PHONE_SCREENSHOTS && totalScreenshots === 0) {
5292
+ findings.push({
5293
+ scanner: "metadata",
5294
+ ruleId: "listing-no-screenshots",
5295
+ severity: "warning",
5296
+ title: `${lang}: No screenshots found`,
5297
+ message: `No screenshot images found for locale ${lang}. Google Play requires at least 2 phone screenshots.`,
5298
+ suggestion: `Add PNG or JPEG screenshots to ${lang}/images/phoneScreenshots/`,
5299
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/9866151"
5300
+ });
5301
+ } else if (phoneScreenshots < RECOMMENDED_PHONE_SCREENSHOTS && phoneScreenshots > 0) {
5302
+ findings.push({
5303
+ scanner: "metadata",
5304
+ ruleId: "listing-few-screenshots",
5305
+ severity: "info",
5306
+ title: `${lang}: Only ${phoneScreenshots} phone screenshot(s)`,
5307
+ message: `Found ${phoneScreenshots} phone screenshot(s). Google recommends at least ${RECOMMENDED_PHONE_SCREENSHOTS} for better store presence.`
5308
+ });
5309
+ }
5310
+ }
5311
+ const defaultLang = locales.includes("en-US") ? "en-US" : locales[0];
5312
+ const privacyPath = join8(dir, defaultLang, "privacy_policy_url.txt");
5313
+ try {
5314
+ const url = await readFile10(privacyPath, "utf-8");
5315
+ if (!url.trim()) throw new Error("empty");
5316
+ } catch {
5317
+ findings.push({
5318
+ scanner: "metadata",
5319
+ ruleId: "listing-no-privacy-policy",
5320
+ severity: "warning",
5321
+ title: "No privacy policy URL",
5322
+ message: "No privacy_policy_url.txt found in metadata. A privacy policy is required for most apps on Google Play.",
5323
+ suggestion: `Create ${defaultLang}/privacy_policy_url.txt with a link to your privacy policy.`,
5324
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/9859455"
5325
+ });
5326
+ }
5327
+ findings.push({
5328
+ scanner: "metadata",
5329
+ ruleId: "metadata-summary",
5330
+ severity: "info",
5331
+ title: `${locales.length} locale(s) found`,
5332
+ message: `Scanned metadata for: ${locales.join(", ")}`
5333
+ });
5334
+ return findings;
5335
+ }
5336
+ };
5337
+
5338
+ // src/preflight/scanners/secrets-scanner.ts
5339
+ import { readFile as readFile11 } from "fs/promises";
5340
+
5341
+ // src/preflight/scan-files.ts
5342
+ import { readdir as readdir6, stat as stat9 } from "fs/promises";
5343
+ import { join as join9, extname as extname4 } from "path";
5344
+ var DEFAULT_SKIP_DIRS = /* @__PURE__ */ new Set([
5345
+ ".git",
5346
+ "node_modules",
5347
+ "build",
5348
+ "dist",
5349
+ ".gradle",
5350
+ "__pycache__",
5351
+ ".idea",
5352
+ ".vscode",
5353
+ "vendor"
5354
+ ]);
5355
+ async function collectSourceFiles(dir, extensions, skipDirs = DEFAULT_SKIP_DIRS, maxDepth = 10) {
5356
+ if (maxDepth <= 0) return [];
5357
+ const files = [];
5358
+ let entries;
5359
+ try {
5360
+ entries = await readdir6(dir);
5361
+ } catch {
5362
+ return files;
5363
+ }
5364
+ for (const entry of entries) {
5365
+ if (skipDirs.has(entry)) continue;
5366
+ const fullPath = join9(dir, entry);
5367
+ const s = await stat9(fullPath).catch(() => null);
5368
+ if (!s) continue;
5369
+ if (s.isDirectory()) {
5370
+ const sub = await collectSourceFiles(fullPath, extensions, skipDirs, maxDepth - 1);
5371
+ files.push(...sub);
5372
+ } else if (s.isFile()) {
5373
+ const ext = extname4(entry).toLowerCase();
5374
+ if (extensions.has(ext) || entry.endsWith(".gradle.kts")) {
5375
+ files.push(fullPath);
5376
+ }
5377
+ }
5378
+ }
5379
+ return files;
5380
+ }
5381
+
5382
+ // src/preflight/scanners/secrets-scanner.ts
5383
+ var SECRET_PATTERNS = [
5384
+ {
5385
+ ruleId: "secret-aws-key",
5386
+ name: "AWS Access Key",
5387
+ pattern: /AKIA[0-9A-Z]{16}/,
5388
+ severity: "critical",
5389
+ suggestion: "Use environment variables or AWS Secrets Manager. Never hardcode AWS credentials."
5390
+ },
5391
+ {
5392
+ ruleId: "secret-google-api-key",
5393
+ name: "Google API Key",
5394
+ pattern: /AIza[0-9A-Za-z\-_]{35}/,
5395
+ severity: "critical",
5396
+ suggestion: "Move Google API keys to local.properties or environment variables. Restrict keys in Google Cloud Console."
5397
+ },
5398
+ {
5399
+ ruleId: "secret-stripe-key",
5400
+ name: "Stripe Secret Key",
5401
+ pattern: /sk_live_[0-9a-zA-Z]{24,}/,
5402
+ severity: "critical",
5403
+ suggestion: "Never ship Stripe secret keys in client code. Use your backend server for Stripe API calls."
5404
+ },
5405
+ {
5406
+ ruleId: "secret-stripe-restricted",
5407
+ name: "Stripe Restricted Key",
5408
+ pattern: /rk_live_[0-9a-zA-Z]{24,}/,
5409
+ severity: "critical",
5410
+ suggestion: "Stripe restricted keys should not be in client code. Use server-side integration."
5411
+ },
5412
+ {
5413
+ ruleId: "secret-private-key",
5414
+ name: "Private Key",
5415
+ pattern: /-----BEGIN\s+(RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/,
5416
+ severity: "critical",
5417
+ suggestion: "Remove private keys from source code. Store them in a secure key management system."
5418
+ },
5419
+ {
5420
+ ruleId: "secret-firebase-key",
5421
+ name: "Firebase API Key in code",
5422
+ pattern: /["']AIza[0-9A-Za-z\-_]{35}["']/,
5423
+ severity: "warning",
5424
+ suggestion: "Firebase API keys in client code are normal for google-services.json, but verify they are restricted in Google Cloud Console."
5425
+ },
5426
+ {
5427
+ ruleId: "secret-generic-token",
5428
+ name: "Generic API Token",
5429
+ pattern: /(?:api[_-]?key|api[_-]?secret|auth[_-]?token|access[_-]?token)\s*[:=]\s*["'][a-zA-Z0-9\-_]{20,}["']/i,
5430
+ severity: "warning",
5431
+ suggestion: "Avoid hardcoding tokens. Use BuildConfig fields, environment variables, or a secrets manager."
5432
+ }
5433
+ ];
5434
+ var SCAN_EXTENSIONS = /* @__PURE__ */ new Set([
5435
+ ".ts",
5436
+ ".js",
5437
+ ".tsx",
5438
+ ".jsx",
5439
+ ".kt",
5440
+ ".java",
5441
+ ".xml",
5442
+ ".json",
5443
+ ".properties",
5444
+ ".yaml",
5445
+ ".yml",
5446
+ ".gradle"
5447
+ ]);
5448
+ var secretsScanner = {
5449
+ name: "secrets",
5450
+ description: "Scans source code for hardcoded credentials and API keys",
5451
+ requires: ["sourceDir"],
5452
+ async scan(ctx) {
5453
+ const dir = ctx.sourceDir;
5454
+ const findings = [];
5455
+ const files = await collectSourceFiles(dir, SCAN_EXTENSIONS);
5456
+ for (const filePath of files) {
5457
+ let content;
5458
+ try {
5459
+ content = await readFile11(filePath, "utf-8");
5460
+ } catch {
5461
+ continue;
5462
+ }
5463
+ const lines = content.split("\n");
5464
+ for (let i = 0; i < lines.length; i++) {
5465
+ const line = lines[i];
5466
+ for (const pattern of SECRET_PATTERNS) {
5467
+ if (pattern.pattern.test(line)) {
5468
+ const relativePath = filePath.startsWith(dir) ? filePath.slice(dir.length + 1) : filePath;
5469
+ findings.push({
5470
+ scanner: "secrets",
5471
+ ruleId: pattern.ruleId,
5472
+ severity: pattern.severity,
5473
+ title: `${pattern.name} found in ${relativePath}:${i + 1}`,
5474
+ message: `Potential ${pattern.name} detected at ${relativePath} line ${i + 1}.`,
5475
+ suggestion: pattern.suggestion
5476
+ });
5477
+ break;
5478
+ }
5479
+ }
5480
+ }
5481
+ }
5482
+ return findings;
5483
+ }
5484
+ };
5485
+
5486
+ // src/preflight/scanners/billing-scanner.ts
5487
+ import { readFile as readFile12 } from "fs/promises";
5488
+ var BILLING_PATTERNS = [
5489
+ {
5490
+ ruleId: "billing-stripe-sdk",
5491
+ name: "Stripe SDK",
5492
+ pattern: /(?:com\.stripe|@stripe\/|stripe-android|StripeSdk)/,
5493
+ message: "Stripe SDK detected. Google Play requires Play Billing for in-app purchases of digital goods.",
5494
+ suggestion: "If selling digital goods, use Google Play Billing Library. Stripe is only allowed for physical goods, services, or out-of-app purchases."
5495
+ },
5496
+ {
5497
+ ruleId: "billing-braintree-sdk",
5498
+ name: "Braintree SDK",
5499
+ pattern: /(?:com\.braintreepayments|braintree-android)/,
5500
+ message: "Braintree SDK detected. Google Play requires Play Billing for digital in-app purchases.",
5501
+ suggestion: "Use Google Play Billing Library for digital goods. Braintree is only allowed for physical goods and services."
5502
+ },
5503
+ {
5504
+ ruleId: "billing-paypal-sdk",
5505
+ name: "PayPal SDK",
5506
+ pattern: /(?:com\.paypal|paypal-android)/,
5507
+ message: "PayPal SDK detected. Google Play requires Play Billing for digital in-app purchases.",
5508
+ suggestion: "Use Google Play Billing Library for digital goods. PayPal is allowed for physical goods only."
5509
+ },
5510
+ {
5511
+ ruleId: "billing-razorpay-sdk",
5512
+ name: "Razorpay SDK",
5513
+ pattern: /(?:com\.razorpay)/,
5514
+ message: "Razorpay SDK detected. If used for digital goods, this may violate Google Play billing policy.",
5515
+ suggestion: "Ensure Razorpay is only used for physical goods/services. Digital goods require Play Billing."
5516
+ },
5517
+ {
5518
+ ruleId: "billing-checkout-sdk",
5519
+ name: "Alternative checkout SDK",
5520
+ pattern: /(?:com\.adyen|com\.checkout|com\.square\.sdk)/,
5521
+ message: "Alternative payment SDK detected. Google Play requires Play Billing for digital goods.",
5522
+ suggestion: "Verify this payment SDK is only used for physical goods or services, not digital content."
5523
+ }
5524
+ ];
5525
+ var SCAN_EXTENSIONS2 = /* @__PURE__ */ new Set([
5526
+ ".kt",
5527
+ ".java",
5528
+ ".xml",
5529
+ ".gradle",
5530
+ ".ts",
5531
+ ".js",
5532
+ ".tsx",
5533
+ ".jsx",
5534
+ ".json"
5535
+ ]);
5536
+ var billingScanner = {
5537
+ name: "billing",
5538
+ description: "Detects non-Play billing SDKs that may violate Google Play billing policy",
5539
+ requires: ["sourceDir"],
5540
+ async scan(ctx) {
5541
+ const dir = ctx.sourceDir;
5542
+ const findings = [];
5543
+ const detectedSdks = /* @__PURE__ */ new Set();
5544
+ const files = await collectSourceFiles(dir, SCAN_EXTENSIONS2);
5545
+ for (const filePath of files) {
5546
+ let content;
5547
+ try {
5548
+ content = await readFile12(filePath, "utf-8");
5549
+ } catch {
5550
+ continue;
5551
+ }
5552
+ for (const bp of BILLING_PATTERNS) {
5553
+ if (detectedSdks.has(bp.ruleId)) continue;
5554
+ if (bp.pattern.test(content)) {
5555
+ const relativePath = filePath.startsWith(dir) ? filePath.slice(dir.length + 1) : filePath;
5556
+ detectedSdks.add(bp.ruleId);
5557
+ findings.push({
5558
+ scanner: "billing",
5559
+ ruleId: bp.ruleId,
5560
+ severity: "warning",
5561
+ title: `${bp.name} detected`,
5562
+ message: `${bp.message} Found in ${relativePath}.`,
5563
+ suggestion: bp.suggestion,
5564
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/10281818"
5565
+ });
5566
+ }
5567
+ }
5568
+ }
5569
+ return findings;
5570
+ }
5571
+ };
5572
+
5573
+ // src/preflight/scanners/privacy-scanner.ts
5574
+ import { readFile as readFile13 } from "fs/promises";
5575
+ var TRACKING_SDKS = [
5576
+ {
5577
+ name: "Facebook SDK",
5578
+ pattern: /(?:com\.facebook\.sdk|com\.facebook\.android|FacebookSdk\.sdkInitialize)/i
5579
+ },
5580
+ { name: "Adjust SDK", pattern: /(?:com\.adjust\.sdk|AdjustConfig|AdjustEvent)/i },
5581
+ {
5582
+ name: "AppsFlyer SDK",
5583
+ pattern: /(?:com\.appsflyer|AppsFlyerLib|AppsFlyerConversionListener)/i
5584
+ },
5585
+ { name: "Amplitude SDK", pattern: /(?:com\.amplitude|AmplitudeClient|@amplitude\/analytics)/i },
5586
+ { name: "Mixpanel SDK", pattern: /(?:com\.mixpanel|MixpanelAPI|@mixpanel)/i },
5587
+ { name: "Branch SDK", pattern: /(?:io\.branch\.referral|Branch\.getInstance)/i },
5588
+ { name: "CleverTap SDK", pattern: /(?:com\.clevertap|CleverTapAPI)/i },
5589
+ { name: "Singular SDK", pattern: /(?:com\.singular\.sdk|SingularConfig)/i }
5590
+ ];
5591
+ var SCAN_EXTENSIONS3 = /* @__PURE__ */ new Set([
5592
+ ".kt",
5593
+ ".java",
5594
+ ".xml",
5595
+ ".gradle",
5596
+ ".ts",
5597
+ ".js",
5598
+ ".tsx",
5599
+ ".jsx",
5600
+ ".json"
5601
+ ]);
5602
+ var privacyScanner = {
5603
+ name: "privacy",
5604
+ description: "Detects tracking SDKs and data collection patterns for Data Safety compliance",
5605
+ requires: ["sourceDir"],
5606
+ async scan(ctx) {
5607
+ const dir = ctx.sourceDir;
5608
+ const findings = [];
5609
+ const detectedSdks = /* @__PURE__ */ new Set();
5610
+ const files = await collectSourceFiles(dir, SCAN_EXTENSIONS3);
5611
+ for (const filePath of files) {
5612
+ let content;
5613
+ try {
5614
+ content = await readFile13(filePath, "utf-8");
5615
+ } catch {
5616
+ continue;
5617
+ }
5618
+ for (const sdk of TRACKING_SDKS) {
5619
+ if (detectedSdks.has(sdk.name)) continue;
5620
+ if (sdk.pattern.test(content)) {
5621
+ detectedSdks.add(sdk.name);
5622
+ const relativePath = filePath.startsWith(dir) ? filePath.slice(dir.length + 1) : filePath;
5623
+ findings.push({
5624
+ scanner: "privacy",
5625
+ ruleId: `tracking-${sdk.name.toLowerCase().replace(/\s+/g, "-")}`,
5626
+ severity: "warning",
5627
+ title: `${sdk.name} detected`,
5628
+ message: `${sdk.name} found in ${relativePath}. This SDK typically collects analytics or attribution data that must be declared in your Data Safety form.`,
5629
+ suggestion: "Ensure your Data Safety declaration accurately lists all data types collected by this SDK.",
5630
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/10787469"
5631
+ });
5632
+ }
5633
+ }
5634
+ if (content.includes("AD_ID") || content.includes("ADVERTISING_ID") || content.includes("AdvertisingIdClient")) {
5635
+ if (!detectedSdks.has("_ad_id")) {
5636
+ detectedSdks.add("_ad_id");
5637
+ findings.push({
5638
+ scanner: "privacy",
5639
+ ruleId: "advertising-id-usage",
5640
+ severity: "warning",
5641
+ title: "Advertising ID usage detected",
5642
+ message: "Your app appears to access the Advertising ID. This must be declared in your Data Safety form under 'Device or other IDs'.",
5643
+ suggestion: "Declare Advertising ID collection in Play Console > Data safety. If your app targets children, Advertising ID usage is restricted.",
5644
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/11043825"
5645
+ });
5646
+ }
5647
+ }
5648
+ }
5649
+ if (ctx.manifest) {
5650
+ const dataPermissions = [
5651
+ { perm: "android.permission.ACCESS_FINE_LOCATION", dataType: "precise location" },
5652
+ { perm: "android.permission.ACCESS_COARSE_LOCATION", dataType: "approximate location" },
5653
+ { perm: "android.permission.READ_CONTACTS", dataType: "contacts" },
5654
+ { perm: "android.permission.CAMERA", dataType: "photos/videos" },
5655
+ { perm: "android.permission.RECORD_AUDIO", dataType: "audio" },
5656
+ { perm: "android.permission.READ_CALENDAR", dataType: "calendar" },
5657
+ { perm: "android.permission.BODY_SENSORS", dataType: "health/fitness data" },
5658
+ { perm: "android.permission.READ_PHONE_STATE", dataType: "phone state/device ID" }
5659
+ ];
5660
+ const collectedTypes = [];
5661
+ for (const { perm, dataType } of dataPermissions) {
5662
+ if (ctx.manifest.permissions.includes(perm)) {
5663
+ collectedTypes.push(dataType);
5664
+ }
5665
+ }
5666
+ if (collectedTypes.length > 0 && detectedSdks.size > 0) {
5667
+ findings.push({
5668
+ scanner: "privacy",
5669
+ ruleId: "data-collection-cross-reference",
5670
+ severity: "info",
5671
+ title: "Data collection cross-reference",
5672
+ message: `Your app requests permissions for: ${collectedTypes.join(", ")}. Combined with ${detectedSdks.size} tracking SDK(s), ensure your Data Safety form declares all collected data types.`,
5673
+ suggestion: "Review your Data Safety form at Play Console > Policy > App content > Data safety.",
5674
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/10787469"
5675
+ });
5676
+ }
5677
+ }
5678
+ return findings;
5679
+ }
5680
+ };
5681
+
5682
+ // src/preflight/scanners/policy-scanner.ts
5683
+ var policyScanner = {
5684
+ name: "policy",
5685
+ description: "Heuristic checks for Google Play policy compliance (families, financial, health, gambling)",
5686
+ requires: ["manifest"],
5687
+ async scan(ctx) {
5688
+ const manifest = ctx.manifest;
5689
+ const findings = [];
5690
+ const perms = new Set(manifest.permissions);
5691
+ if (manifest.targetSdk >= 28) {
5692
+ const childrenIndicators = [
5693
+ perms.has("android.permission.READ_CONTACTS"),
5694
+ perms.has("android.permission.ACCESS_FINE_LOCATION"),
5695
+ perms.has("android.permission.RECORD_AUDIO")
5696
+ ];
5697
+ const hasChildFeatures = manifest.features.some(
5698
+ (f) => f.name.includes("kids") || f.name.includes("children") || f.name.includes("education")
5699
+ );
5700
+ if (hasChildFeatures && childrenIndicators.some(Boolean)) {
5701
+ findings.push({
5702
+ scanner: "policy",
5703
+ ruleId: "policy-families-data-collection",
5704
+ severity: "warning",
5705
+ title: "Potential Families Policy concern",
5706
+ message: "App appears to target children (based on features) but requests sensitive permissions (location, contacts, or audio). Apps in the Families program have strict data collection limits.",
5707
+ suggestion: "Review the Families Policy requirements. Apps for children must minimize data collection and cannot use advertising ID.",
5708
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/9893335"
5709
+ });
5710
+ }
5711
+ }
5712
+ const financialPerms = [
5713
+ perms.has("android.permission.READ_SMS"),
5714
+ perms.has("android.permission.RECEIVE_SMS"),
5715
+ perms.has("android.permission.BIND_AUTOFILL_SERVICE")
5716
+ ];
5717
+ if (financialPerms.filter(Boolean).length >= 2) {
5718
+ findings.push({
5719
+ scanner: "policy",
5720
+ ruleId: "policy-financial-app",
5721
+ severity: "warning",
5722
+ title: "Potential financial app detected",
5723
+ message: "App requests SMS + autofill permissions, common in financial apps. Financial apps must comply with additional disclosure and security requirements.",
5724
+ suggestion: "Ensure your app meets Google Play's financial services policy. Declare appropriate app category in Play Console.",
5725
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/9876821"
5726
+ });
5727
+ }
5728
+ if (perms.has("android.permission.BODY_SENSORS") || perms.has("android.permission.ACTIVITY_RECOGNITION")) {
5729
+ findings.push({
5730
+ scanner: "policy",
5731
+ ruleId: "policy-health-app",
5732
+ severity: "info",
5733
+ title: "Health/fitness app detected",
5734
+ message: "App requests body sensor or activity recognition permissions. Health apps must comply with health data policies.",
5735
+ suggestion: "Review Google Play's health app policy. Ensure accurate health claims and proper data handling disclosures.",
5736
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/10787469"
5737
+ });
5738
+ }
5739
+ const ugcIndicators = [
5740
+ perms.has("android.permission.CAMERA"),
5741
+ perms.has("android.permission.RECORD_AUDIO"),
5742
+ perms.has("android.permission.READ_MEDIA_IMAGES")
5743
+ ];
5744
+ if (ugcIndicators.filter(Boolean).length >= 2) {
5745
+ findings.push({
5746
+ scanner: "policy",
5747
+ ruleId: "policy-ugc-content",
5748
+ severity: "info",
5749
+ title: "User-generated content indicators",
5750
+ message: "App requests camera + audio/media permissions, suggesting user-generated content. Apps with UGC must have content moderation.",
5751
+ suggestion: "Implement content moderation, reporting mechanisms, and content policies if your app allows user-generated content.",
5752
+ policyUrl: "https://support.google.com/googleplay/android-developer/answer/9876937"
5753
+ });
5754
+ }
5755
+ if (perms.has("android.permission.SYSTEM_ALERT_WINDOW")) {
5756
+ findings.push({
5757
+ scanner: "policy",
5758
+ ruleId: "policy-overlay",
5759
+ severity: "warning",
5760
+ title: "SYSTEM_ALERT_WINDOW (overlay) permission",
5761
+ message: "App requests overlay permission. This is restricted and must be justified. Misuse can lead to rejection.",
5762
+ suggestion: "Only use SYSTEM_ALERT_WINDOW if overlay display is core to your app's functionality."
5763
+ });
5764
+ }
5765
+ return findings;
5766
+ }
5767
+ };
5768
+
5769
+ // src/preflight/scanners/size-scanner.ts
5770
+ var sizeScanner = {
5771
+ name: "size",
5772
+ description: "Analyzes app bundle size and warns on large downloads",
5773
+ requires: ["zipEntries"],
5774
+ async scan(ctx) {
5775
+ const entries = ctx.zipEntries;
5776
+ const findings = [];
5777
+ const maxMb = ctx.config.maxDownloadSizeMb;
5778
+ const totalCompressed = entries.reduce((sum, e) => sum + e.compressedSize, 0);
5779
+ const totalUncompressed = entries.reduce((sum, e) => sum + e.uncompressedSize, 0);
5780
+ const compressedMb = totalCompressed / (1024 * 1024);
5781
+ const uncompressedMb = totalUncompressed / (1024 * 1024);
5782
+ if (compressedMb > maxMb) {
5783
+ findings.push({
5784
+ scanner: "size",
5785
+ ruleId: "size-over-limit",
5786
+ severity: "warning",
5787
+ title: `Download size exceeds ${maxMb} MB`,
5788
+ message: `Compressed size is ${compressedMb.toFixed(1)} MB. Downloads over ${maxMb} MB show a mobile data warning to users, which reduces install rates.`,
5789
+ suggestion: "Use Android App Bundles for split APKs, remove unused resources, enable R8/ProGuard, and compress assets.",
5790
+ policyUrl: "https://developer.android.com/topic/performance/reduce-apk-size"
5791
+ });
5792
+ }
5793
+ const categories = /* @__PURE__ */ new Map();
5794
+ for (const entry of entries) {
5795
+ const cat = detectCategory(entry.path);
5796
+ const existing = categories.get(cat) ?? { compressed: 0, uncompressed: 0, count: 0 };
5797
+ existing.compressed += entry.compressedSize;
5798
+ existing.uncompressed += entry.uncompressedSize;
5799
+ existing.count += 1;
5800
+ categories.set(cat, existing);
5801
+ }
5802
+ const nativeLibs = categories.get("native-libs");
5803
+ if (nativeLibs && nativeLibs.compressed > 50 * 1024 * 1024) {
5804
+ findings.push({
5805
+ scanner: "size",
5806
+ ruleId: "size-large-native",
5807
+ severity: "warning",
5808
+ title: "Large native libraries",
5809
+ message: `Native libraries are ${(nativeLibs.compressed / (1024 * 1024)).toFixed(1)} MB (compressed). This is the largest contributor to download size.`,
5810
+ suggestion: "Review which native libraries are bundled. Consider using dynamic feature modules for optional native code."
5811
+ });
5812
+ }
5813
+ const assets = categories.get("assets");
5814
+ if (assets && assets.compressed > 30 * 1024 * 1024) {
5815
+ findings.push({
5816
+ scanner: "size",
5817
+ ruleId: "size-large-assets",
5818
+ severity: "info",
5819
+ title: "Large assets directory",
5820
+ message: `Assets are ${(assets.compressed / (1024 * 1024)).toFixed(1)} MB (compressed). Consider using Play Asset Delivery for large assets.`,
5821
+ suggestion: "Move large assets to Play Asset Delivery (install-time, fast-follow, or on-demand packs).",
5822
+ policyUrl: "https://developer.android.com/guide/playcore/asset-delivery"
5823
+ });
5824
+ }
5825
+ 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(", ");
5826
+ findings.push({
5827
+ scanner: "size",
5828
+ ruleId: "size-summary",
5829
+ severity: "info",
5830
+ title: `Total size: ${compressedMb.toFixed(1)} MB compressed, ${uncompressedMb.toFixed(1)} MB uncompressed`,
5831
+ message: `${entries.length} files. Breakdown: ${breakdown}`
5832
+ });
5833
+ return findings;
5834
+ }
5835
+ };
5836
+ function detectCategory(path) {
5837
+ const lower = path.toLowerCase();
5838
+ if (lower.endsWith(".dex") || /\/dex\//.test(lower)) return "dex";
5839
+ if (/\/lib\/[^/]+\/[^/]+\.so$/.test(lower)) return "native-libs";
5840
+ if (/\/res\//.test(lower) || lower.endsWith("/resources.pb") || lower.endsWith("/resources.arsc"))
5841
+ return "resources";
5842
+ if (/\/assets\//.test(lower)) return "assets";
5843
+ if (lower.includes("androidmanifest.xml") || /\/manifest\//.test(lower)) return "manifest";
5844
+ if (lower.startsWith("meta-inf/")) return "signing";
5845
+ return "other";
5846
+ }
5847
+
5848
+ // src/preflight/orchestrator.ts
5849
+ var ALL_SCANNERS = [
5850
+ manifestScanner,
5851
+ permissionsScanner,
5852
+ nativeLibsScanner,
5853
+ metadataScanner,
5854
+ secretsScanner,
5855
+ billingScanner,
5856
+ privacyScanner,
5857
+ policyScanner,
5858
+ sizeScanner
5859
+ ];
5860
+ function getAllScannerNames() {
5861
+ return ALL_SCANNERS.map((s) => s.name);
5862
+ }
5863
+ async function runPreflight(options) {
5864
+ const start = Date.now();
5865
+ const fileConfig = await loadPreflightConfig(options.configPath);
5866
+ const config = {
5867
+ ...fileConfig,
5868
+ failOn: options.failOn ?? fileConfig.failOn ?? DEFAULT_PREFLIGHT_CONFIG.failOn
5869
+ };
5870
+ const ctx = { config };
5871
+ if (options.aabPath) {
5872
+ ctx.aabPath = options.aabPath;
5873
+ const aab = await readAab(options.aabPath);
5874
+ ctx.manifest = aab.manifest;
5875
+ ctx.zipEntries = aab.entries;
5876
+ }
5877
+ if (options.metadataDir) ctx.metadataDir = options.metadataDir;
5878
+ if (options.sourceDir) ctx.sourceDir = options.sourceDir;
5879
+ const requestedNames = options.scanners ? new Set(options.scanners.map((s) => s.toLowerCase())) : null;
5880
+ const applicableScanners = ALL_SCANNERS.filter((scanner) => {
5881
+ if (requestedNames && !requestedNames.has(scanner.name)) return false;
5882
+ for (const req of scanner.requires) {
5883
+ if (req === "manifest" && !ctx.manifest) return false;
5884
+ if (req === "zipEntries" && !ctx.zipEntries) return false;
5885
+ if (req === "metadataDir" && !ctx.metadataDir) return false;
5886
+ if (req === "sourceDir" && !ctx.sourceDir) return false;
5887
+ }
5888
+ return true;
5889
+ });
5890
+ const settled = await Promise.allSettled(applicableScanners.map((scanner) => scanner.scan(ctx)));
5891
+ let findings = [];
5892
+ for (let i = 0; i < settled.length; i++) {
5893
+ const result = settled[i];
5894
+ if (result.status === "fulfilled") {
5895
+ findings.push(...result.value);
5896
+ } else {
5897
+ const scanner = applicableScanners[i];
5898
+ findings.push({
5899
+ scanner: scanner.name,
5900
+ ruleId: "scanner-error",
5901
+ severity: "error",
5902
+ title: `Scanner "${scanner.name}" failed`,
5903
+ message: result.reason instanceof Error ? result.reason.message : String(result.reason),
5904
+ suggestion: "This scanner encountered an unexpected error. Other scanners still ran."
5905
+ });
5906
+ }
5907
+ }
5908
+ if (config.disabledRules.length > 0) {
5909
+ const disabled = new Set(config.disabledRules);
5910
+ findings = findings.filter((f) => !disabled.has(f.ruleId));
5911
+ }
5912
+ if (Object.keys(config.severityOverrides).length > 0) {
5913
+ findings = findings.map((f) => {
5914
+ const override = config.severityOverrides[f.ruleId];
5915
+ return override ? { ...f, severity: override } : f;
5916
+ });
5917
+ }
5918
+ findings.sort((a, b) => SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity]);
5919
+ const summary = { critical: 0, error: 0, warning: 0, info: 0 };
5920
+ for (const f of findings) summary[f.severity]++;
5921
+ const failThreshold = SEVERITY_ORDER[config.failOn];
5922
+ const passed = !findings.some((f) => SEVERITY_ORDER[f.severity] >= failThreshold);
5923
+ return {
5924
+ scanners: applicableScanners.map((s) => s.name),
5925
+ findings,
5926
+ summary,
5927
+ passed,
5928
+ durationMs: Date.now() - start
5929
+ };
5930
+ }
5931
+
4304
5932
  // src/utils/sort.ts
4305
5933
  function getNestedValue(obj, path) {
4306
5934
  const parts = path.split(".");
@@ -4342,16 +5970,16 @@ function sortResults(items, sortSpec) {
4342
5970
  }
4343
5971
 
4344
5972
  // src/commands/plugin-scaffold.ts
4345
- import { mkdir as mkdir5, writeFile as writeFile6 } from "fs/promises";
4346
- import { join as join7 } from "path";
5973
+ import { mkdir as mkdir6, writeFile as writeFile7 } from "fs/promises";
5974
+ import { join as join10 } from "path";
4347
5975
  async function scaffoldPlugin(options) {
4348
5976
  const { name, dir, description = `GPC plugin: ${name}` } = options;
4349
5977
  const pluginName = name.startsWith("gpc-plugin-") ? name : `gpc-plugin-${name}`;
4350
5978
  const shortName = pluginName.replace(/^gpc-plugin-/, "");
4351
- const srcDir = join7(dir, "src");
4352
- const testDir = join7(dir, "tests");
4353
- await mkdir5(srcDir, { recursive: true });
4354
- await mkdir5(testDir, { recursive: true });
5979
+ const srcDir = join10(dir, "src");
5980
+ const testDir = join10(dir, "tests");
5981
+ await mkdir6(srcDir, { recursive: true });
5982
+ await mkdir6(testDir, { recursive: true });
4355
5983
  const files = [];
4356
5984
  const pkg = {
4357
5985
  name: pluginName,
@@ -4385,7 +6013,7 @@ async function scaffoldPlugin(options) {
4385
6013
  vitest: "^3.0.0"
4386
6014
  }
4387
6015
  };
4388
- await writeFile6(join7(dir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
6016
+ await writeFile7(join10(dir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
4389
6017
  files.push("package.json");
4390
6018
  const tsconfig = {
4391
6019
  compilerOptions: {
@@ -4401,7 +6029,7 @@ async function scaffoldPlugin(options) {
4401
6029
  },
4402
6030
  include: ["src"]
4403
6031
  };
4404
- await writeFile6(join7(dir, "tsconfig.json"), JSON.stringify(tsconfig, null, 2) + "\n");
6032
+ await writeFile7(join10(dir, "tsconfig.json"), JSON.stringify(tsconfig, null, 2) + "\n");
4405
6033
  files.push("tsconfig.json");
4406
6034
  const srcContent = `import { definePlugin } from "@gpc-cli/plugin-sdk";
4407
6035
  import type { CommandEvent, CommandResult } from "@gpc-cli/plugin-sdk";
@@ -4434,7 +6062,7 @@ export const plugin = definePlugin({
4434
6062
  },
4435
6063
  });
4436
6064
  `;
4437
- await writeFile6(join7(srcDir, "index.ts"), srcContent);
6065
+ await writeFile7(join10(srcDir, "index.ts"), srcContent);
4438
6066
  files.push("src/index.ts");
4439
6067
  const testContent = `import { describe, it, expect, vi } from "vitest";
4440
6068
  import { plugin } from "../src/index";
@@ -4459,7 +6087,7 @@ describe("${pluginName}", () => {
4459
6087
  });
4460
6088
  });
4461
6089
  `;
4462
- await writeFile6(join7(testDir, "plugin.test.ts"), testContent);
6090
+ await writeFile7(join10(testDir, "plugin.test.ts"), testContent);
4463
6091
  files.push("tests/plugin.test.ts");
4464
6092
  return { dir, files };
4465
6093
  }
@@ -4570,7 +6198,7 @@ async function sendWebhook(config, payload, target) {
4570
6198
  }
4571
6199
 
4572
6200
  // src/commands/internal-sharing.ts
4573
- import { extname as extname4 } from "path";
6201
+ import { extname as extname5 } from "path";
4574
6202
  async function uploadInternalSharing(client, packageName, filePath, fileType) {
4575
6203
  const resolvedType = fileType ?? detectFileType(filePath);
4576
6204
  const validation = await validateUploadFile(filePath);
@@ -4597,7 +6225,7 @@ ${validation.errors.join("\n")}`,
4597
6225
  };
4598
6226
  }
4599
6227
  function detectFileType(filePath) {
4600
- const ext = extname4(filePath).toLowerCase();
6228
+ const ext = extname5(filePath).toLowerCase();
4601
6229
  if (ext === ".aab") return "bundle";
4602
6230
  if (ext === ".apk") return "apk";
4603
6231
  throw new GpcError(
@@ -4609,7 +6237,7 @@ function detectFileType(filePath) {
4609
6237
  }
4610
6238
 
4611
6239
  // src/commands/generated-apks.ts
4612
- import { writeFile as writeFile7 } from "fs/promises";
6240
+ import { writeFile as writeFile8 } from "fs/promises";
4613
6241
  async function listGeneratedApks(client, packageName, versionCode) {
4614
6242
  if (!Number.isInteger(versionCode) || versionCode <= 0) {
4615
6243
  throw new GpcError(
@@ -4640,7 +6268,7 @@ async function downloadGeneratedApk(client, packageName, versionCode, apkId, out
4640
6268
  }
4641
6269
  const buffer = await client.generatedApks.download(packageName, versionCode, apkId);
4642
6270
  const bytes = new Uint8Array(buffer);
4643
- await writeFile7(outputPath, bytes);
6271
+ await writeFile8(outputPath, bytes);
4644
6272
  return { path: outputPath, sizeBytes: bytes.byteLength };
4645
6273
  }
4646
6274
 
@@ -4707,11 +6335,11 @@ async function deactivatePurchaseOption(client, packageName, purchaseOptionId) {
4707
6335
  }
4708
6336
 
4709
6337
  // src/commands/bundle-analysis.ts
4710
- import { readFile as readFile9, stat as stat7 } from "fs/promises";
6338
+ import { readFile as readFile14, stat as stat10 } from "fs/promises";
4711
6339
  var EOCD_SIGNATURE = 101010256;
4712
6340
  var CD_SIGNATURE = 33639248;
4713
6341
  var MODULE_SUBDIRS = /* @__PURE__ */ new Set(["dex", "manifest", "res", "assets", "lib", "resources.pb", "root"]);
4714
- function detectCategory(path) {
6342
+ function detectCategory2(path) {
4715
6343
  const lower = path.toLowerCase();
4716
6344
  if (lower.endsWith(".dex") || /\/dex\/[^/]+\.dex$/.test(lower)) return "dex";
4717
6345
  if (lower === "resources.arsc" || lower.endsWith("/resources.arsc") || lower.endsWith("/resources.pb") || /^(([^/]+\/)?res\/)/.test(lower))
@@ -4783,18 +6411,18 @@ function detectFileType2(filePath) {
4783
6411
  return "apk";
4784
6412
  }
4785
6413
  async function analyzeBundle(filePath) {
4786
- const fileInfo = await stat7(filePath).catch(() => null);
6414
+ const fileInfo = await stat10(filePath).catch(() => null);
4787
6415
  if (!fileInfo || !fileInfo.isFile()) {
4788
6416
  throw new Error(`File not found: ${filePath}`);
4789
6417
  }
4790
- const buf = await readFile9(filePath);
6418
+ const buf = await readFile14(filePath);
4791
6419
  const cdEntries = parseCentralDirectory(buf);
4792
6420
  const fileType = detectFileType2(filePath);
4793
6421
  const isAab = fileType === "aab";
4794
6422
  const entries = cdEntries.map((e) => ({
4795
6423
  path: e.filename,
4796
6424
  module: detectModule(e.filename, isAab),
4797
- category: detectCategory(e.filename),
6425
+ category: detectCategory2(e.filename),
4798
6426
  compressedSize: e.compressedSize,
4799
6427
  uncompressedSize: e.uncompressedSize
4800
6428
  }));
@@ -4873,7 +6501,7 @@ function topFiles(analysis, n = 20) {
4873
6501
  async function checkBundleSize(analysis, configPath = ".bundlesize.json") {
4874
6502
  let config;
4875
6503
  try {
4876
- const raw = await readFile9(configPath, "utf-8");
6504
+ const raw = await readFile14(configPath, "utf-8");
4877
6505
  config = JSON.parse(raw);
4878
6506
  } catch {
4879
6507
  return { passed: true, violations: [] };
@@ -4902,17 +6530,17 @@ async function checkBundleSize(analysis, configPath = ".bundlesize.json") {
4902
6530
  }
4903
6531
 
4904
6532
  // src/commands/status.ts
4905
- import { mkdir as mkdir6, readFile as readFile10, writeFile as writeFile8 } from "fs/promises";
6533
+ import { mkdir as mkdir7, readFile as readFile15, writeFile as writeFile9 } from "fs/promises";
4906
6534
  import { execFile as execFile2 } from "child_process";
4907
- import { join as join8 } from "path";
6535
+ import { join as join11 } from "path";
4908
6536
  import { getCacheDir as getCacheDir2 } from "@gpc-cli/config";
4909
6537
  var DEFAULT_TTL_SECONDS = 3600;
4910
6538
  function cacheFilePath(packageName) {
4911
- return join8(getCacheDir2(), `status-${packageName}.json`);
6539
+ return join11(getCacheDir2(), `status-${packageName}.json`);
4912
6540
  }
4913
6541
  async function loadStatusCache(packageName, ttlSeconds = DEFAULT_TTL_SECONDS) {
4914
6542
  try {
4915
- const raw = await readFile10(cacheFilePath(packageName), "utf-8");
6543
+ const raw = await readFile15(cacheFilePath(packageName), "utf-8");
4916
6544
  const entry = JSON.parse(raw);
4917
6545
  const age = (Date.now() - new Date(entry.fetchedAt).getTime()) / 1e3;
4918
6546
  if (age > (entry.ttl ?? ttlSeconds)) return null;
@@ -4929,9 +6557,9 @@ async function loadStatusCache(packageName, ttlSeconds = DEFAULT_TTL_SECONDS) {
4929
6557
  async function saveStatusCache(packageName, data, ttlSeconds = DEFAULT_TTL_SECONDS) {
4930
6558
  try {
4931
6559
  const dir = getCacheDir2();
4932
- await mkdir6(dir, { recursive: true });
6560
+ await mkdir7(dir, { recursive: true });
4933
6561
  const entry = { fetchedAt: data.fetchedAt, ttl: ttlSeconds, data };
4934
- await writeFile8(cacheFilePath(packageName), JSON.stringify(entry, null, 2), {
6562
+ await writeFile9(cacheFilePath(packageName), JSON.stringify(entry, null, 2), {
4935
6563
  encoding: "utf-8",
4936
6564
  mode: 384
4937
6565
  });
@@ -5035,6 +6663,7 @@ function computeReviewSentiment(reviews, windowDays) {
5035
6663
  }
5036
6664
  async function getAppStatus(client, reporting, packageName, options = {}) {
5037
6665
  const days = options.days ?? 7;
6666
+ const reviewDays = options.reviewDays ?? 30;
5038
6667
  const sections = new Set(options.sections ?? ["releases", "vitals", "reviews"]);
5039
6668
  const thresholds = {
5040
6669
  crashRate: options.vitalThresholds?.crashRate ?? DEFAULT_THRESHOLDS.crashRate,
@@ -5069,7 +6698,7 @@ async function getAppStatus(client, reporting, packageName, options = {}) {
5069
6698
  const slowStart = slowStartResult.status === "fulfilled" ? slowStartResult.value : SKIPPED_VITAL;
5070
6699
  const slowRender = slowRenderResult.status === "fulfilled" ? slowRenderResult.value : SKIPPED_VITAL;
5071
6700
  const rawReviews = reviewsResult.status === "fulfilled" ? reviewsResult.value : [];
5072
- const reviews = computeReviewSentiment(rawReviews, 30);
6701
+ const reviews = computeReviewSentiment(rawReviews, reviewDays);
5073
6702
  return {
5074
6703
  packageName,
5075
6704
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -5292,6 +6921,7 @@ async function runWatchLoop(opts) {
5292
6921
  process.on("SIGTERM", cleanup);
5293
6922
  while (running) {
5294
6923
  process.stdout.write("\x1B[2J\x1B[H");
6924
+ const fetchedAt = Date.now();
5295
6925
  try {
5296
6926
  const status = await opts.fetch();
5297
6927
  await opts.save(status);
@@ -5299,28 +6929,31 @@ async function runWatchLoop(opts) {
5299
6929
  } catch (err) {
5300
6930
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
5301
6931
  }
5302
- console.log(`
5303
- [gpc status] Refreshing in ${opts.intervalSeconds}s\u2026 (Ctrl+C to stop)`);
5304
6932
  for (let i = 0; i < opts.intervalSeconds && running; i++) {
6933
+ const elapsed = Math.round((Date.now() - fetchedAt) / 1e3);
6934
+ const remaining = opts.intervalSeconds - i;
6935
+ process.stdout.write(
6936
+ `\r[gpc status] Fetched ${elapsed}s ago \xB7 refreshing in ${remaining}s (Ctrl+C to stop)\x1B[K`
6937
+ );
5305
6938
  await new Promise((r) => setTimeout(r, 1e3));
5306
6939
  }
5307
6940
  }
5308
6941
  }
5309
6942
  function breachStateFilePath(packageName) {
5310
- return join8(getCacheDir2(), `breach-state-${packageName}.json`);
6943
+ return join11(getCacheDir2(), `breach-state-${packageName}.json`);
5311
6944
  }
5312
6945
  async function trackBreachState(packageName, isBreaching) {
5313
6946
  const filePath = breachStateFilePath(packageName);
5314
6947
  let prevBreaching = false;
5315
6948
  try {
5316
- const raw = await readFile10(filePath, "utf-8");
6949
+ const raw = await readFile15(filePath, "utf-8");
5317
6950
  prevBreaching = JSON.parse(raw).breaching;
5318
6951
  } catch {
5319
6952
  }
5320
6953
  if (prevBreaching !== isBreaching) {
5321
6954
  try {
5322
- await mkdir6(getCacheDir2(), { recursive: true });
5323
- await writeFile8(
6955
+ await mkdir7(getCacheDir2(), { recursive: true });
6956
+ await writeFile9(
5324
6957
  filePath,
5325
6958
  JSON.stringify({ breaching: isBreaching, since: (/* @__PURE__ */ new Date()).toISOString() }, null, 2),
5326
6959
  { encoding: "utf-8", mode: 384 }
@@ -5361,6 +6994,7 @@ export {
5361
6994
  ApiError,
5362
6995
  ConfigError,
5363
6996
  DEFAULT_LIMITS,
6997
+ DEFAULT_PREFLIGHT_CONFIG,
5364
6998
  GOOGLE_PLAY_LANGUAGES,
5365
6999
  GpcError,
5366
7000
  NetworkError,
@@ -5368,6 +7002,7 @@ export {
5368
7002
  PluginManager,
5369
7003
  SENSITIVE_ARG_KEYS,
5370
7004
  SENSITIVE_KEYS,
7005
+ SEVERITY_ORDER,
5371
7006
  abortTrain,
5372
7007
  acknowledgeProductPurchase,
5373
7008
  activateBasePlan,
@@ -5433,6 +7068,7 @@ export {
5433
7068
  exportDataSafety,
5434
7069
  exportImages,
5435
7070
  exportReviews,
7071
+ fetchReleaseNotes,
5436
7072
  formatCustomPayload,
5437
7073
  formatDiscordPayload,
5438
7074
  formatJunit,
@@ -5444,6 +7080,7 @@ export {
5444
7080
  formatWordDiff,
5445
7081
  generateMigrationPlan,
5446
7082
  generateNotesFromGit,
7083
+ getAllScannerNames,
5447
7084
  getAppInfo,
5448
7085
  getAppStatus,
5449
7086
  getCountryAvailability,
@@ -5477,6 +7114,7 @@ export {
5477
7114
  importDataSafety,
5478
7115
  importTestersFromCsv,
5479
7116
  initAudit,
7117
+ initProject,
5480
7118
  inviteUser,
5481
7119
  isFinancialReportType,
5482
7120
  isStatsReportType,
@@ -5508,6 +7146,7 @@ export {
5508
7146
  listTracks,
5509
7147
  listUsers,
5510
7148
  listVoidedPurchases,
7149
+ loadPreflightConfig,
5511
7150
  loadStatusCache,
5512
7151
  maybePaginate,
5513
7152
  migratePrices,
@@ -5531,6 +7170,7 @@ export {
5531
7170
  removeUser,
5532
7171
  replyToReview,
5533
7172
  revokeSubscriptionPurchase,
7173
+ runPreflight,
5534
7174
  runWatchLoop,
5535
7175
  safePath,
5536
7176
  safePathWithin,