@gpc-cli/core 0.9.21 → 0.9.22

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
@@ -244,7 +244,7 @@ function buildTestCase(item, commandName, index = 0) {
244
244
  const record = item;
245
245
  const name = escapeXml(
246
246
  String(
247
- record["name"] ?? record["title"] ?? record["sku"] ?? record["id"] ?? record["productId"] ?? record["packageName"] ?? record["trackId"] ?? record["region"] ?? record["languageCode"] ?? `item-${index + 1}`
247
+ record["name"] ?? record["title"] ?? record["sku"] ?? record["id"] ?? record["reviewId"] ?? record["productId"] ?? record["packageName"] ?? record["track"] ?? record["trackId"] ?? record["versionCode"] ?? record["region"] ?? record["languageCode"] ?? `item-${index + 1}`
248
248
  )
249
249
  );
250
250
  const classname = `gpc.${escapeXml(commandName)}`;
@@ -1329,8 +1329,8 @@ var ALL_IMAGE_TYPES = [
1329
1329
  "tvBanner"
1330
1330
  ];
1331
1331
  async function exportImages(client, packageName, dir, options) {
1332
- const { mkdir: mkdir5, writeFile: writeFile7 } = await import("fs/promises");
1333
- const { join: join7 } = await import("path");
1332
+ const { mkdir: mkdir6, writeFile: writeFile8 } = await import("fs/promises");
1333
+ const { join: join8 } = await import("path");
1334
1334
  const edit = await client.edits.insert(packageName);
1335
1335
  try {
1336
1336
  let languages;
@@ -1361,12 +1361,12 @@ async function exportImages(client, packageName, dir, options) {
1361
1361
  const batch = tasks.slice(i, i + concurrency);
1362
1362
  const results = await Promise.all(
1363
1363
  batch.map(async (task) => {
1364
- const dirPath = join7(dir, task.language, task.imageType);
1365
- await mkdir5(dirPath, { recursive: true });
1364
+ const dirPath = join8(dir, task.language, task.imageType);
1365
+ await mkdir6(dirPath, { recursive: true });
1366
1366
  const response = await fetch(task.url);
1367
1367
  const buffer = Buffer.from(await response.arrayBuffer());
1368
- const filePath = join7(dirPath, `${task.index}.png`);
1369
- await writeFile7(filePath, buffer);
1368
+ const filePath = join8(dirPath, `${task.index}.png`);
1369
+ await writeFile8(filePath, buffer);
1370
1370
  return buffer.length;
1371
1371
  })
1372
1372
  );
@@ -1404,6 +1404,7 @@ async function updateAppDetails(client, packageName, details) {
1404
1404
  // src/commands/migrate.ts
1405
1405
  import { readdir as readdir2, readFile as readFile3, writeFile as writeFile2, mkdir as mkdir2, access } from "fs/promises";
1406
1406
  import { join as join2 } from "path";
1407
+ var COMPLEX_RUBY_RE = /\b(begin|rescue|ensure|if |unless |case |while |until |for )\b/;
1407
1408
  async function fileExists(path) {
1408
1409
  try {
1409
1410
  await access(path);
@@ -1419,7 +1420,8 @@ async function detectFastlane(cwd) {
1419
1420
  hasMetadata: false,
1420
1421
  hasGemfile: false,
1421
1422
  lanes: [],
1422
- metadataLanguages: []
1423
+ metadataLanguages: [],
1424
+ parseWarnings: []
1423
1425
  };
1424
1426
  const fastlaneDir = join2(cwd, "fastlane");
1425
1427
  const hasFastlaneDir = await fileExists(fastlaneDir);
@@ -1435,13 +1437,20 @@ async function detectFastlane(cwd) {
1435
1437
  const entries = await readdir2(metadataDir, { withFileTypes: true });
1436
1438
  result.metadataLanguages = entries.filter((e) => e.isDirectory()).map((e) => e.name);
1437
1439
  } catch {
1440
+ result.parseWarnings.push("Could not read metadata directory \u2014 check permissions");
1438
1441
  }
1439
1442
  }
1440
1443
  if (result.hasFastfile) {
1441
1444
  try {
1442
1445
  const content = await readFile3(fastfilePath, "utf-8");
1443
1446
  result.lanes = parseFastfile(content);
1447
+ if (COMPLEX_RUBY_RE.test(content)) {
1448
+ result.parseWarnings.push(
1449
+ "Fastfile contains complex Ruby constructs (begin/rescue/if/unless/case). Lane detection may be incomplete \u2014 review MIGRATION.md and adjust manually."
1450
+ );
1451
+ }
1444
1452
  } catch {
1453
+ result.parseWarnings.push("Could not read Fastfile \u2014 check permissions");
1445
1454
  }
1446
1455
  }
1447
1456
  if (result.hasAppfile) {
@@ -1451,6 +1460,7 @@ async function detectFastlane(cwd) {
1451
1460
  result.packageName = parsed.packageName;
1452
1461
  result.jsonKeyPath = parsed.jsonKeyPath;
1453
1462
  } catch {
1463
+ result.parseWarnings.push("Could not read Appfile \u2014 check permissions");
1454
1464
  }
1455
1465
  }
1456
1466
  return result;
@@ -1481,8 +1491,9 @@ function mapLaneToGpc(name, actions, body) {
1481
1491
  const trackMatch = body.match(/track\s*:\s*["'](\w+)["']/);
1482
1492
  const rolloutMatch = body.match(/rollout\s*:\s*["']?([\d.]+)["']?/);
1483
1493
  if (rolloutMatch) {
1484
- const percentage = Math.round(parseFloat(rolloutMatch[1] ?? "0") * 100);
1485
- return `gpc releases promote --rollout ${percentage}`;
1494
+ const raw = parseFloat(rolloutMatch[1] ?? "0");
1495
+ const pct = raw > 1 ? Math.round(raw) : Math.round(raw * 100);
1496
+ return `gpc releases upload --rollout ${pct}${trackMatch ? ` --track ${trackMatch[1]}` : ""}`;
1486
1497
  }
1487
1498
  if (trackMatch) {
1488
1499
  return `gpc releases upload --track ${trackMatch[1]}`;
@@ -1512,7 +1523,7 @@ function parseAppfile(content) {
1512
1523
  function generateMigrationPlan(detection) {
1513
1524
  const config = {};
1514
1525
  const checklist = [];
1515
- const warnings = [];
1526
+ const warnings = [...detection.parseWarnings];
1516
1527
  if (detection.packageName) {
1517
1528
  config["app"] = detection.packageName;
1518
1529
  } else {
@@ -1521,44 +1532,61 @@ function generateMigrationPlan(detection) {
1521
1532
  if (detection.jsonKeyPath) {
1522
1533
  config["auth"] = { serviceAccount: detection.jsonKeyPath };
1523
1534
  } else {
1524
- checklist.push("Configure authentication: gpc auth setup");
1535
+ checklist.push("Configure authentication: gpc auth login");
1525
1536
  }
1526
1537
  for (const lane of detection.lanes) {
1527
1538
  if (lane.gpcEquivalent) {
1528
- checklist.push(`Replace Fastlane lane "${lane.name}" with: ${lane.gpcEquivalent}`);
1539
+ checklist.push(
1540
+ `Replace Fastlane lane "${lane.name}" with: ${lane.gpcEquivalent} <your.aab>`
1541
+ );
1529
1542
  }
1530
1543
  if (lane.actions.includes("capture_android_screenshots")) {
1531
1544
  warnings.push(
1532
- `Lane "${lane.name}" uses capture_android_screenshots which has no GPC equivalent. You will need to continue using Fastlane for screenshot capture or use a separate tool.`
1545
+ `Lane "${lane.name}" uses capture_android_screenshots which has no GPC equivalent. Use a separate screenshot tool or check gpc plugins list for community plugins.`
1546
+ );
1547
+ }
1548
+ if (lane.actions.length === 0 || lane.gpcEquivalent === void 0 && !lane.actions.includes("capture_android_screenshots")) {
1549
+ warnings.push(
1550
+ `Lane "${lane.name}" has no automatic GPC equivalent. Check \`gpc plugins list\` or the plugin SDK docs to build a custom command.`
1533
1551
  );
1534
1552
  }
1535
1553
  }
1536
1554
  if (detection.hasMetadata && detection.metadataLanguages.length > 0) {
1555
+ const langs = detection.metadataLanguages.slice(0, 3).join(", ");
1556
+ const more = detection.metadataLanguages.length > 3 ? ` (+${detection.metadataLanguages.length - 3} more)` : "";
1557
+ checklist.push(
1558
+ `Pull current metadata for ${detection.metadataLanguages.length} language(s) (${langs}${more}): gpc listings pull --dir fastlane/metadata/android`
1559
+ );
1537
1560
  checklist.push(
1538
- `Migrate metadata for ${detection.metadataLanguages.length} language(s): gpc listings pull --dir metadata`
1561
+ "Review pulled metadata, then push back: gpc listings push --dir fastlane/metadata/android"
1539
1562
  );
1540
- checklist.push("Review and push metadata: gpc listings push --dir metadata");
1541
1563
  }
1542
1564
  checklist.push("Run gpc doctor to verify your setup");
1543
- checklist.push("Test with --dry-run before making real changes");
1565
+ checklist.push("Test each command with --dry-run before making real changes");
1544
1566
  if (detection.hasGemfile) {
1545
1567
  checklist.push("Remove Fastlane from your Gemfile once migration is complete");
1546
1568
  }
1547
- if (detection.lanes.some((l) => l.actions.includes("supply") || l.actions.includes("upload_to_play_store"))) {
1548
- checklist.push("Update CI/CD pipelines to use gpc commands instead of Fastlane lanes");
1569
+ if (detection.lanes.some(
1570
+ (l) => l.actions.includes("supply") || l.actions.includes("upload_to_play_store")
1571
+ )) {
1572
+ checklist.push("Update CI/CD pipelines to call gpc commands instead of Fastlane lanes");
1549
1573
  }
1550
1574
  return { config, checklist, warnings };
1551
1575
  }
1552
1576
  async function writeMigrationOutput(result, dir) {
1553
1577
  await mkdir2(dir, { recursive: true });
1554
1578
  const files = [];
1555
- const configPath = join2(dir, ".gpcrc.json");
1556
- await writeFile2(configPath, JSON.stringify(result.config, null, 2) + "\n", "utf-8");
1557
- files.push(configPath);
1579
+ if (Object.keys(result.config).length > 0) {
1580
+ const configPath = join2(dir, ".gpcrc.json");
1581
+ await writeFile2(configPath, JSON.stringify(result.config, null, 2) + "\n", "utf-8");
1582
+ files.push(configPath);
1583
+ }
1558
1584
  const migrationPath = join2(dir, "MIGRATION.md");
1559
1585
  const lines = [
1560
1586
  "# Fastlane to GPC Migration",
1561
1587
  "",
1588
+ "Generated by `gpc migrate fastlane`. Review and adjust before applying.",
1589
+ "",
1562
1590
  "## Migration Checklist",
1563
1591
  ""
1564
1592
  ];
@@ -1570,7 +1598,7 @@ async function writeMigrationOutput(result, dir) {
1570
1598
  lines.push("## Warnings");
1571
1599
  lines.push("");
1572
1600
  for (const warning of result.warnings) {
1573
- lines.push(`- ${warning}`);
1601
+ lines.push(`> \u26A0 ${warning}`);
1574
1602
  }
1575
1603
  }
1576
1604
  lines.push("");
@@ -1581,8 +1609,11 @@ async function writeMigrationOutput(result, dir) {
1581
1609
  lines.push("| `fastlane supply` | `gpc releases upload` / `gpc listings push` |");
1582
1610
  lines.push("| `upload_to_play_store` | `gpc releases upload` |");
1583
1611
  lines.push('| `supply(track: "internal")` | `gpc releases upload --track internal` |');
1584
- lines.push('| `supply(rollout: "0.1")` | `gpc releases promote --rollout 10` |');
1585
- lines.push("| `capture_android_screenshots` | No equivalent (use separate tool) |");
1612
+ lines.push('| `supply(rollout: "0.1")` | `gpc releases upload --rollout 10` |');
1613
+ lines.push("| `supply(skip_upload_aab: true)` | `gpc listings push` |");
1614
+ lines.push("| `capture_android_screenshots` | No equivalent \u2014 use separate tool |");
1615
+ lines.push("");
1616
+ lines.push("See the full migration guide: https://yasserstudio.github.io/gpc/migration/from-fastlane");
1586
1617
  lines.push("");
1587
1618
  await writeFile2(migrationPath, lines.join("\n"), "utf-8");
1588
1619
  files.push(migrationPath);
@@ -1729,12 +1760,16 @@ var STANDARD_TRACKS = /* @__PURE__ */ new Set([
1729
1760
  var TRACK_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_:-]*$/;
1730
1761
  async function validatePreSubmission(options) {
1731
1762
  const checks = [];
1763
+ const resultWarnings = [];
1732
1764
  const fileResult = await validateUploadFile(options.filePath);
1733
1765
  checks.push({
1734
1766
  name: "file",
1735
1767
  passed: fileResult.valid,
1736
- message: fileResult.valid ? `Valid ${fileResult.fileType} file (${formatSize3(fileResult.sizeBytes)})` : fileResult.errors.join("; ")
1768
+ message: fileResult.valid ? `Valid ${fileResult.fileType.toUpperCase()} (${formatSize3(fileResult.sizeBytes)})` : fileResult.errors.join("; ")
1737
1769
  });
1770
+ for (const w of fileResult.warnings) {
1771
+ resultWarnings.push(w);
1772
+ }
1738
1773
  if (options.mappingFile) {
1739
1774
  try {
1740
1775
  const stats = await stat5(options.mappingFile);
@@ -1786,7 +1821,8 @@ async function validatePreSubmission(options) {
1786
1821
  }
1787
1822
  return {
1788
1823
  valid: checks.every((c) => c.passed),
1789
- checks
1824
+ checks,
1825
+ warnings: resultWarnings
1790
1826
  };
1791
1827
  }
1792
1828
  function formatSize3(bytes) {
@@ -1955,23 +1991,22 @@ var METRIC_SET_METRICS = {
1955
1991
  function buildQuery(metricSet, options) {
1956
1992
  const metrics = METRIC_SET_METRICS[metricSet] ?? ["errorReportCount", "distinctUsers"];
1957
1993
  const days = options?.days ?? 30;
1958
- const end = /* @__PURE__ */ new Date();
1959
- end.setDate(end.getDate() - 1);
1960
- const start = new Date(end);
1961
- start.setDate(start.getDate() - days);
1994
+ const DAY_MS = 24 * 60 * 60 * 1e3;
1995
+ const end = new Date(Date.now() - DAY_MS);
1996
+ const start = new Date(Date.now() - DAY_MS - days * DAY_MS);
1962
1997
  const query = {
1963
1998
  metrics,
1964
1999
  timelineSpec: {
1965
2000
  aggregationPeriod: options?.aggregation ?? "DAILY",
1966
2001
  startTime: {
1967
- year: start.getFullYear(),
1968
- month: start.getMonth() + 1,
1969
- day: start.getDate()
2002
+ year: start.getUTCFullYear(),
2003
+ month: start.getUTCMonth() + 1,
2004
+ day: start.getUTCDate()
1970
2005
  },
1971
2006
  endTime: {
1972
- year: end.getFullYear(),
1973
- month: end.getMonth() + 1,
1974
- day: end.getDate()
2007
+ year: end.getUTCFullYear(),
2008
+ month: end.getUTCMonth() + 1,
2009
+ day: end.getUTCDate()
1975
2010
  }
1976
2011
  }
1977
2012
  };
@@ -2036,22 +2071,25 @@ async function searchVitalsErrors(reporting, packageName, options) {
2036
2071
  return reporting.searchErrorIssues(packageName, options?.filter, options?.maxResults);
2037
2072
  }
2038
2073
  async function compareVitalsTrend(reporting, packageName, metricSet, days = 7) {
2039
- const now = /* @__PURE__ */ new Date();
2040
- now.setDate(now.getDate() - 1);
2041
- const currentEnd = new Date(now);
2042
- const currentStart = new Date(now);
2043
- currentStart.setDate(currentStart.getDate() - days);
2044
- const previousEnd = new Date(currentStart);
2045
- previousEnd.setDate(previousEnd.getDate() - 1);
2046
- const previousStart = new Date(previousEnd);
2047
- previousStart.setDate(previousStart.getDate() - days);
2074
+ const DAY_MS = 24 * 60 * 60 * 1e3;
2075
+ const nowMs = Date.now();
2076
+ const baseMs = nowMs - 2 * DAY_MS;
2077
+ const currentEnd = new Date(baseMs);
2078
+ const currentStart = new Date(baseMs - days * DAY_MS);
2079
+ const previousEnd = new Date(baseMs - days * DAY_MS - DAY_MS);
2080
+ const previousStart = new Date(baseMs - days * DAY_MS - DAY_MS - days * DAY_MS);
2048
2081
  const metrics = METRIC_SET_METRICS[metricSet] ?? ["errorReportCount", "distinctUsers"];
2082
+ const toApiDate2 = (d) => ({
2083
+ year: d.getUTCFullYear(),
2084
+ month: d.getUTCMonth() + 1,
2085
+ day: d.getUTCDate()
2086
+ });
2049
2087
  const makeQuery = (start, end) => ({
2050
2088
  metrics,
2051
2089
  timelineSpec: {
2052
2090
  aggregationPeriod: "DAILY",
2053
- startTime: { year: start.getFullYear(), month: start.getMonth() + 1, day: start.getDate() },
2054
- endTime: { year: end.getFullYear(), month: end.getMonth() + 1, day: end.getDate() }
2091
+ startTime: toApiDate2(start),
2092
+ endTime: toApiDate2(end)
2055
2093
  }
2056
2094
  });
2057
2095
  const [currentResult, previousResult] = await Promise.all([
@@ -2798,10 +2836,12 @@ function formatNotes(commits, maxLength) {
2798
2836
  ${bullets}`);
2799
2837
  }
2800
2838
  let text = sections.join("\n\n");
2839
+ let truncated = false;
2801
2840
  if (text.length > maxLength) {
2802
2841
  text = text.slice(0, maxLength - 3) + "...";
2842
+ truncated = true;
2803
2843
  }
2804
- return text;
2844
+ return { text, truncated };
2805
2845
  }
2806
2846
  async function gitExec(args) {
2807
2847
  try {
@@ -2855,17 +2895,19 @@ async function generateNotesFromGit(options) {
2855
2895
  language,
2856
2896
  text: "No changes since last release.",
2857
2897
  commitCount: 0,
2858
- since
2898
+ since,
2899
+ truncated: false
2859
2900
  };
2860
2901
  }
2861
2902
  const subjects = logOutput.split("\n").filter((line) => line.length > 0);
2862
2903
  const commits = subjects.map(parseConventionalCommit);
2863
- const text = formatNotes(commits, maxLength);
2904
+ const { text, truncated } = formatNotes(commits, maxLength);
2864
2905
  return {
2865
2906
  language,
2866
2907
  text,
2867
2908
  commitCount: subjects.length,
2868
- since
2909
+ since,
2910
+ truncated
2869
2911
  };
2870
2912
  }
2871
2913
 
@@ -3912,6 +3954,230 @@ function compareBundles(before, after) {
3912
3954
  categoryDeltas
3913
3955
  };
3914
3956
  }
3957
+
3958
+ // src/commands/status.ts
3959
+ import { mkdir as mkdir5, readFile as readFile10, writeFile as writeFile7 } from "fs/promises";
3960
+ import { join as join7 } from "path";
3961
+ import { getCacheDir } from "@gpc-cli/config";
3962
+ var DEFAULT_TTL_SECONDS = 3600;
3963
+ function cacheFilePath(packageName) {
3964
+ return join7(getCacheDir(), `status-${packageName}.json`);
3965
+ }
3966
+ async function loadStatusCache(packageName, ttlSeconds = DEFAULT_TTL_SECONDS) {
3967
+ try {
3968
+ const raw = await readFile10(cacheFilePath(packageName), "utf-8");
3969
+ const entry = JSON.parse(raw);
3970
+ const age = (Date.now() - new Date(entry.fetchedAt).getTime()) / 1e3;
3971
+ if (age > (entry.ttl ?? ttlSeconds)) return null;
3972
+ return { ...entry.data, cached: true };
3973
+ } catch {
3974
+ return null;
3975
+ }
3976
+ }
3977
+ async function saveStatusCache(packageName, data, ttlSeconds = DEFAULT_TTL_SECONDS) {
3978
+ try {
3979
+ const dir = getCacheDir();
3980
+ await mkdir5(dir, { recursive: true });
3981
+ const entry = {
3982
+ fetchedAt: data.fetchedAt,
3983
+ ttl: ttlSeconds,
3984
+ data
3985
+ };
3986
+ await writeFile7(cacheFilePath(packageName), JSON.stringify(entry, null, 2), { encoding: "utf-8", mode: 384 });
3987
+ } catch {
3988
+ }
3989
+ }
3990
+ var METRIC_SET_METRICS2 = {
3991
+ crashRateMetricSet: ["crashRate", "userPerceivedCrashRate", "distinctUsers"],
3992
+ anrRateMetricSet: ["anrRate", "userPerceivedAnrRate", "distinctUsers"],
3993
+ slowStartRateMetricSet: ["slowStartRate", "distinctUsers"],
3994
+ slowRenderingRateMetricSet: ["slowRenderingRate", "distinctUsers"]
3995
+ };
3996
+ var DEFAULT_THRESHOLDS = {
3997
+ crashRate: 0.02,
3998
+ anrRate: 0.01,
3999
+ slowStartRate: 0.05,
4000
+ slowRenderingRate: 0.1
4001
+ };
4002
+ var WARN_MARGIN = 0.2;
4003
+ function toApiDate(d) {
4004
+ return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
4005
+ }
4006
+ async function queryVitalForStatus(reporting, packageName, metricSet, days) {
4007
+ const DAY_MS = 24 * 60 * 60 * 1e3;
4008
+ const baseMs = Date.now() - 2 * DAY_MS;
4009
+ const end = new Date(baseMs);
4010
+ const start = new Date(baseMs - days * DAY_MS);
4011
+ const metrics = METRIC_SET_METRICS2[metricSet] ?? ["distinctUsers"];
4012
+ const query = {
4013
+ metrics,
4014
+ timelineSpec: {
4015
+ aggregationPeriod: "DAILY",
4016
+ startTime: toApiDate(start),
4017
+ endTime: toApiDate(end)
4018
+ }
4019
+ };
4020
+ const result = await reporting.queryMetricSet(packageName, metricSet, query);
4021
+ if (!result.rows || result.rows.length === 0) return void 0;
4022
+ const values = result.rows.map((row) => {
4023
+ const firstKey = Object.keys(row.metrics)[0];
4024
+ return firstKey ? Number(row.metrics[firstKey]?.decimalValue?.value) : NaN;
4025
+ }).filter((v) => !isNaN(v));
4026
+ if (values.length === 0) return void 0;
4027
+ return values.reduce((a, b) => a + b, 0) / values.length;
4028
+ }
4029
+ function toVitalMetric(value, threshold) {
4030
+ if (value === void 0) {
4031
+ return { value: void 0, threshold, status: "unknown" };
4032
+ }
4033
+ if (value > threshold) return { value, threshold, status: "breach" };
4034
+ if (value > threshold * (1 - WARN_MARGIN)) return { value, threshold, status: "warn" };
4035
+ return { value, threshold, status: "ok" };
4036
+ }
4037
+ function computeReviewSentiment(reviews, windowDays) {
4038
+ const now = Date.now();
4039
+ const DAY_MS = 24 * 60 * 60 * 1e3;
4040
+ const windowMs = windowDays * DAY_MS;
4041
+ const prevWindowStart = now - 2 * windowMs;
4042
+ const curWindowStart = now - windowMs;
4043
+ const current = reviews.filter((r) => {
4044
+ const uc = r.comments?.[0]?.userComment;
4045
+ if (!uc) return false;
4046
+ const ts = Number(uc.lastModified.seconds) * 1e3;
4047
+ return ts >= curWindowStart;
4048
+ });
4049
+ const previous = reviews.filter((r) => {
4050
+ const uc = r.comments?.[0]?.userComment;
4051
+ if (!uc) return false;
4052
+ const ts = Number(uc.lastModified.seconds) * 1e3;
4053
+ return ts >= prevWindowStart && ts < curWindowStart;
4054
+ });
4055
+ const avgRating = (items) => {
4056
+ const ratings = items.map((r) => r.comments?.[0]?.userComment?.starRating).filter((v) => v !== void 0 && v > 0);
4057
+ if (ratings.length === 0) return void 0;
4058
+ return Math.round(ratings.reduce((a, b) => a + b, 0) / ratings.length * 10) / 10;
4059
+ };
4060
+ const positiveCount = current.filter(
4061
+ (r) => (r.comments?.[0]?.userComment?.starRating ?? 0) >= 4
4062
+ ).length;
4063
+ const positivePercent = current.length > 0 ? Math.round(positiveCount / current.length * 100) : void 0;
4064
+ return {
4065
+ windowDays,
4066
+ averageRating: avgRating(current),
4067
+ previousAverageRating: avgRating(previous),
4068
+ totalNew: current.length,
4069
+ positivePercent
4070
+ };
4071
+ }
4072
+ async function getAppStatus(client, reporting, packageName, options = {}) {
4073
+ const days = options.days ?? 7;
4074
+ const thresholds = {
4075
+ crashRate: options.vitalThresholds?.crashRate ?? DEFAULT_THRESHOLDS.crashRate,
4076
+ anrRate: options.vitalThresholds?.anrRate ?? DEFAULT_THRESHOLDS.anrRate,
4077
+ slowStartRate: options.vitalThresholds?.slowStartRate ?? DEFAULT_THRESHOLDS.slowStartRate,
4078
+ slowRenderingRate: options.vitalThresholds?.slowRenderingRate ?? DEFAULT_THRESHOLDS.slowRenderingRate
4079
+ };
4080
+ const [releasesResult, crashesResult, anrResult, slowStartResult, slowRenderResult, reviewsResult] = await Promise.allSettled([
4081
+ getReleasesStatus(client, packageName),
4082
+ queryVitalForStatus(reporting, packageName, "crashRateMetricSet", days),
4083
+ queryVitalForStatus(reporting, packageName, "anrRateMetricSet", days),
4084
+ queryVitalForStatus(reporting, packageName, "slowStartRateMetricSet", days),
4085
+ queryVitalForStatus(reporting, packageName, "slowRenderingRateMetricSet", days),
4086
+ listReviews(client, packageName, { maxResults: 500 })
4087
+ ]);
4088
+ const rawReleases = releasesResult.status === "fulfilled" ? releasesResult.value : [];
4089
+ const releases = rawReleases.map((r) => ({
4090
+ track: r.track,
4091
+ versionCode: r.versionCodes[r.versionCodes.length - 1] ?? "\u2014",
4092
+ status: r.status,
4093
+ userFraction: r.userFraction ?? null
4094
+ }));
4095
+ const crashValue = crashesResult.status === "fulfilled" ? crashesResult.value : void 0;
4096
+ const anrValue = anrResult.status === "fulfilled" ? anrResult.value : void 0;
4097
+ const slowStartValue = slowStartResult.status === "fulfilled" ? slowStartResult.value : void 0;
4098
+ const slowRenderValue = slowRenderResult.status === "fulfilled" ? slowRenderResult.value : void 0;
4099
+ const rawReviews = reviewsResult.status === "fulfilled" ? reviewsResult.value : [];
4100
+ const reviews = computeReviewSentiment(rawReviews, 30);
4101
+ const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
4102
+ return {
4103
+ packageName,
4104
+ fetchedAt,
4105
+ cached: false,
4106
+ releases,
4107
+ vitals: {
4108
+ windowDays: days,
4109
+ crashes: toVitalMetric(crashValue, thresholds.crashRate),
4110
+ anr: toVitalMetric(anrValue, thresholds.anrRate),
4111
+ slowStarts: toVitalMetric(slowStartValue, thresholds.slowStartRate),
4112
+ slowRender: toVitalMetric(slowRenderValue, thresholds.slowRenderingRate)
4113
+ },
4114
+ reviews
4115
+ };
4116
+ }
4117
+ function vitalIndicator(metric) {
4118
+ if (metric.status === "unknown") return "?";
4119
+ if (metric.status === "breach") return "\u2717";
4120
+ if (metric.status === "warn") return "\u26A0";
4121
+ return "\u2713";
4122
+ }
4123
+ function formatVitalValue(metric) {
4124
+ if (metric.value === void 0) return "n/a";
4125
+ return `${(metric.value * 100).toFixed(2)}%`;
4126
+ }
4127
+ function formatFraction(fraction) {
4128
+ if (fraction === null) return "\u2014";
4129
+ return `${Math.round(fraction * 100)}%`;
4130
+ }
4131
+ function formatRating(rating) {
4132
+ if (rating === void 0) return "n/a";
4133
+ return `\u2605 ${rating.toFixed(1)}`;
4134
+ }
4135
+ function formatTrend(current, previous) {
4136
+ if (current === void 0 || previous === void 0) return "";
4137
+ if (current > previous) return ` \u2191 from ${previous.toFixed(1)}`;
4138
+ if (current < previous) return ` \u2193 from ${previous.toFixed(1)}`;
4139
+ return "";
4140
+ }
4141
+ function formatStatusTable(status) {
4142
+ const lines = [];
4143
+ const cachedLabel = status.cached ? ` (cached ${new Date(status.fetchedAt).toLocaleTimeString()})` : ` (fetched ${new Date(status.fetchedAt).toLocaleTimeString()})`;
4144
+ lines.push(`App: ${status.packageName}${cachedLabel}`);
4145
+ lines.push("");
4146
+ lines.push("RELEASES");
4147
+ if (status.releases.length === 0) {
4148
+ lines.push(" No releases found.");
4149
+ } else {
4150
+ const trackW = Math.max(10, ...status.releases.map((r) => r.track.length));
4151
+ const versionW = Math.max(7, ...status.releases.map((r) => r.versionCode.length));
4152
+ const statusW = Math.max(8, ...status.releases.map((r) => r.status.length));
4153
+ for (const r of status.releases) {
4154
+ lines.push(
4155
+ ` ${r.track.padEnd(trackW)} ${r.versionCode.padEnd(versionW)} ${r.status.padEnd(statusW)} ${formatFraction(r.userFraction)}`
4156
+ );
4157
+ }
4158
+ }
4159
+ lines.push("");
4160
+ lines.push(`VITALS (last ${status.vitals.windowDays} days)`);
4161
+ const { crashes, anr, slowStarts, slowRender } = status.vitals;
4162
+ lines.push(
4163
+ ` crashes ${formatVitalValue(crashes).padEnd(8)} ${vitalIndicator(crashes)} anr ${formatVitalValue(anr).padEnd(8)} ${vitalIndicator(anr)}`
4164
+ );
4165
+ lines.push(
4166
+ ` slow starts ${formatVitalValue(slowStarts).padEnd(8)} ${vitalIndicator(slowStarts)} slow render ${formatVitalValue(slowRender).padEnd(8)} ${vitalIndicator(slowRender)}`
4167
+ );
4168
+ lines.push("");
4169
+ lines.push(`REVIEWS (last ${status.reviews.windowDays} days)`);
4170
+ const { averageRating, previousAverageRating, totalNew, positivePercent } = status.reviews;
4171
+ const trend = formatTrend(averageRating, previousAverageRating);
4172
+ const positiveStr = positivePercent !== void 0 ? ` ${positivePercent}% positive` : "";
4173
+ lines.push(
4174
+ ` ${formatRating(averageRating)} ${totalNew} new${positiveStr}${trend}`
4175
+ );
4176
+ return lines.join("\n");
4177
+ }
4178
+ function statusHasBreach(status) {
4179
+ return status.vitals.crashes.status === "breach" || status.vitals.anr.status === "breach" || status.vitals.slowStarts.status === "breach" || status.vitals.slowRender.status === "breach";
4180
+ }
3915
4181
  export {
3916
4182
  ApiError,
3917
4183
  ConfigError,
@@ -3981,9 +4247,11 @@ export {
3981
4247
  formatJunit,
3982
4248
  formatOutput,
3983
4249
  formatSlackPayload,
4250
+ formatStatusTable,
3984
4251
  generateMigrationPlan,
3985
4252
  generateNotesFromGit,
3986
4253
  getAppInfo,
4254
+ getAppStatus,
3987
4255
  getCountryAvailability,
3988
4256
  getDataSafety,
3989
4257
  getDeviceTier,
@@ -4034,6 +4302,7 @@ export {
4034
4302
  listTracks,
4035
4303
  listUsers,
4036
4304
  listVoidedPurchases,
4305
+ loadStatusCache,
4037
4306
  migratePrices,
4038
4307
  parseAppfile,
4039
4308
  parseFastfile,
@@ -4055,11 +4324,13 @@ export {
4055
4324
  revokeSubscriptionPurchase,
4056
4325
  safePath,
4057
4326
  safePathWithin,
4327
+ saveStatusCache,
4058
4328
  scaffoldPlugin,
4059
4329
  searchAuditEvents,
4060
4330
  searchVitalsErrors,
4061
4331
  sendWebhook,
4062
4332
  sortResults,
4333
+ statusHasBreach,
4063
4334
  syncInAppProducts,
4064
4335
  updateAppDetails,
4065
4336
  updateDataSafety,