@gpc-cli/core 0.9.5 → 0.9.7
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.d.ts +88 -5
- package/dist/index.js +514 -31
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -459,9 +459,46 @@ function formatSize(bytes) {
|
|
|
459
459
|
// src/commands/releases.ts
|
|
460
460
|
async function uploadRelease(client, packageName, filePath, options) {
|
|
461
461
|
const validation = await validateUploadFile(filePath);
|
|
462
|
+
if (options.dryRun) {
|
|
463
|
+
const plannedStatus = options.status || (options.userFraction ? "inProgress" : "completed");
|
|
464
|
+
let currentReleases = [];
|
|
465
|
+
const edit2 = await client.edits.insert(packageName);
|
|
466
|
+
try {
|
|
467
|
+
const trackData = await client.tracks.get(packageName, edit2.id, options.track);
|
|
468
|
+
currentReleases = (trackData.releases || []).map((r) => ({
|
|
469
|
+
versionCodes: r.versionCodes || [],
|
|
470
|
+
status: r.status,
|
|
471
|
+
...r.userFraction !== void 0 && { userFraction: r.userFraction }
|
|
472
|
+
}));
|
|
473
|
+
} catch {
|
|
474
|
+
} finally {
|
|
475
|
+
await client.edits.delete(packageName, edit2.id).catch(() => {
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
dryRun: true,
|
|
480
|
+
file: {
|
|
481
|
+
path: filePath,
|
|
482
|
+
valid: validation.valid,
|
|
483
|
+
errors: validation.errors,
|
|
484
|
+
warnings: validation.warnings
|
|
485
|
+
},
|
|
486
|
+
track: options.track,
|
|
487
|
+
currentReleases,
|
|
488
|
+
plannedRelease: {
|
|
489
|
+
status: plannedStatus,
|
|
490
|
+
...options.userFraction !== void 0 && { userFraction: options.userFraction }
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
}
|
|
462
494
|
if (!validation.valid) {
|
|
463
|
-
throw new
|
|
464
|
-
|
|
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
|
+
);
|
|
465
502
|
}
|
|
466
503
|
const edit = await client.edits.insert(packageName);
|
|
467
504
|
try {
|
|
@@ -527,10 +564,20 @@ async function promoteRelease(client, packageName, fromTrack, toTrack, options)
|
|
|
527
564
|
(r) => r.status === "completed" || r.status === "inProgress"
|
|
528
565
|
);
|
|
529
566
|
if (!currentRelease) {
|
|
530
|
-
throw new
|
|
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
|
+
);
|
|
531
573
|
}
|
|
532
574
|
if (options?.userFraction && (options.userFraction <= 0 || options.userFraction > 1)) {
|
|
533
|
-
throw new
|
|
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
|
+
);
|
|
534
581
|
}
|
|
535
582
|
const release = {
|
|
536
583
|
versionCodes: currentRelease.versionCodes,
|
|
@@ -561,15 +608,30 @@ async function updateRollout(client, packageName, track, action, userFraction) {
|
|
|
561
608
|
(r) => r.status === "inProgress" || r.status === "halted"
|
|
562
609
|
);
|
|
563
610
|
if (!currentRelease) {
|
|
564
|
-
throw new
|
|
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
|
+
);
|
|
565
617
|
}
|
|
566
618
|
let newStatus;
|
|
567
619
|
let newFraction;
|
|
568
620
|
switch (action) {
|
|
569
621
|
case "increase":
|
|
570
|
-
if (!userFraction) throw new
|
|
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
|
+
);
|
|
571
628
|
if (userFraction <= 0 || userFraction > 1) {
|
|
572
|
-
throw new
|
|
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
|
+
);
|
|
573
635
|
}
|
|
574
636
|
newStatus = "inProgress";
|
|
575
637
|
newFraction = userFraction;
|
|
@@ -866,7 +928,12 @@ function diffListings(local, remote) {
|
|
|
866
928
|
// src/commands/listings.ts
|
|
867
929
|
function validateLanguage(lang) {
|
|
868
930
|
if (!isValidBcp47(lang)) {
|
|
869
|
-
throw new
|
|
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
|
+
);
|
|
870
937
|
}
|
|
871
938
|
}
|
|
872
939
|
async function getListings(client, packageName, language) {
|
|
@@ -931,7 +998,12 @@ async function pullListings(client, packageName, dir) {
|
|
|
931
998
|
async function pushListings(client, packageName, dir, options) {
|
|
932
999
|
const localListings = await readListingsFromDir(dir);
|
|
933
1000
|
if (localListings.length === 0) {
|
|
934
|
-
throw new
|
|
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
|
+
);
|
|
935
1007
|
}
|
|
936
1008
|
for (const listing of localListings) {
|
|
937
1009
|
validateLanguage(listing.language);
|
|
@@ -977,7 +1049,12 @@ async function uploadImage(client, packageName, language, imageType, filePath) {
|
|
|
977
1049
|
validateLanguage(language);
|
|
978
1050
|
const imageCheck = await validateImage(filePath, imageType);
|
|
979
1051
|
if (!imageCheck.valid) {
|
|
980
|
-
throw new
|
|
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
|
+
);
|
|
981
1058
|
}
|
|
982
1059
|
if (imageCheck.warnings.length > 0) {
|
|
983
1060
|
for (const w of imageCheck.warnings) {
|
|
@@ -1009,6 +1086,19 @@ async function deleteImage(client, packageName, language, imageType, imageId) {
|
|
|
1009
1086
|
throw error;
|
|
1010
1087
|
}
|
|
1011
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
|
+
}
|
|
1012
1102
|
async function getCountryAvailability(client, packageName, track) {
|
|
1013
1103
|
const edit = await client.edits.insert(packageName);
|
|
1014
1104
|
try {
|
|
@@ -1044,7 +1134,12 @@ async function readReleaseNotesFromDir(dir) {
|
|
|
1044
1134
|
try {
|
|
1045
1135
|
entries = await readdir2(dir);
|
|
1046
1136
|
} catch {
|
|
1047
|
-
throw new
|
|
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
|
+
);
|
|
1048
1143
|
}
|
|
1049
1144
|
const notes = [];
|
|
1050
1145
|
for (const entry of entries) {
|
|
@@ -1185,6 +1280,14 @@ async function publish(client, packageName, filePath, options) {
|
|
|
1185
1280
|
track: options.track || "internal",
|
|
1186
1281
|
notes: releaseNotes
|
|
1187
1282
|
});
|
|
1283
|
+
if (options.dryRun) {
|
|
1284
|
+
const upload2 = await uploadRelease(client, packageName, filePath, {
|
|
1285
|
+
track: options.track || "internal",
|
|
1286
|
+
userFraction: options.rolloutPercent ? options.rolloutPercent / 100 : void 0,
|
|
1287
|
+
dryRun: true
|
|
1288
|
+
});
|
|
1289
|
+
return { dryRun: true, validation, upload: upload2 };
|
|
1290
|
+
}
|
|
1188
1291
|
if (!validation.valid) {
|
|
1189
1292
|
return { validation };
|
|
1190
1293
|
}
|
|
@@ -1233,12 +1336,20 @@ async function getReview(client, packageName, reviewId, translationLanguage) {
|
|
|
1233
1336
|
var MAX_REPLY_LENGTH = 350;
|
|
1234
1337
|
async function replyToReview(client, packageName, reviewId, replyText) {
|
|
1235
1338
|
if (replyText.length > MAX_REPLY_LENGTH) {
|
|
1236
|
-
throw new
|
|
1237
|
-
`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}.`
|
|
1238
1344
|
);
|
|
1239
1345
|
}
|
|
1240
1346
|
if (replyText.length === 0) {
|
|
1241
|
-
throw new
|
|
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
|
+
);
|
|
1242
1353
|
}
|
|
1243
1354
|
return client.reviews.reply(packageName, reviewId, replyText);
|
|
1244
1355
|
}
|
|
@@ -1571,7 +1682,12 @@ async function syncInAppProducts(client, packageName, dir, options) {
|
|
|
1571
1682
|
try {
|
|
1572
1683
|
localProducts.push(JSON.parse(content));
|
|
1573
1684
|
} catch {
|
|
1574
|
-
throw new
|
|
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
|
+
);
|
|
1575
1691
|
}
|
|
1576
1692
|
}
|
|
1577
1693
|
const response = await client.inappproducts.list(packageName);
|
|
@@ -1705,12 +1821,22 @@ function isValidStatsDimension(dim) {
|
|
|
1705
1821
|
function parseMonth(monthStr) {
|
|
1706
1822
|
const match = /^(\d{4})-(\d{2})$/.exec(monthStr);
|
|
1707
1823
|
if (!match) {
|
|
1708
|
-
throw new
|
|
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
|
+
);
|
|
1709
1830
|
}
|
|
1710
1831
|
const year = Number(match[1]);
|
|
1711
1832
|
const month = Number(match[2]);
|
|
1712
1833
|
if (month < 1 || month > 12) {
|
|
1713
|
-
throw new
|
|
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
|
+
);
|
|
1714
1840
|
}
|
|
1715
1841
|
return { year, month };
|
|
1716
1842
|
}
|
|
@@ -1720,21 +1846,31 @@ async function listReports(client, packageName, reportType, year, month) {
|
|
|
1720
1846
|
}
|
|
1721
1847
|
async function downloadReport(client, packageName, reportType, year, month) {
|
|
1722
1848
|
const reports = await listReports(client, packageName, reportType, year, month);
|
|
1849
|
+
const monthPadded = String(month).padStart(2, "0");
|
|
1723
1850
|
if (reports.length === 0) {
|
|
1724
|
-
throw new
|
|
1725
|
-
`No ${reportType} reports found for ${year}-${
|
|
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.`
|
|
1726
1856
|
);
|
|
1727
1857
|
}
|
|
1728
1858
|
const bucket = reports[0];
|
|
1729
1859
|
if (!bucket) {
|
|
1730
|
-
throw new
|
|
1731
|
-
`No ${reportType} reports found for ${year}-${
|
|
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.`
|
|
1732
1865
|
);
|
|
1733
1866
|
}
|
|
1734
1867
|
const uri = bucket.uri;
|
|
1735
1868
|
const response = await fetch(uri);
|
|
1736
1869
|
if (!response.ok) {
|
|
1737
|
-
throw new
|
|
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
|
+
);
|
|
1738
1874
|
}
|
|
1739
1875
|
return response.text();
|
|
1740
1876
|
}
|
|
@@ -1785,8 +1921,11 @@ async function removeUser(client, developerId, userId) {
|
|
|
1785
1921
|
function parseGrantArg(grantStr) {
|
|
1786
1922
|
const colonIdx = grantStr.indexOf(":");
|
|
1787
1923
|
if (colonIdx === -1) {
|
|
1788
|
-
throw new
|
|
1789
|
-
`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"
|
|
1790
1929
|
);
|
|
1791
1930
|
}
|
|
1792
1931
|
const packageName = grantStr.slice(0, colonIdx);
|
|
@@ -1847,12 +1986,189 @@ async function importTestersFromCsv(client, packageName, track, csvPath) {
|
|
|
1847
1986
|
const content = await readFile5(csvPath, "utf-8");
|
|
1848
1987
|
const emails = content.split(/[,\n\r]+/).map((e) => e.trim()).filter((e) => e.length > 0 && e.includes("@"));
|
|
1849
1988
|
if (emails.length === 0) {
|
|
1850
|
-
throw new
|
|
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
|
+
);
|
|
1851
1995
|
}
|
|
1852
1996
|
const testers = await addTesters(client, packageName, track, emails);
|
|
1853
1997
|
return { added: emails.length, testers };
|
|
1854
1998
|
}
|
|
1855
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
|
+
|
|
2118
|
+
// src/commands/data-safety.ts
|
|
2119
|
+
import { readFile as readFile6, writeFile as writeFile2 } from "fs/promises";
|
|
2120
|
+
async function getDataSafety(client, packageName) {
|
|
2121
|
+
const edit = await client.edits.insert(packageName);
|
|
2122
|
+
try {
|
|
2123
|
+
const dataSafety = await client.dataSafety.get(packageName, edit.id);
|
|
2124
|
+
await client.edits.delete(packageName, edit.id);
|
|
2125
|
+
return dataSafety;
|
|
2126
|
+
} catch (error) {
|
|
2127
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
2128
|
+
});
|
|
2129
|
+
throw error;
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
async function updateDataSafety(client, packageName, data) {
|
|
2133
|
+
const edit = await client.edits.insert(packageName);
|
|
2134
|
+
try {
|
|
2135
|
+
const result = await client.dataSafety.update(packageName, edit.id, data);
|
|
2136
|
+
await client.edits.validate(packageName, edit.id);
|
|
2137
|
+
await client.edits.commit(packageName, edit.id);
|
|
2138
|
+
return result;
|
|
2139
|
+
} catch (error) {
|
|
2140
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
2141
|
+
});
|
|
2142
|
+
throw error;
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
async function exportDataSafety(client, packageName, outputPath) {
|
|
2146
|
+
const dataSafety = await getDataSafety(client, packageName);
|
|
2147
|
+
await writeFile2(outputPath, JSON.stringify(dataSafety, null, 2) + "\n", "utf-8");
|
|
2148
|
+
return dataSafety;
|
|
2149
|
+
}
|
|
2150
|
+
async function importDataSafety(client, packageName, filePath) {
|
|
2151
|
+
const content = await readFile6(filePath, "utf-8");
|
|
2152
|
+
let data;
|
|
2153
|
+
try {
|
|
2154
|
+
data = JSON.parse(content);
|
|
2155
|
+
} catch {
|
|
2156
|
+
throw new Error(`Failed to parse data safety JSON from "${filePath}"`);
|
|
2157
|
+
}
|
|
2158
|
+
return updateDataSafety(client, packageName, data);
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
// src/commands/external-transactions.ts
|
|
2162
|
+
async function createExternalTransaction(client, packageName, data) {
|
|
2163
|
+
return client.externalTransactions.create(packageName, data);
|
|
2164
|
+
}
|
|
2165
|
+
async function getExternalTransaction(client, packageName, transactionId) {
|
|
2166
|
+
return client.externalTransactions.get(packageName, transactionId);
|
|
2167
|
+
}
|
|
2168
|
+
async function refundExternalTransaction(client, packageName, transactionId, refundData) {
|
|
2169
|
+
return client.externalTransactions.refund(packageName, transactionId, refundData);
|
|
2170
|
+
}
|
|
2171
|
+
|
|
1856
2172
|
// src/utils/safe-path.ts
|
|
1857
2173
|
import { resolve, normalize } from "path";
|
|
1858
2174
|
function safePath(userPath) {
|
|
@@ -1862,13 +2178,58 @@ function safePathWithin(userPath, baseDir) {
|
|
|
1862
2178
|
const resolved = safePath(userPath);
|
|
1863
2179
|
const base = safePath(baseDir);
|
|
1864
2180
|
if (!resolved.startsWith(base + "/") && resolved !== base) {
|
|
1865
|
-
throw new
|
|
2181
|
+
throw new GpcError(
|
|
2182
|
+
`Path "${userPath}" resolves outside the expected directory "${baseDir}"`,
|
|
2183
|
+
"PATH_TRAVERSAL",
|
|
2184
|
+
2,
|
|
2185
|
+
"The path must stay within the target directory. Remove any ../ segments or use an absolute path within the expected directory."
|
|
2186
|
+
);
|
|
1866
2187
|
}
|
|
1867
2188
|
return resolved;
|
|
1868
2189
|
}
|
|
1869
2190
|
|
|
2191
|
+
// src/utils/sort.ts
|
|
2192
|
+
function getNestedValue(obj, path) {
|
|
2193
|
+
const parts = path.split(".");
|
|
2194
|
+
let current = obj;
|
|
2195
|
+
for (const part of parts) {
|
|
2196
|
+
if (current === null || current === void 0 || typeof current !== "object") {
|
|
2197
|
+
return void 0;
|
|
2198
|
+
}
|
|
2199
|
+
current = current[part];
|
|
2200
|
+
}
|
|
2201
|
+
return current;
|
|
2202
|
+
}
|
|
2203
|
+
function compare(a, b) {
|
|
2204
|
+
if (a === void 0 && b === void 0) return 0;
|
|
2205
|
+
if (a === void 0) return 1;
|
|
2206
|
+
if (b === void 0) return -1;
|
|
2207
|
+
if (typeof a === "number" && typeof b === "number") {
|
|
2208
|
+
return a - b;
|
|
2209
|
+
}
|
|
2210
|
+
return String(a).localeCompare(String(b));
|
|
2211
|
+
}
|
|
2212
|
+
function sortResults(items, sortSpec) {
|
|
2213
|
+
if (!sortSpec || items.length === 0) {
|
|
2214
|
+
return items;
|
|
2215
|
+
}
|
|
2216
|
+
const descending = sortSpec.startsWith("-");
|
|
2217
|
+
const field = descending ? sortSpec.slice(1) : sortSpec;
|
|
2218
|
+
const hasField = items.some((item) => getNestedValue(item, field) !== void 0);
|
|
2219
|
+
if (!hasField) {
|
|
2220
|
+
return items;
|
|
2221
|
+
}
|
|
2222
|
+
const sorted = [...items].sort((a, b) => {
|
|
2223
|
+
const aVal = getNestedValue(a, field);
|
|
2224
|
+
const bVal = getNestedValue(b, field);
|
|
2225
|
+
const result = compare(aVal, bVal);
|
|
2226
|
+
return descending ? -result : result;
|
|
2227
|
+
});
|
|
2228
|
+
return sorted;
|
|
2229
|
+
}
|
|
2230
|
+
|
|
1870
2231
|
// src/commands/plugin-scaffold.ts
|
|
1871
|
-
import { mkdir as mkdir2, writeFile as
|
|
2232
|
+
import { mkdir as mkdir2, writeFile as writeFile3 } from "fs/promises";
|
|
1872
2233
|
import { join as join4 } from "path";
|
|
1873
2234
|
async function scaffoldPlugin(options) {
|
|
1874
2235
|
const { name, dir, description = `GPC plugin: ${name}` } = options;
|
|
@@ -1911,7 +2272,7 @@ async function scaffoldPlugin(options) {
|
|
|
1911
2272
|
vitest: "^3.0.0"
|
|
1912
2273
|
}
|
|
1913
2274
|
};
|
|
1914
|
-
await
|
|
2275
|
+
await writeFile3(join4(dir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
|
|
1915
2276
|
files.push("package.json");
|
|
1916
2277
|
const tsconfig = {
|
|
1917
2278
|
compilerOptions: {
|
|
@@ -1927,7 +2288,7 @@ async function scaffoldPlugin(options) {
|
|
|
1927
2288
|
},
|
|
1928
2289
|
include: ["src"]
|
|
1929
2290
|
};
|
|
1930
|
-
await
|
|
2291
|
+
await writeFile3(join4(dir, "tsconfig.json"), JSON.stringify(tsconfig, null, 2) + "\n");
|
|
1931
2292
|
files.push("tsconfig.json");
|
|
1932
2293
|
const srcContent = `import { definePlugin } from "@gpc-cli/plugin-sdk";
|
|
1933
2294
|
import type { CommandEvent, CommandResult } from "@gpc-cli/plugin-sdk";
|
|
@@ -1960,7 +2321,7 @@ export const plugin = definePlugin({
|
|
|
1960
2321
|
},
|
|
1961
2322
|
});
|
|
1962
2323
|
`;
|
|
1963
|
-
await
|
|
2324
|
+
await writeFile3(join4(srcDir, "index.ts"), srcContent);
|
|
1964
2325
|
files.push("src/index.ts");
|
|
1965
2326
|
const testContent = `import { describe, it, expect, vi } from "vitest";
|
|
1966
2327
|
import { plugin } from "../src/index";
|
|
@@ -1985,7 +2346,7 @@ describe("${pluginName}", () => {
|
|
|
1985
2346
|
});
|
|
1986
2347
|
});
|
|
1987
2348
|
`;
|
|
1988
|
-
await
|
|
2349
|
+
await writeFile3(join4(testDir, "plugin.test.ts"), testContent);
|
|
1989
2350
|
files.push("tests/plugin.test.ts");
|
|
1990
2351
|
return { dir, files };
|
|
1991
2352
|
}
|
|
@@ -2038,6 +2399,111 @@ function createAuditEntry(command, args, app) {
|
|
|
2038
2399
|
args
|
|
2039
2400
|
};
|
|
2040
2401
|
}
|
|
2402
|
+
|
|
2403
|
+
// src/utils/webhooks.ts
|
|
2404
|
+
function formatSlackPayload(payload) {
|
|
2405
|
+
const status = payload.success ? "\u2713" : "\u2717";
|
|
2406
|
+
const color = payload.success ? "#2eb886" : "#e01e5a";
|
|
2407
|
+
const durationSec = (payload.duration / 1e3).toFixed(1);
|
|
2408
|
+
const fields = [
|
|
2409
|
+
{ title: "Command", value: payload.command, short: true },
|
|
2410
|
+
{ title: "Duration", value: `${durationSec}s`, short: true }
|
|
2411
|
+
];
|
|
2412
|
+
if (payload.app) {
|
|
2413
|
+
fields.push({ title: "App", value: payload.app, short: true });
|
|
2414
|
+
}
|
|
2415
|
+
if (payload.error) {
|
|
2416
|
+
fields.push({ title: "Error", value: payload.error, short: false });
|
|
2417
|
+
}
|
|
2418
|
+
if (payload.details) {
|
|
2419
|
+
for (const [key, value] of Object.entries(payload.details)) {
|
|
2420
|
+
fields.push({ title: key, value: String(value), short: true });
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
return {
|
|
2424
|
+
attachments: [
|
|
2425
|
+
{
|
|
2426
|
+
color,
|
|
2427
|
+
fallback: `GPC: ${payload.command} ${status}`,
|
|
2428
|
+
title: `GPC: ${payload.command} ${status}`,
|
|
2429
|
+
fields,
|
|
2430
|
+
footer: "GPC CLI",
|
|
2431
|
+
ts: Math.floor(Date.now() / 1e3)
|
|
2432
|
+
}
|
|
2433
|
+
]
|
|
2434
|
+
};
|
|
2435
|
+
}
|
|
2436
|
+
function formatDiscordPayload(payload) {
|
|
2437
|
+
const status = payload.success ? "\u2713" : "\u2717";
|
|
2438
|
+
const color = payload.success ? 3061894 : 14687834;
|
|
2439
|
+
const durationSec = (payload.duration / 1e3).toFixed(1);
|
|
2440
|
+
const fields = [
|
|
2441
|
+
{ name: "Command", value: payload.command, inline: true },
|
|
2442
|
+
{ name: "Duration", value: `${durationSec}s`, inline: true }
|
|
2443
|
+
];
|
|
2444
|
+
if (payload.app) {
|
|
2445
|
+
fields.push({ name: "App", value: payload.app, inline: true });
|
|
2446
|
+
}
|
|
2447
|
+
if (payload.error) {
|
|
2448
|
+
fields.push({ name: "Error", value: payload.error, inline: false });
|
|
2449
|
+
}
|
|
2450
|
+
if (payload.details) {
|
|
2451
|
+
for (const [key, value] of Object.entries(payload.details)) {
|
|
2452
|
+
fields.push({ name: key, value: String(value), inline: true });
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
return {
|
|
2456
|
+
embeds: [
|
|
2457
|
+
{
|
|
2458
|
+
title: `GPC: ${payload.command} ${status}`,
|
|
2459
|
+
color,
|
|
2460
|
+
fields,
|
|
2461
|
+
footer: { text: "GPC CLI" },
|
|
2462
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2463
|
+
}
|
|
2464
|
+
]
|
|
2465
|
+
};
|
|
2466
|
+
}
|
|
2467
|
+
function formatCustomPayload(payload) {
|
|
2468
|
+
return { ...payload };
|
|
2469
|
+
}
|
|
2470
|
+
var FORMATTERS = {
|
|
2471
|
+
slack: formatSlackPayload,
|
|
2472
|
+
discord: formatDiscordPayload,
|
|
2473
|
+
custom: formatCustomPayload
|
|
2474
|
+
};
|
|
2475
|
+
var WEBHOOK_TIMEOUT_MS = 5e3;
|
|
2476
|
+
async function sendSingle(url, body) {
|
|
2477
|
+
const controller = new AbortController();
|
|
2478
|
+
const timer = setTimeout(() => controller.abort(), WEBHOOK_TIMEOUT_MS);
|
|
2479
|
+
try {
|
|
2480
|
+
await fetch(url, {
|
|
2481
|
+
method: "POST",
|
|
2482
|
+
headers: { "Content-Type": "application/json" },
|
|
2483
|
+
body: JSON.stringify(body),
|
|
2484
|
+
signal: controller.signal
|
|
2485
|
+
});
|
|
2486
|
+
} finally {
|
|
2487
|
+
clearTimeout(timer);
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
async function sendWebhook(config, payload, target) {
|
|
2491
|
+
try {
|
|
2492
|
+
const targets = target ? [target] : Object.keys(FORMATTERS);
|
|
2493
|
+
const promises = [];
|
|
2494
|
+
for (const t of targets) {
|
|
2495
|
+
const url = config[t];
|
|
2496
|
+
if (!url) continue;
|
|
2497
|
+
const formatter = FORMATTERS[t];
|
|
2498
|
+
if (!formatter) continue;
|
|
2499
|
+
const body = formatter(payload);
|
|
2500
|
+
promises.push(sendSingle(url, body).catch(() => {
|
|
2501
|
+
}));
|
|
2502
|
+
}
|
|
2503
|
+
await Promise.all(promises);
|
|
2504
|
+
} catch {
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2041
2507
|
export {
|
|
2042
2508
|
ApiError,
|
|
2043
2509
|
ConfigError,
|
|
@@ -2050,12 +2516,14 @@ export {
|
|
|
2050
2516
|
activateBasePlan,
|
|
2051
2517
|
activateOffer,
|
|
2052
2518
|
addTesters,
|
|
2519
|
+
cancelRecoveryAction,
|
|
2053
2520
|
cancelSubscriptionPurchase,
|
|
2054
2521
|
checkThreshold,
|
|
2055
2522
|
compareVitalsTrend,
|
|
2056
2523
|
consumeProductPurchase,
|
|
2057
2524
|
convertRegionPrices,
|
|
2058
2525
|
createAuditEntry,
|
|
2526
|
+
createExternalTransaction,
|
|
2059
2527
|
createInAppProduct,
|
|
2060
2528
|
createOffer,
|
|
2061
2529
|
createSubscription,
|
|
@@ -2068,14 +2536,23 @@ export {
|
|
|
2068
2536
|
deleteListing,
|
|
2069
2537
|
deleteOffer,
|
|
2070
2538
|
deleteSubscription,
|
|
2539
|
+
deployRecoveryAction,
|
|
2071
2540
|
detectOutputFormat,
|
|
2072
2541
|
diffListings,
|
|
2542
|
+
diffListingsCommand,
|
|
2073
2543
|
discoverPlugins,
|
|
2074
2544
|
downloadReport,
|
|
2545
|
+
exportDataSafety,
|
|
2075
2546
|
exportReviews,
|
|
2547
|
+
formatCustomPayload,
|
|
2548
|
+
formatDiscordPayload,
|
|
2076
2549
|
formatOutput,
|
|
2550
|
+
formatSlackPayload,
|
|
2551
|
+
generateNotesFromGit,
|
|
2077
2552
|
getAppInfo,
|
|
2078
2553
|
getCountryAvailability,
|
|
2554
|
+
getDataSafety,
|
|
2555
|
+
getExternalTransaction,
|
|
2079
2556
|
getInAppProduct,
|
|
2080
2557
|
getListings,
|
|
2081
2558
|
getOffer,
|
|
@@ -2093,6 +2570,7 @@ export {
|
|
|
2093
2570
|
getVitalsOverview,
|
|
2094
2571
|
getVitalsRendering,
|
|
2095
2572
|
getVitalsStartup,
|
|
2573
|
+
importDataSafety,
|
|
2096
2574
|
importTestersFromCsv,
|
|
2097
2575
|
initAudit,
|
|
2098
2576
|
inviteUser,
|
|
@@ -2104,6 +2582,7 @@ export {
|
|
|
2104
2582
|
listImages,
|
|
2105
2583
|
listInAppProducts,
|
|
2106
2584
|
listOffers,
|
|
2585
|
+
listRecoveryActions,
|
|
2107
2586
|
listReports,
|
|
2108
2587
|
listReviews,
|
|
2109
2588
|
listSubscriptions,
|
|
@@ -2121,6 +2600,7 @@ export {
|
|
|
2121
2600
|
readListingsFromDir,
|
|
2122
2601
|
readReleaseNotesFromDir,
|
|
2123
2602
|
redactSensitive,
|
|
2603
|
+
refundExternalTransaction,
|
|
2124
2604
|
refundOrder,
|
|
2125
2605
|
removeTesters,
|
|
2126
2606
|
removeUser,
|
|
@@ -2130,8 +2610,11 @@ export {
|
|
|
2130
2610
|
safePathWithin,
|
|
2131
2611
|
scaffoldPlugin,
|
|
2132
2612
|
searchVitalsErrors,
|
|
2613
|
+
sendWebhook,
|
|
2614
|
+
sortResults,
|
|
2133
2615
|
syncInAppProducts,
|
|
2134
2616
|
updateAppDetails,
|
|
2617
|
+
updateDataSafety,
|
|
2135
2618
|
updateInAppProduct,
|
|
2136
2619
|
updateListing,
|
|
2137
2620
|
updateOffer,
|