@gpc-cli/core 0.9.6 → 0.9.8

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
@@ -492,8 +492,13 @@ async function uploadRelease(client, packageName, filePath, options) {
492
492
  };
493
493
  }
494
494
  if (!validation.valid) {
495
- throw new Error(`File validation failed:
496
- ${validation.errors.join("\n")}`);
495
+ throw new GpcError(
496
+ `File validation failed:
497
+ ${validation.errors.join("\n")}`,
498
+ "RELEASE_INVALID_FILE",
499
+ 2,
500
+ "Check that the file is a valid AAB or APK and is not corrupted."
501
+ );
497
502
  }
498
503
  const edit = await client.edits.insert(packageName);
499
504
  try {
@@ -559,10 +564,20 @@ async function promoteRelease(client, packageName, fromTrack, toTrack, options)
559
564
  (r) => r.status === "completed" || r.status === "inProgress"
560
565
  );
561
566
  if (!currentRelease) {
562
- throw new Error(`No active release found on track "${fromTrack}"`);
567
+ throw new GpcError(
568
+ `No active release found on track "${fromTrack}"`,
569
+ "RELEASE_NOT_FOUND",
570
+ 1,
571
+ `Ensure there is a completed or in-progress release on the "${fromTrack}" track before promoting.`
572
+ );
563
573
  }
564
574
  if (options?.userFraction && (options.userFraction <= 0 || options.userFraction > 1)) {
565
- throw new Error("Rollout percentage must be between 0 and 1 (e.g., 0.1 for 10%)");
575
+ throw new GpcError(
576
+ "Rollout percentage must be between 0 and 1 (e.g., 0.1 for 10%)",
577
+ "RELEASE_INVALID_FRACTION",
578
+ 2,
579
+ "Use a decimal value like 0.1 for 10%, 0.5 for 50%, or 1.0 for 100%."
580
+ );
566
581
  }
567
582
  const release = {
568
583
  versionCodes: currentRelease.versionCodes,
@@ -593,15 +608,30 @@ async function updateRollout(client, packageName, track, action, userFraction) {
593
608
  (r) => r.status === "inProgress" || r.status === "halted"
594
609
  );
595
610
  if (!currentRelease) {
596
- throw new Error(`No active rollout found on track "${track}"`);
611
+ throw new GpcError(
612
+ `No active rollout found on track "${track}"`,
613
+ "ROLLOUT_NOT_FOUND",
614
+ 1,
615
+ `There is no in-progress or halted rollout on the "${track}" track. Start a staged rollout first with: gpc releases upload --track ${track} --status inProgress --fraction 0.1`
616
+ );
597
617
  }
598
618
  let newStatus;
599
619
  let newFraction;
600
620
  switch (action) {
601
621
  case "increase":
602
- if (!userFraction) throw new Error("--to <percentage> is required for rollout increase");
622
+ if (!userFraction) throw new GpcError(
623
+ "--to <percentage> is required for rollout increase",
624
+ "ROLLOUT_MISSING_FRACTION",
625
+ 2,
626
+ "Specify the target rollout percentage with --to, e.g.: gpc rollout increase --to 0.5"
627
+ );
603
628
  if (userFraction <= 0 || userFraction > 1) {
604
- throw new Error("Rollout percentage must be between 0 and 1 (e.g., 0.1 for 10%)");
629
+ throw new GpcError(
630
+ "Rollout percentage must be between 0 and 1 (e.g., 0.1 for 10%)",
631
+ "RELEASE_INVALID_FRACTION",
632
+ 2,
633
+ "Use a decimal value like 0.1 for 10%, 0.5 for 50%, or 1.0 for 100%."
634
+ );
605
635
  }
606
636
  newStatus = "inProgress";
607
637
  newFraction = userFraction;
@@ -898,7 +928,12 @@ function diffListings(local, remote) {
898
928
  // src/commands/listings.ts
899
929
  function validateLanguage(lang) {
900
930
  if (!isValidBcp47(lang)) {
901
- throw new Error(`Invalid language tag "${lang}". Must be a valid Google Play BCP 47 code.`);
931
+ throw new GpcError(
932
+ `Invalid language tag "${lang}". Must be a valid Google Play BCP 47 code.`,
933
+ "LISTING_INVALID_LANGUAGE",
934
+ 2,
935
+ "Use a valid BCP 47 language code such as en-US, de-DE, or ja-JP. See the Google Play Console for supported language codes."
936
+ );
902
937
  }
903
938
  }
904
939
  async function getListings(client, packageName, language) {
@@ -963,7 +998,12 @@ async function pullListings(client, packageName, dir) {
963
998
  async function pushListings(client, packageName, dir, options) {
964
999
  const localListings = await readListingsFromDir(dir);
965
1000
  if (localListings.length === 0) {
966
- throw new Error(`No listings found in directory "${dir}"`);
1001
+ throw new GpcError(
1002
+ `No listings found in directory "${dir}"`,
1003
+ "LISTING_DIR_EMPTY",
1004
+ 1,
1005
+ `The directory must contain subdirectories named by language code (e.g., en-US/) with listing metadata files. Pull existing listings first with: gpc listings pull --dir "${dir}"`
1006
+ );
967
1007
  }
968
1008
  for (const listing of localListings) {
969
1009
  validateLanguage(listing.language);
@@ -1009,7 +1049,12 @@ async function uploadImage(client, packageName, language, imageType, filePath) {
1009
1049
  validateLanguage(language);
1010
1050
  const imageCheck = await validateImage(filePath, imageType);
1011
1051
  if (!imageCheck.valid) {
1012
- throw new Error(`Image validation failed: ${imageCheck.errors.join("; ")}`);
1052
+ throw new GpcError(
1053
+ `Image validation failed: ${imageCheck.errors.join("; ")}`,
1054
+ "IMAGE_INVALID",
1055
+ 2,
1056
+ "Check image dimensions, file size, and format. Google Play requires PNG or JPEG images within specific size limits per image type."
1057
+ );
1013
1058
  }
1014
1059
  if (imageCheck.warnings.length > 0) {
1015
1060
  for (const w of imageCheck.warnings) {
@@ -1041,6 +1086,19 @@ async function deleteImage(client, packageName, language, imageType, imageId) {
1041
1086
  throw error;
1042
1087
  }
1043
1088
  }
1089
+ async function diffListingsCommand(client, packageName, dir) {
1090
+ const localListings = await readListingsFromDir(dir);
1091
+ const edit = await client.edits.insert(packageName);
1092
+ try {
1093
+ const remoteListings = await client.listings.list(packageName, edit.id);
1094
+ await client.edits.delete(packageName, edit.id);
1095
+ return diffListings(localListings, remoteListings);
1096
+ } catch (error) {
1097
+ await client.edits.delete(packageName, edit.id).catch(() => {
1098
+ });
1099
+ throw error;
1100
+ }
1101
+ }
1044
1102
  async function getCountryAvailability(client, packageName, track) {
1045
1103
  const edit = await client.edits.insert(packageName);
1046
1104
  try {
@@ -1076,7 +1134,12 @@ async function readReleaseNotesFromDir(dir) {
1076
1134
  try {
1077
1135
  entries = await readdir2(dir);
1078
1136
  } catch {
1079
- throw new Error(`Release notes directory not found: ${dir}`);
1137
+ throw new GpcError(
1138
+ `Release notes directory not found: ${dir}`,
1139
+ "RELEASE_NOTES_DIR_NOT_FOUND",
1140
+ 1,
1141
+ `Create the directory and add .txt files named by language code (e.g., en-US.txt). Path: ${dir}`
1142
+ );
1080
1143
  }
1081
1144
  const notes = [];
1082
1145
  for (const entry of entries) {
@@ -1273,12 +1336,20 @@ async function getReview(client, packageName, reviewId, translationLanguage) {
1273
1336
  var MAX_REPLY_LENGTH = 350;
1274
1337
  async function replyToReview(client, packageName, reviewId, replyText) {
1275
1338
  if (replyText.length > MAX_REPLY_LENGTH) {
1276
- throw new Error(
1277
- `Reply text exceeds ${MAX_REPLY_LENGTH} characters (${replyText.length}). Google Play limits replies to ${MAX_REPLY_LENGTH} characters.`
1339
+ throw new GpcError(
1340
+ `Reply text exceeds ${MAX_REPLY_LENGTH} characters (${replyText.length}). Google Play limits replies to ${MAX_REPLY_LENGTH} characters.`,
1341
+ "REVIEW_REPLY_TOO_LONG",
1342
+ 2,
1343
+ `Shorten your reply to ${MAX_REPLY_LENGTH} characters or fewer. Current length: ${replyText.length}.`
1278
1344
  );
1279
1345
  }
1280
1346
  if (replyText.length === 0) {
1281
- throw new Error("Reply text cannot be empty.");
1347
+ throw new GpcError(
1348
+ "Reply text cannot be empty.",
1349
+ "REVIEW_REPLY_EMPTY",
1350
+ 2,
1351
+ "Provide a non-empty reply text with --text or -t."
1352
+ );
1282
1353
  }
1283
1354
  return client.reviews.reply(packageName, reviewId, replyText);
1284
1355
  }
@@ -1611,7 +1682,12 @@ async function syncInAppProducts(client, packageName, dir, options) {
1611
1682
  try {
1612
1683
  localProducts.push(JSON.parse(content));
1613
1684
  } catch {
1614
- throw new Error(`Failed to parse ${file}: invalid JSON`);
1685
+ throw new GpcError(
1686
+ `Failed to parse ${file}: invalid JSON`,
1687
+ "IAP_INVALID_JSON",
1688
+ 1,
1689
+ `Check that "${file}" contains valid JSON. You can validate it with: cat "${file}" | jq .`
1690
+ );
1615
1691
  }
1616
1692
  }
1617
1693
  const response = await client.inappproducts.list(packageName);
@@ -1745,12 +1821,22 @@ function isValidStatsDimension(dim) {
1745
1821
  function parseMonth(monthStr) {
1746
1822
  const match = /^(\d{4})-(\d{2})$/.exec(monthStr);
1747
1823
  if (!match) {
1748
- throw new Error(`Invalid month format "${monthStr}". Expected YYYY-MM (e.g., 2026-03).`);
1824
+ throw new GpcError(
1825
+ `Invalid month format "${monthStr}". Expected YYYY-MM (e.g., 2026-03).`,
1826
+ "REPORT_INVALID_MONTH",
1827
+ 2,
1828
+ "Use the format YYYY-MM, for example: --month 2026-03"
1829
+ );
1749
1830
  }
1750
1831
  const year = Number(match[1]);
1751
1832
  const month = Number(match[2]);
1752
1833
  if (month < 1 || month > 12) {
1753
- throw new Error(`Invalid month "${month}". Must be between 01 and 12.`);
1834
+ throw new GpcError(
1835
+ `Invalid month "${month}". Must be between 01 and 12.`,
1836
+ "REPORT_INVALID_MONTH",
1837
+ 2,
1838
+ "The month value must be between 01 and 12."
1839
+ );
1754
1840
  }
1755
1841
  return { year, month };
1756
1842
  }
@@ -1760,21 +1846,31 @@ async function listReports(client, packageName, reportType, year, month) {
1760
1846
  }
1761
1847
  async function downloadReport(client, packageName, reportType, year, month) {
1762
1848
  const reports = await listReports(client, packageName, reportType, year, month);
1849
+ const monthPadded = String(month).padStart(2, "0");
1763
1850
  if (reports.length === 0) {
1764
- throw new Error(
1765
- `No ${reportType} reports found for ${year}-${String(month).padStart(2, "0")}.`
1851
+ throw new GpcError(
1852
+ `No ${reportType} reports found for ${year}-${monthPadded}.`,
1853
+ "REPORT_NOT_FOUND",
1854
+ 1,
1855
+ `Reports may not be available yet for this period. Financial reports are typically available a few days after the month ends. Try a different month or report type.`
1766
1856
  );
1767
1857
  }
1768
1858
  const bucket = reports[0];
1769
1859
  if (!bucket) {
1770
- throw new Error(
1771
- `No ${reportType} reports found for ${year}-${String(month).padStart(2, "0")}.`
1860
+ throw new GpcError(
1861
+ `No ${reportType} reports found for ${year}-${monthPadded}.`,
1862
+ "REPORT_NOT_FOUND",
1863
+ 1,
1864
+ `Reports may not be available yet for this period. Try a different month or report type.`
1772
1865
  );
1773
1866
  }
1774
1867
  const uri = bucket.uri;
1775
1868
  const response = await fetch(uri);
1776
1869
  if (!response.ok) {
1777
- throw new Error(`Failed to download report from signed URI: HTTP ${response.status}`);
1870
+ throw new NetworkError(
1871
+ `Failed to download report from signed URI: HTTP ${response.status}`,
1872
+ "The signed download URL may have expired. Retry the command to generate a fresh URL."
1873
+ );
1778
1874
  }
1779
1875
  return response.text();
1780
1876
  }
@@ -1825,8 +1921,11 @@ async function removeUser(client, developerId, userId) {
1825
1921
  function parseGrantArg(grantStr) {
1826
1922
  const colonIdx = grantStr.indexOf(":");
1827
1923
  if (colonIdx === -1) {
1828
- throw new Error(
1829
- `Invalid grant format "${grantStr}". Expected <packageName>:<PERMISSION>[,<PERMISSION>...]`
1924
+ throw new GpcError(
1925
+ `Invalid grant format "${grantStr}". Expected <packageName>:<PERMISSION>[,<PERMISSION>...]`,
1926
+ "USER_INVALID_GRANT",
1927
+ 2,
1928
+ "Use the format: com.example.app:VIEW_APP_INFORMATION,MANAGE_STORE_LISTING"
1830
1929
  );
1831
1930
  }
1832
1931
  const packageName = grantStr.slice(0, colonIdx);
@@ -1887,12 +1986,368 @@ async function importTestersFromCsv(client, packageName, track, csvPath) {
1887
1986
  const content = await readFile5(csvPath, "utf-8");
1888
1987
  const emails = content.split(/[,\n\r]+/).map((e) => e.trim()).filter((e) => e.length > 0 && e.includes("@"));
1889
1988
  if (emails.length === 0) {
1890
- throw new Error(`No valid email addresses found in ${csvPath}.`);
1989
+ throw new GpcError(
1990
+ `No valid email addresses found in ${csvPath}.`,
1991
+ "TESTER_NO_EMAILS",
1992
+ 1,
1993
+ "The CSV file must contain email addresses separated by commas or newlines. Each email must contain an @ symbol."
1994
+ );
1891
1995
  }
1892
1996
  const testers = await addTesters(client, packageName, track, emails);
1893
1997
  return { added: emails.length, testers };
1894
1998
  }
1895
1999
 
2000
+ // src/utils/git-notes.ts
2001
+ import { execFile as execFileCb } from "child_process";
2002
+ import { promisify } from "util";
2003
+ var execFile = promisify(execFileCb);
2004
+ var COMMIT_TYPE_HEADERS = {
2005
+ feat: "New",
2006
+ fix: "Fixed",
2007
+ perf: "Improved"
2008
+ };
2009
+ var DEFAULT_MAX_LENGTH = 500;
2010
+ function parseConventionalCommit(subject) {
2011
+ const match = subject.match(/^(\w+)(?:\([^)]*\))?:\s*(.+)$/);
2012
+ if (match) {
2013
+ return { type: match[1], message: match[2].trim() };
2014
+ }
2015
+ return { type: "other", message: subject.trim() };
2016
+ }
2017
+ function formatNotes(commits, maxLength) {
2018
+ const groups = /* @__PURE__ */ new Map();
2019
+ for (const commit of commits) {
2020
+ const header = COMMIT_TYPE_HEADERS[commit.type] || "Changes";
2021
+ if (!groups.has(header)) {
2022
+ groups.set(header, []);
2023
+ }
2024
+ groups.get(header).push(commit.message);
2025
+ }
2026
+ const order = ["New", "Fixed", "Improved", "Changes"];
2027
+ const sections = [];
2028
+ for (const header of order) {
2029
+ const items = groups.get(header);
2030
+ if (!items || items.length === 0) continue;
2031
+ const bullets = items.map((m) => `\u2022 ${m}`).join("\n");
2032
+ sections.push(`${header}:
2033
+ ${bullets}`);
2034
+ }
2035
+ let text = sections.join("\n\n");
2036
+ if (text.length > maxLength) {
2037
+ text = text.slice(0, maxLength - 3) + "...";
2038
+ }
2039
+ return text;
2040
+ }
2041
+ async function gitExec(args) {
2042
+ try {
2043
+ const { stdout } = await execFile("git", args);
2044
+ return stdout.trim();
2045
+ } catch (error) {
2046
+ const err = error;
2047
+ if (err.code === "ENOENT") {
2048
+ throw new GpcError(
2049
+ "git is not available on this system",
2050
+ "GIT_NOT_FOUND",
2051
+ 1,
2052
+ "Install git and ensure it is in your PATH."
2053
+ );
2054
+ }
2055
+ throw error;
2056
+ }
2057
+ }
2058
+ async function generateNotesFromGit(options) {
2059
+ const language = options?.language || "en-US";
2060
+ const maxLength = options?.maxLength ?? DEFAULT_MAX_LENGTH;
2061
+ let since = options?.since;
2062
+ if (!since) {
2063
+ try {
2064
+ since = await gitExec(["describe", "--tags", "--abbrev=0"]);
2065
+ } catch (e) {
2066
+ if (e instanceof GpcError && e.code === "GIT_NOT_FOUND") throw e;
2067
+ throw new GpcError(
2068
+ "No git tags found. Cannot determine commit range for release notes.",
2069
+ "GIT_NO_TAGS",
2070
+ 1,
2071
+ "Create a tag first (e.g., git tag v1.0.0) or use --since <ref> to specify a starting point."
2072
+ );
2073
+ }
2074
+ }
2075
+ let logOutput;
2076
+ try {
2077
+ logOutput = await gitExec(["log", `${since}..HEAD`, "--format=%s"]);
2078
+ } catch (error) {
2079
+ const err = error;
2080
+ const msg = err.stderr || err.message || String(error);
2081
+ throw new GpcError(
2082
+ `Failed to read git log from "${since}": ${msg}`,
2083
+ "GIT_LOG_FAILED",
2084
+ 1,
2085
+ `Verify that "${since}" is a valid git ref (tag, branch, or SHA).`
2086
+ );
2087
+ }
2088
+ if (!logOutput) {
2089
+ return {
2090
+ language,
2091
+ text: "No changes since last release.",
2092
+ commitCount: 0,
2093
+ since
2094
+ };
2095
+ }
2096
+ const subjects = logOutput.split("\n").filter((line) => line.length > 0);
2097
+ const commits = subjects.map(parseConventionalCommit);
2098
+ const text = formatNotes(commits, maxLength);
2099
+ return {
2100
+ language,
2101
+ text,
2102
+ commitCount: subjects.length,
2103
+ since
2104
+ };
2105
+ }
2106
+
2107
+ // src/commands/app-recovery.ts
2108
+ async function listRecoveryActions(client, packageName) {
2109
+ return client.appRecovery.list(packageName);
2110
+ }
2111
+ async function cancelRecoveryAction(client, packageName, recoveryId) {
2112
+ return client.appRecovery.cancel(packageName, recoveryId);
2113
+ }
2114
+ async function deployRecoveryAction(client, packageName, recoveryId) {
2115
+ return client.appRecovery.deploy(packageName, recoveryId);
2116
+ }
2117
+ async function createRecoveryAction(client, packageName, request) {
2118
+ return client.appRecovery.create(packageName, request);
2119
+ }
2120
+ async function addRecoveryTargeting(client, packageName, actionId, targeting) {
2121
+ return client.appRecovery.addTargeting(packageName, actionId, targeting);
2122
+ }
2123
+
2124
+ // src/commands/data-safety.ts
2125
+ import { readFile as readFile6, writeFile as writeFile2 } from "fs/promises";
2126
+ async function getDataSafety(client, packageName) {
2127
+ const edit = await client.edits.insert(packageName);
2128
+ try {
2129
+ const dataSafety = await client.dataSafety.get(packageName, edit.id);
2130
+ await client.edits.delete(packageName, edit.id);
2131
+ return dataSafety;
2132
+ } catch (error) {
2133
+ await client.edits.delete(packageName, edit.id).catch(() => {
2134
+ });
2135
+ throw error;
2136
+ }
2137
+ }
2138
+ async function updateDataSafety(client, packageName, data) {
2139
+ const edit = await client.edits.insert(packageName);
2140
+ try {
2141
+ const result = await client.dataSafety.update(packageName, edit.id, data);
2142
+ await client.edits.validate(packageName, edit.id);
2143
+ await client.edits.commit(packageName, edit.id);
2144
+ return result;
2145
+ } catch (error) {
2146
+ await client.edits.delete(packageName, edit.id).catch(() => {
2147
+ });
2148
+ throw error;
2149
+ }
2150
+ }
2151
+ async function exportDataSafety(client, packageName, outputPath) {
2152
+ const dataSafety = await getDataSafety(client, packageName);
2153
+ await writeFile2(outputPath, JSON.stringify(dataSafety, null, 2) + "\n", "utf-8");
2154
+ return dataSafety;
2155
+ }
2156
+ async function importDataSafety(client, packageName, filePath) {
2157
+ const content = await readFile6(filePath, "utf-8");
2158
+ let data;
2159
+ try {
2160
+ data = JSON.parse(content);
2161
+ } catch {
2162
+ throw new Error(`Failed to parse data safety JSON from "${filePath}"`);
2163
+ }
2164
+ return updateDataSafety(client, packageName, data);
2165
+ }
2166
+
2167
+ // src/commands/external-transactions.ts
2168
+ async function createExternalTransaction(client, packageName, data) {
2169
+ return client.externalTransactions.create(packageName, data);
2170
+ }
2171
+ async function getExternalTransaction(client, packageName, transactionId) {
2172
+ return client.externalTransactions.get(packageName, transactionId);
2173
+ }
2174
+ async function refundExternalTransaction(client, packageName, transactionId, refundData) {
2175
+ return client.externalTransactions.refund(packageName, transactionId, refundData);
2176
+ }
2177
+
2178
+ // src/commands/device-tiers.ts
2179
+ async function listDeviceTiers(client, packageName) {
2180
+ if (!packageName) {
2181
+ throw new GpcError(
2182
+ "Package name is required",
2183
+ "MISSING_PACKAGE_NAME",
2184
+ 2,
2185
+ "Provide a package name with --app or set it in config."
2186
+ );
2187
+ }
2188
+ return client.deviceTiers.list(packageName);
2189
+ }
2190
+ async function getDeviceTier(client, packageName, configId) {
2191
+ if (!packageName) {
2192
+ throw new GpcError(
2193
+ "Package name is required",
2194
+ "MISSING_PACKAGE_NAME",
2195
+ 2,
2196
+ "Provide a package name with --app or set it in config."
2197
+ );
2198
+ }
2199
+ if (!configId) {
2200
+ throw new GpcError(
2201
+ "Config ID is required",
2202
+ "MISSING_CONFIG_ID",
2203
+ 2,
2204
+ "Provide a device tier config ID."
2205
+ );
2206
+ }
2207
+ return client.deviceTiers.get(packageName, configId);
2208
+ }
2209
+ async function createDeviceTier(client, packageName, config) {
2210
+ if (!packageName) {
2211
+ throw new GpcError(
2212
+ "Package name is required",
2213
+ "MISSING_PACKAGE_NAME",
2214
+ 2,
2215
+ "Provide a package name with --app or set it in config."
2216
+ );
2217
+ }
2218
+ if (!config || !config.deviceGroups || config.deviceGroups.length === 0) {
2219
+ throw new GpcError(
2220
+ "Device tier config must include at least one device group",
2221
+ "INVALID_DEVICE_TIER_CONFIG",
2222
+ 2,
2223
+ "Provide a valid config with deviceGroups."
2224
+ );
2225
+ }
2226
+ return client.deviceTiers.create(packageName, config);
2227
+ }
2228
+
2229
+ // src/commands/one-time-products.ts
2230
+ async function listOneTimeProducts(client, packageName) {
2231
+ try {
2232
+ return await client.oneTimeProducts.list(packageName);
2233
+ } catch (error) {
2234
+ throw new GpcError(
2235
+ `Failed to list one-time products: ${error instanceof Error ? error.message : String(error)}`,
2236
+ "OTP_LIST_FAILED",
2237
+ 4,
2238
+ "Check your package name and API credentials."
2239
+ );
2240
+ }
2241
+ }
2242
+ async function getOneTimeProduct(client, packageName, productId) {
2243
+ try {
2244
+ return await client.oneTimeProducts.get(packageName, productId);
2245
+ } catch (error) {
2246
+ throw new GpcError(
2247
+ `Failed to get one-time product "${productId}": ${error instanceof Error ? error.message : String(error)}`,
2248
+ "OTP_GET_FAILED",
2249
+ 4,
2250
+ "Check that the product ID exists."
2251
+ );
2252
+ }
2253
+ }
2254
+ async function createOneTimeProduct(client, packageName, data) {
2255
+ try {
2256
+ return await client.oneTimeProducts.create(packageName, data);
2257
+ } catch (error) {
2258
+ throw new GpcError(
2259
+ `Failed to create one-time product: ${error instanceof Error ? error.message : String(error)}`,
2260
+ "OTP_CREATE_FAILED",
2261
+ 4,
2262
+ "Verify the product data and ensure the product ID is unique."
2263
+ );
2264
+ }
2265
+ }
2266
+ async function updateOneTimeProduct(client, packageName, productId, data) {
2267
+ try {
2268
+ return await client.oneTimeProducts.update(packageName, productId, data);
2269
+ } catch (error) {
2270
+ throw new GpcError(
2271
+ `Failed to update one-time product "${productId}": ${error instanceof Error ? error.message : String(error)}`,
2272
+ "OTP_UPDATE_FAILED",
2273
+ 4,
2274
+ "Check that the product ID exists and the data is valid."
2275
+ );
2276
+ }
2277
+ }
2278
+ async function deleteOneTimeProduct(client, packageName, productId) {
2279
+ try {
2280
+ await client.oneTimeProducts.delete(packageName, productId);
2281
+ } catch (error) {
2282
+ throw new GpcError(
2283
+ `Failed to delete one-time product "${productId}": ${error instanceof Error ? error.message : String(error)}`,
2284
+ "OTP_DELETE_FAILED",
2285
+ 4,
2286
+ "Check that the product ID exists and is not active."
2287
+ );
2288
+ }
2289
+ }
2290
+ async function listOneTimeOffers(client, packageName, productId) {
2291
+ try {
2292
+ return await client.oneTimeProducts.listOffers(packageName, productId);
2293
+ } catch (error) {
2294
+ throw new GpcError(
2295
+ `Failed to list offers for product "${productId}": ${error instanceof Error ? error.message : String(error)}`,
2296
+ "OTP_OFFERS_LIST_FAILED",
2297
+ 4,
2298
+ "Check the product ID and your API credentials."
2299
+ );
2300
+ }
2301
+ }
2302
+ async function getOneTimeOffer(client, packageName, productId, offerId) {
2303
+ try {
2304
+ return await client.oneTimeProducts.getOffer(packageName, productId, offerId);
2305
+ } catch (error) {
2306
+ throw new GpcError(
2307
+ `Failed to get offer "${offerId}" for product "${productId}": ${error instanceof Error ? error.message : String(error)}`,
2308
+ "OTP_OFFER_GET_FAILED",
2309
+ 4,
2310
+ "Check that the product and offer IDs exist."
2311
+ );
2312
+ }
2313
+ }
2314
+ async function createOneTimeOffer(client, packageName, productId, data) {
2315
+ try {
2316
+ return await client.oneTimeProducts.createOffer(packageName, productId, data);
2317
+ } catch (error) {
2318
+ throw new GpcError(
2319
+ `Failed to create offer for product "${productId}": ${error instanceof Error ? error.message : String(error)}`,
2320
+ "OTP_OFFER_CREATE_FAILED",
2321
+ 4,
2322
+ "Verify the offer data and ensure the offer ID is unique."
2323
+ );
2324
+ }
2325
+ }
2326
+ async function updateOneTimeOffer(client, packageName, productId, offerId, data) {
2327
+ try {
2328
+ return await client.oneTimeProducts.updateOffer(packageName, productId, offerId, data);
2329
+ } catch (error) {
2330
+ throw new GpcError(
2331
+ `Failed to update offer "${offerId}" for product "${productId}": ${error instanceof Error ? error.message : String(error)}`,
2332
+ "OTP_OFFER_UPDATE_FAILED",
2333
+ 4,
2334
+ "Check that the product and offer IDs exist and the data is valid."
2335
+ );
2336
+ }
2337
+ }
2338
+ async function deleteOneTimeOffer(client, packageName, productId, offerId) {
2339
+ try {
2340
+ await client.oneTimeProducts.deleteOffer(packageName, productId, offerId);
2341
+ } catch (error) {
2342
+ throw new GpcError(
2343
+ `Failed to delete offer "${offerId}" for product "${productId}": ${error instanceof Error ? error.message : String(error)}`,
2344
+ "OTP_OFFER_DELETE_FAILED",
2345
+ 4,
2346
+ "Check that the product and offer IDs exist."
2347
+ );
2348
+ }
2349
+ }
2350
+
1896
2351
  // src/utils/safe-path.ts
1897
2352
  import { resolve, normalize } from "path";
1898
2353
  function safePath(userPath) {
@@ -1902,13 +2357,58 @@ function safePathWithin(userPath, baseDir) {
1902
2357
  const resolved = safePath(userPath);
1903
2358
  const base = safePath(baseDir);
1904
2359
  if (!resolved.startsWith(base + "/") && resolved !== base) {
1905
- throw new Error(`Path "${userPath}" resolves outside the expected directory "${baseDir}"`);
2360
+ throw new GpcError(
2361
+ `Path "${userPath}" resolves outside the expected directory "${baseDir}"`,
2362
+ "PATH_TRAVERSAL",
2363
+ 2,
2364
+ "The path must stay within the target directory. Remove any ../ segments or use an absolute path within the expected directory."
2365
+ );
1906
2366
  }
1907
2367
  return resolved;
1908
2368
  }
1909
2369
 
2370
+ // src/utils/sort.ts
2371
+ function getNestedValue(obj, path) {
2372
+ const parts = path.split(".");
2373
+ let current = obj;
2374
+ for (const part of parts) {
2375
+ if (current === null || current === void 0 || typeof current !== "object") {
2376
+ return void 0;
2377
+ }
2378
+ current = current[part];
2379
+ }
2380
+ return current;
2381
+ }
2382
+ function compare(a, b) {
2383
+ if (a === void 0 && b === void 0) return 0;
2384
+ if (a === void 0) return 1;
2385
+ if (b === void 0) return -1;
2386
+ if (typeof a === "number" && typeof b === "number") {
2387
+ return a - b;
2388
+ }
2389
+ return String(a).localeCompare(String(b));
2390
+ }
2391
+ function sortResults(items, sortSpec) {
2392
+ if (!sortSpec || items.length === 0) {
2393
+ return items;
2394
+ }
2395
+ const descending = sortSpec.startsWith("-");
2396
+ const field = descending ? sortSpec.slice(1) : sortSpec;
2397
+ const hasField = items.some((item) => getNestedValue(item, field) !== void 0);
2398
+ if (!hasField) {
2399
+ return items;
2400
+ }
2401
+ const sorted = [...items].sort((a, b) => {
2402
+ const aVal = getNestedValue(a, field);
2403
+ const bVal = getNestedValue(b, field);
2404
+ const result = compare(aVal, bVal);
2405
+ return descending ? -result : result;
2406
+ });
2407
+ return sorted;
2408
+ }
2409
+
1910
2410
  // src/commands/plugin-scaffold.ts
1911
- import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
2411
+ import { mkdir as mkdir2, writeFile as writeFile3 } from "fs/promises";
1912
2412
  import { join as join4 } from "path";
1913
2413
  async function scaffoldPlugin(options) {
1914
2414
  const { name, dir, description = `GPC plugin: ${name}` } = options;
@@ -1951,7 +2451,7 @@ async function scaffoldPlugin(options) {
1951
2451
  vitest: "^3.0.0"
1952
2452
  }
1953
2453
  };
1954
- await writeFile2(join4(dir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
2454
+ await writeFile3(join4(dir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
1955
2455
  files.push("package.json");
1956
2456
  const tsconfig = {
1957
2457
  compilerOptions: {
@@ -1967,7 +2467,7 @@ async function scaffoldPlugin(options) {
1967
2467
  },
1968
2468
  include: ["src"]
1969
2469
  };
1970
- await writeFile2(join4(dir, "tsconfig.json"), JSON.stringify(tsconfig, null, 2) + "\n");
2470
+ await writeFile3(join4(dir, "tsconfig.json"), JSON.stringify(tsconfig, null, 2) + "\n");
1971
2471
  files.push("tsconfig.json");
1972
2472
  const srcContent = `import { definePlugin } from "@gpc-cli/plugin-sdk";
1973
2473
  import type { CommandEvent, CommandResult } from "@gpc-cli/plugin-sdk";
@@ -2000,7 +2500,7 @@ export const plugin = definePlugin({
2000
2500
  },
2001
2501
  });
2002
2502
  `;
2003
- await writeFile2(join4(srcDir, "index.ts"), srcContent);
2503
+ await writeFile3(join4(srcDir, "index.ts"), srcContent);
2004
2504
  files.push("src/index.ts");
2005
2505
  const testContent = `import { describe, it, expect, vi } from "vitest";
2006
2506
  import { plugin } from "../src/index";
@@ -2025,7 +2525,7 @@ describe("${pluginName}", () => {
2025
2525
  });
2026
2526
  });
2027
2527
  `;
2028
- await writeFile2(join4(testDir, "plugin.test.ts"), testContent);
2528
+ await writeFile3(join4(testDir, "plugin.test.ts"), testContent);
2029
2529
  files.push("tests/plugin.test.ts");
2030
2530
  return { dir, files };
2031
2531
  }
@@ -2078,6 +2578,186 @@ function createAuditEntry(command, args, app) {
2078
2578
  args
2079
2579
  };
2080
2580
  }
2581
+
2582
+ // src/utils/webhooks.ts
2583
+ function formatSlackPayload(payload) {
2584
+ const status = payload.success ? "\u2713" : "\u2717";
2585
+ const color = payload.success ? "#2eb886" : "#e01e5a";
2586
+ const durationSec = (payload.duration / 1e3).toFixed(1);
2587
+ const fields = [
2588
+ { title: "Command", value: payload.command, short: true },
2589
+ { title: "Duration", value: `${durationSec}s`, short: true }
2590
+ ];
2591
+ if (payload.app) {
2592
+ fields.push({ title: "App", value: payload.app, short: true });
2593
+ }
2594
+ if (payload.error) {
2595
+ fields.push({ title: "Error", value: payload.error, short: false });
2596
+ }
2597
+ if (payload.details) {
2598
+ for (const [key, value] of Object.entries(payload.details)) {
2599
+ fields.push({ title: key, value: String(value), short: true });
2600
+ }
2601
+ }
2602
+ return {
2603
+ attachments: [
2604
+ {
2605
+ color,
2606
+ fallback: `GPC: ${payload.command} ${status}`,
2607
+ title: `GPC: ${payload.command} ${status}`,
2608
+ fields,
2609
+ footer: "GPC CLI",
2610
+ ts: Math.floor(Date.now() / 1e3)
2611
+ }
2612
+ ]
2613
+ };
2614
+ }
2615
+ function formatDiscordPayload(payload) {
2616
+ const status = payload.success ? "\u2713" : "\u2717";
2617
+ const color = payload.success ? 3061894 : 14687834;
2618
+ const durationSec = (payload.duration / 1e3).toFixed(1);
2619
+ const fields = [
2620
+ { name: "Command", value: payload.command, inline: true },
2621
+ { name: "Duration", value: `${durationSec}s`, inline: true }
2622
+ ];
2623
+ if (payload.app) {
2624
+ fields.push({ name: "App", value: payload.app, inline: true });
2625
+ }
2626
+ if (payload.error) {
2627
+ fields.push({ name: "Error", value: payload.error, inline: false });
2628
+ }
2629
+ if (payload.details) {
2630
+ for (const [key, value] of Object.entries(payload.details)) {
2631
+ fields.push({ name: key, value: String(value), inline: true });
2632
+ }
2633
+ }
2634
+ return {
2635
+ embeds: [
2636
+ {
2637
+ title: `GPC: ${payload.command} ${status}`,
2638
+ color,
2639
+ fields,
2640
+ footer: { text: "GPC CLI" },
2641
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2642
+ }
2643
+ ]
2644
+ };
2645
+ }
2646
+ function formatCustomPayload(payload) {
2647
+ return { ...payload };
2648
+ }
2649
+ var FORMATTERS = {
2650
+ slack: formatSlackPayload,
2651
+ discord: formatDiscordPayload,
2652
+ custom: formatCustomPayload
2653
+ };
2654
+ var WEBHOOK_TIMEOUT_MS = 5e3;
2655
+ async function sendSingle(url, body) {
2656
+ const controller = new AbortController();
2657
+ const timer = setTimeout(() => controller.abort(), WEBHOOK_TIMEOUT_MS);
2658
+ try {
2659
+ await fetch(url, {
2660
+ method: "POST",
2661
+ headers: { "Content-Type": "application/json" },
2662
+ body: JSON.stringify(body),
2663
+ signal: controller.signal
2664
+ });
2665
+ } finally {
2666
+ clearTimeout(timer);
2667
+ }
2668
+ }
2669
+ async function sendWebhook(config, payload, target) {
2670
+ try {
2671
+ const targets = target ? [target] : Object.keys(FORMATTERS);
2672
+ const promises = [];
2673
+ for (const t of targets) {
2674
+ const url = config[t];
2675
+ if (!url) continue;
2676
+ const formatter = FORMATTERS[t];
2677
+ if (!formatter) continue;
2678
+ const body = formatter(payload);
2679
+ promises.push(sendSingle(url, body).catch(() => {
2680
+ }));
2681
+ }
2682
+ await Promise.all(promises);
2683
+ } catch {
2684
+ }
2685
+ }
2686
+
2687
+ // src/commands/internal-sharing.ts
2688
+ import { extname as extname4 } from "path";
2689
+ async function uploadInternalSharing(client, packageName, filePath, fileType) {
2690
+ const resolvedType = fileType ?? detectFileType(filePath);
2691
+ const validation = await validateUploadFile(filePath);
2692
+ if (!validation.valid) {
2693
+ throw new GpcError(
2694
+ `File validation failed:
2695
+ ${validation.errors.join("\n")}`,
2696
+ "INTERNAL_SHARING_INVALID_FILE",
2697
+ 2,
2698
+ "Check that the file is a valid AAB or APK and is not corrupted."
2699
+ );
2700
+ }
2701
+ let artifact;
2702
+ if (resolvedType === "bundle") {
2703
+ artifact = await client.internalAppSharing.uploadBundle(packageName, filePath);
2704
+ } else {
2705
+ artifact = await client.internalAppSharing.uploadApk(packageName, filePath);
2706
+ }
2707
+ return {
2708
+ downloadUrl: artifact.downloadUrl,
2709
+ sha256: artifact.sha256,
2710
+ certificateFingerprint: artifact.certificateFingerprint,
2711
+ fileType: resolvedType
2712
+ };
2713
+ }
2714
+ function detectFileType(filePath) {
2715
+ const ext = extname4(filePath).toLowerCase();
2716
+ if (ext === ".aab") return "bundle";
2717
+ if (ext === ".apk") return "apk";
2718
+ throw new GpcError(
2719
+ `Cannot detect file type from extension "${ext}". Use --type to specify bundle or apk.`,
2720
+ "INTERNAL_SHARING_UNKNOWN_TYPE",
2721
+ 2,
2722
+ "Use --type bundle for .aab files or --type apk for .apk files."
2723
+ );
2724
+ }
2725
+
2726
+ // src/commands/generated-apks.ts
2727
+ import { writeFile as writeFile4 } from "fs/promises";
2728
+ async function listGeneratedApks(client, packageName, versionCode) {
2729
+ if (!Number.isInteger(versionCode) || versionCode <= 0) {
2730
+ throw new GpcError(
2731
+ `Invalid version code: ${versionCode}`,
2732
+ "GENERATED_APKS_INVALID_VERSION",
2733
+ 2,
2734
+ "Provide a positive integer version code."
2735
+ );
2736
+ }
2737
+ return client.generatedApks.list(packageName, versionCode);
2738
+ }
2739
+ async function downloadGeneratedApk(client, packageName, versionCode, apkId, outputPath) {
2740
+ if (!Number.isInteger(versionCode) || versionCode <= 0) {
2741
+ throw new GpcError(
2742
+ `Invalid version code: ${versionCode}`,
2743
+ "GENERATED_APKS_INVALID_VERSION",
2744
+ 2,
2745
+ "Provide a positive integer version code."
2746
+ );
2747
+ }
2748
+ if (!apkId) {
2749
+ throw new GpcError(
2750
+ "APK ID is required",
2751
+ "GENERATED_APKS_MISSING_ID",
2752
+ 2,
2753
+ "Provide the generated APK ID. Use 'gpc generated-apks list <version-code>' to see available APKs."
2754
+ );
2755
+ }
2756
+ const buffer = await client.generatedApks.download(packageName, versionCode, apkId);
2757
+ const bytes = new Uint8Array(buffer);
2758
+ await writeFile4(outputPath, bytes);
2759
+ return { path: outputPath, sizeBytes: bytes.byteLength };
2760
+ }
2081
2761
  export {
2082
2762
  ApiError,
2083
2763
  ConfigError,
@@ -2089,15 +2769,22 @@ export {
2089
2769
  acknowledgeProductPurchase,
2090
2770
  activateBasePlan,
2091
2771
  activateOffer,
2772
+ addRecoveryTargeting,
2092
2773
  addTesters,
2774
+ cancelRecoveryAction,
2093
2775
  cancelSubscriptionPurchase,
2094
2776
  checkThreshold,
2095
2777
  compareVitalsTrend,
2096
2778
  consumeProductPurchase,
2097
2779
  convertRegionPrices,
2098
2780
  createAuditEntry,
2781
+ createDeviceTier,
2782
+ createExternalTransaction,
2099
2783
  createInAppProduct,
2100
2784
  createOffer,
2785
+ createOneTimeOffer,
2786
+ createOneTimeProduct,
2787
+ createRecoveryAction,
2101
2788
  createSubscription,
2102
2789
  deactivateBasePlan,
2103
2790
  deactivateOffer,
@@ -2107,18 +2794,33 @@ export {
2107
2794
  deleteInAppProduct,
2108
2795
  deleteListing,
2109
2796
  deleteOffer,
2797
+ deleteOneTimeOffer,
2798
+ deleteOneTimeProduct,
2110
2799
  deleteSubscription,
2800
+ deployRecoveryAction,
2111
2801
  detectOutputFormat,
2112
2802
  diffListings,
2803
+ diffListingsCommand,
2113
2804
  discoverPlugins,
2805
+ downloadGeneratedApk,
2114
2806
  downloadReport,
2807
+ exportDataSafety,
2115
2808
  exportReviews,
2809
+ formatCustomPayload,
2810
+ formatDiscordPayload,
2116
2811
  formatOutput,
2812
+ formatSlackPayload,
2813
+ generateNotesFromGit,
2117
2814
  getAppInfo,
2118
2815
  getCountryAvailability,
2816
+ getDataSafety,
2817
+ getDeviceTier,
2818
+ getExternalTransaction,
2119
2819
  getInAppProduct,
2120
2820
  getListings,
2121
2821
  getOffer,
2822
+ getOneTimeOffer,
2823
+ getOneTimeProduct,
2122
2824
  getProductPurchase,
2123
2825
  getReleasesStatus,
2124
2826
  getReview,
@@ -2133,6 +2835,7 @@ export {
2133
2835
  getVitalsOverview,
2134
2836
  getVitalsRendering,
2135
2837
  getVitalsStartup,
2838
+ importDataSafety,
2136
2839
  importTestersFromCsv,
2137
2840
  initAudit,
2138
2841
  inviteUser,
@@ -2141,9 +2844,14 @@ export {
2141
2844
  isValidBcp47,
2142
2845
  isValidReportType,
2143
2846
  isValidStatsDimension,
2847
+ listDeviceTiers,
2848
+ listGeneratedApks,
2144
2849
  listImages,
2145
2850
  listInAppProducts,
2146
2851
  listOffers,
2852
+ listOneTimeOffers,
2853
+ listOneTimeProducts,
2854
+ listRecoveryActions,
2147
2855
  listReports,
2148
2856
  listReviews,
2149
2857
  listSubscriptions,
@@ -2161,6 +2869,7 @@ export {
2161
2869
  readListingsFromDir,
2162
2870
  readReleaseNotesFromDir,
2163
2871
  redactSensitive,
2872
+ refundExternalTransaction,
2164
2873
  refundOrder,
2165
2874
  removeTesters,
2166
2875
  removeUser,
@@ -2170,15 +2879,21 @@ export {
2170
2879
  safePathWithin,
2171
2880
  scaffoldPlugin,
2172
2881
  searchVitalsErrors,
2882
+ sendWebhook,
2883
+ sortResults,
2173
2884
  syncInAppProducts,
2174
2885
  updateAppDetails,
2886
+ updateDataSafety,
2175
2887
  updateInAppProduct,
2176
2888
  updateListing,
2177
2889
  updateOffer,
2890
+ updateOneTimeOffer,
2891
+ updateOneTimeProduct,
2178
2892
  updateRollout,
2179
2893
  updateSubscription,
2180
2894
  updateUser,
2181
2895
  uploadImage,
2896
+ uploadInternalSharing,
2182
2897
  uploadRelease,
2183
2898
  validateImage,
2184
2899
  validatePreSubmission,