@gpc-cli/core 0.9.8 → 0.9.10

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
@@ -54,6 +54,8 @@ function formatOutput(data, format, redact = true) {
54
54
  return formatMarkdown(safe);
55
55
  case "table":
56
56
  return formatTable(safe);
57
+ case "junit":
58
+ return formatJunit(safe);
57
59
  default:
58
60
  return formatJson(safe);
59
61
  }
@@ -182,6 +184,73 @@ function toRows(data) {
182
184
  }
183
185
  return [];
184
186
  }
187
+ function escapeXml(str) {
188
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
189
+ }
190
+ function toTestCases(data, commandName) {
191
+ const cases = [];
192
+ let failures = 0;
193
+ if (Array.isArray(data)) {
194
+ for (const item of data) {
195
+ const tc = buildTestCase(item, commandName);
196
+ cases.push(tc.xml);
197
+ if (tc.failed) failures++;
198
+ }
199
+ } else if (typeof data === "object" && data !== null) {
200
+ const tc = buildTestCase(data, commandName);
201
+ cases.push(tc.xml);
202
+ if (tc.failed) failures++;
203
+ } else if (typeof data === "string") {
204
+ cases.push(
205
+ ` <testcase name="${escapeXml(data)}" classname="gpc.${escapeXml(commandName)}" />`
206
+ );
207
+ }
208
+ return { cases, failures };
209
+ }
210
+ function buildTestCase(item, commandName) {
211
+ if (typeof item !== "object" || item === null) {
212
+ const text = String(item);
213
+ return {
214
+ xml: ` <testcase name="${escapeXml(text)}" classname="gpc.${escapeXml(commandName)}" />`,
215
+ failed: false
216
+ };
217
+ }
218
+ const record = item;
219
+ const name = escapeXml(
220
+ String(record["name"] ?? record["title"] ?? record["sku"] ?? record["id"] ?? JSON.stringify(item))
221
+ );
222
+ const classname = `gpc.${escapeXml(commandName)}`;
223
+ const breached = record["breached"];
224
+ if (breached === true) {
225
+ const message = escapeXml(String(record["message"] ?? "threshold breached"));
226
+ const details = escapeXml(
227
+ String(record["details"] ?? record["metric"] ?? JSON.stringify(item))
228
+ );
229
+ return {
230
+ xml: ` <testcase name="${name}" classname="${classname}">
231
+ <failure message="${message}">${details}</failure>
232
+ </testcase>`,
233
+ failed: true
234
+ };
235
+ }
236
+ return {
237
+ xml: ` <testcase name="${name}" classname="${classname}" />`,
238
+ failed: false
239
+ };
240
+ }
241
+ function formatJunit(data, commandName = "command") {
242
+ const { cases, failures } = toTestCases(data, commandName);
243
+ const tests = cases.length;
244
+ const lines = [
245
+ '<?xml version="1.0" encoding="UTF-8"?>',
246
+ `<testsuites name="gpc" tests="${tests}" failures="${failures}" time="0">`,
247
+ ` <testsuite name="${escapeXml(commandName)}" tests="${tests}" failures="${failures}">`,
248
+ ...cases,
249
+ " </testsuite>",
250
+ "</testsuites>"
251
+ ];
252
+ return lines.join("\n");
253
+ }
185
254
 
186
255
  // src/plugins.ts
187
256
  var PluginManager = class {
@@ -682,6 +751,90 @@ async function listTracks(client, packageName) {
682
751
  throw error;
683
752
  }
684
753
  }
754
+ async function createTrack(client, packageName, trackName) {
755
+ if (!trackName || trackName.trim().length === 0) {
756
+ throw new GpcError(
757
+ "Track name must not be empty",
758
+ "TRACK_INVALID_NAME",
759
+ 2,
760
+ "Provide a valid custom track name, e.g.: gpc tracks create my-qa-track"
761
+ );
762
+ }
763
+ const edit = await client.edits.insert(packageName);
764
+ try {
765
+ const track = await client.tracks.create(packageName, edit.id, trackName);
766
+ await client.edits.validate(packageName, edit.id);
767
+ await client.edits.commit(packageName, edit.id);
768
+ return track;
769
+ } catch (error) {
770
+ await client.edits.delete(packageName, edit.id).catch(() => {
771
+ });
772
+ throw error;
773
+ }
774
+ }
775
+ async function updateTrackConfig(client, packageName, trackName, config) {
776
+ if (!trackName || trackName.trim().length === 0) {
777
+ throw new GpcError(
778
+ "Track name must not be empty",
779
+ "TRACK_INVALID_NAME",
780
+ 2,
781
+ "Provide a valid track name."
782
+ );
783
+ }
784
+ const edit = await client.edits.insert(packageName);
785
+ try {
786
+ const release = {
787
+ versionCodes: config["versionCodes"] || [],
788
+ status: config["status"] || "completed"
789
+ };
790
+ if (config["userFraction"] !== void 0) {
791
+ release.userFraction = config["userFraction"];
792
+ }
793
+ if (config["releaseNotes"]) {
794
+ release.releaseNotes = config["releaseNotes"];
795
+ }
796
+ if (config["name"]) {
797
+ release.name = config["name"];
798
+ }
799
+ const track = await client.tracks.update(packageName, edit.id, trackName, release);
800
+ await client.edits.validate(packageName, edit.id);
801
+ await client.edits.commit(packageName, edit.id);
802
+ return track;
803
+ } catch (error) {
804
+ await client.edits.delete(packageName, edit.id).catch(() => {
805
+ });
806
+ throw error;
807
+ }
808
+ }
809
+ async function uploadExternallyHosted(client, packageName, data) {
810
+ if (!data.externallyHostedUrl) {
811
+ throw new GpcError(
812
+ "externallyHostedUrl is required",
813
+ "EXTERNAL_APK_MISSING_URL",
814
+ 2,
815
+ "Provide a valid URL for the externally hosted APK."
816
+ );
817
+ }
818
+ if (!data.packageName) {
819
+ throw new GpcError(
820
+ "packageName is required in externally hosted APK data",
821
+ "EXTERNAL_APK_MISSING_PACKAGE",
822
+ 2,
823
+ "Include the packageName field in the APK configuration."
824
+ );
825
+ }
826
+ const edit = await client.edits.insert(packageName);
827
+ try {
828
+ const result = await client.apks.addExternallyHosted(packageName, edit.id, data);
829
+ await client.edits.validate(packageName, edit.id);
830
+ await client.edits.commit(packageName, edit.id);
831
+ return result;
832
+ } catch (error) {
833
+ await client.edits.delete(packageName, edit.id).catch(() => {
834
+ });
835
+ throw error;
836
+ }
837
+ }
685
838
 
686
839
  // src/utils/bcp47.ts
687
840
  var GOOGLE_PLAY_LANGUAGES = [
@@ -1111,6 +1264,75 @@ async function getCountryAvailability(client, packageName, track) {
1111
1264
  throw error;
1112
1265
  }
1113
1266
  }
1267
+ var ALL_IMAGE_TYPES = [
1268
+ "phoneScreenshots",
1269
+ "sevenInchScreenshots",
1270
+ "tenInchScreenshots",
1271
+ "tvScreenshots",
1272
+ "wearScreenshots",
1273
+ "icon",
1274
+ "featureGraphic",
1275
+ "tvBanner"
1276
+ ];
1277
+ async function exportImages(client, packageName, dir, options) {
1278
+ const { mkdir: mkdir5, writeFile: writeFile6 } = await import("fs/promises");
1279
+ const { join: join7 } = await import("path");
1280
+ const edit = await client.edits.insert(packageName);
1281
+ try {
1282
+ let languages;
1283
+ if (options?.lang) {
1284
+ validateLanguage(options.lang);
1285
+ languages = [options.lang];
1286
+ } else {
1287
+ const listings = await client.listings.list(packageName, edit.id);
1288
+ languages = listings.map((l) => l.language);
1289
+ }
1290
+ const imageTypes = options?.type ? [options.type] : ALL_IMAGE_TYPES;
1291
+ let totalImages = 0;
1292
+ let totalSize = 0;
1293
+ const tasks = [];
1294
+ for (const language of languages) {
1295
+ for (const imageType of imageTypes) {
1296
+ const images = await client.images.list(packageName, edit.id, language, imageType);
1297
+ for (let i = 0; i < images.length; i++) {
1298
+ const img = images[i];
1299
+ if (img && img.url) {
1300
+ tasks.push({ language, imageType, url: img.url, index: i + 1 });
1301
+ }
1302
+ }
1303
+ }
1304
+ }
1305
+ const concurrency = 5;
1306
+ for (let i = 0; i < tasks.length; i += concurrency) {
1307
+ const batch = tasks.slice(i, i + concurrency);
1308
+ const results = await Promise.all(
1309
+ batch.map(async (task) => {
1310
+ const dirPath = join7(dir, task.language, task.imageType);
1311
+ await mkdir5(dirPath, { recursive: true });
1312
+ const response = await fetch(task.url);
1313
+ const buffer = Buffer.from(await response.arrayBuffer());
1314
+ const filePath = join7(dirPath, `${task.index}.png`);
1315
+ await writeFile6(filePath, buffer);
1316
+ return buffer.length;
1317
+ })
1318
+ );
1319
+ for (const size of results) {
1320
+ totalImages++;
1321
+ totalSize += size;
1322
+ }
1323
+ }
1324
+ await client.edits.delete(packageName, edit.id);
1325
+ return {
1326
+ languages: languages.length,
1327
+ images: totalImages,
1328
+ totalSize
1329
+ };
1330
+ } catch (error) {
1331
+ await client.edits.delete(packageName, edit.id).catch(() => {
1332
+ });
1333
+ throw error;
1334
+ }
1335
+ }
1114
1336
  async function updateAppDetails(client, packageName, details) {
1115
1337
  const edit = await client.edits.insert(packageName);
1116
1338
  try {
@@ -1125,14 +1347,202 @@ async function updateAppDetails(client, packageName, details) {
1125
1347
  }
1126
1348
  }
1127
1349
 
1350
+ // src/commands/migrate.ts
1351
+ import { readdir as readdir2, readFile as readFile3, writeFile as writeFile2, mkdir as mkdir2, access } from "fs/promises";
1352
+ import { join as join2 } from "path";
1353
+ async function fileExists(path) {
1354
+ try {
1355
+ await access(path);
1356
+ return true;
1357
+ } catch {
1358
+ return false;
1359
+ }
1360
+ }
1361
+ async function detectFastlane(cwd) {
1362
+ const result = {
1363
+ hasFastfile: false,
1364
+ hasAppfile: false,
1365
+ hasMetadata: false,
1366
+ hasGemfile: false,
1367
+ lanes: [],
1368
+ metadataLanguages: []
1369
+ };
1370
+ const fastlaneDir = join2(cwd, "fastlane");
1371
+ const hasFastlaneDir = await fileExists(fastlaneDir);
1372
+ const fastfilePath = hasFastlaneDir ? join2(fastlaneDir, "Fastfile") : join2(cwd, "Fastfile");
1373
+ const appfilePath = hasFastlaneDir ? join2(fastlaneDir, "Appfile") : join2(cwd, "Appfile");
1374
+ result.hasFastfile = await fileExists(fastfilePath);
1375
+ result.hasAppfile = await fileExists(appfilePath);
1376
+ result.hasGemfile = await fileExists(join2(cwd, "Gemfile"));
1377
+ const metadataDir = hasFastlaneDir ? join2(fastlaneDir, "metadata", "android") : join2(cwd, "metadata", "android");
1378
+ result.hasMetadata = await fileExists(metadataDir);
1379
+ if (result.hasMetadata) {
1380
+ try {
1381
+ const entries = await readdir2(metadataDir, { withFileTypes: true });
1382
+ result.metadataLanguages = entries.filter((e) => e.isDirectory()).map((e) => e.name);
1383
+ } catch {
1384
+ }
1385
+ }
1386
+ if (result.hasFastfile) {
1387
+ try {
1388
+ const content = await readFile3(fastfilePath, "utf-8");
1389
+ result.lanes = parseFastfile(content);
1390
+ } catch {
1391
+ }
1392
+ }
1393
+ if (result.hasAppfile) {
1394
+ try {
1395
+ const content = await readFile3(appfilePath, "utf-8");
1396
+ const parsed = parseAppfile(content);
1397
+ result.packageName = parsed.packageName;
1398
+ result.jsonKeyPath = parsed.jsonKeyPath;
1399
+ } catch {
1400
+ }
1401
+ }
1402
+ return result;
1403
+ }
1404
+ function parseFastfile(content) {
1405
+ const lanes = [];
1406
+ const laneRegex = /lane\s+:(\w+)\s+do([\s\S]*?)(?=\bend\b)/g;
1407
+ let match;
1408
+ while ((match = laneRegex.exec(content)) !== null) {
1409
+ const name = match[1] ?? "";
1410
+ const body = match[2] ?? "";
1411
+ const actions = [];
1412
+ const actionRegex = /\b(supply|upload_to_play_store|capture_android_screenshots|deliver|gradle)\b/g;
1413
+ let actionMatch;
1414
+ while ((actionMatch = actionRegex.exec(body)) !== null) {
1415
+ const action = actionMatch[1] ?? "";
1416
+ if (!actions.includes(action)) {
1417
+ actions.push(action);
1418
+ }
1419
+ }
1420
+ const gpcEquivalent = mapLaneToGpc(name, actions, body);
1421
+ lanes.push({ name, actions, gpcEquivalent });
1422
+ }
1423
+ return lanes;
1424
+ }
1425
+ function mapLaneToGpc(name, actions, body) {
1426
+ if (actions.includes("upload_to_play_store") || actions.includes("supply")) {
1427
+ const trackMatch = body.match(/track\s*:\s*["'](\w+)["']/);
1428
+ const rolloutMatch = body.match(/rollout\s*:\s*["']?([\d.]+)["']?/);
1429
+ if (rolloutMatch) {
1430
+ const percentage = Math.round(parseFloat(rolloutMatch[1] ?? "0") * 100);
1431
+ return `gpc releases promote --rollout ${percentage}`;
1432
+ }
1433
+ if (trackMatch) {
1434
+ return `gpc releases upload --track ${trackMatch[1]}`;
1435
+ }
1436
+ if (body.match(/skip_upload_apk\s*:\s*true/) || body.match(/skip_upload_aab\s*:\s*true/)) {
1437
+ return "gpc listings push";
1438
+ }
1439
+ return "gpc releases upload";
1440
+ }
1441
+ if (actions.includes("capture_android_screenshots")) {
1442
+ return void 0;
1443
+ }
1444
+ return void 0;
1445
+ }
1446
+ function parseAppfile(content) {
1447
+ const result = {};
1448
+ const pkgMatch = content.match(/package_name\s*\(?\s*["']([^"']+)["']\s*\)?/);
1449
+ if (pkgMatch) {
1450
+ result.packageName = pkgMatch[1];
1451
+ }
1452
+ const keyMatch = content.match(/json_key_file\s*\(?\s*["']([^"']+)["']\s*\)?/);
1453
+ if (keyMatch) {
1454
+ result.jsonKeyPath = keyMatch[1];
1455
+ }
1456
+ return result;
1457
+ }
1458
+ function generateMigrationPlan(detection) {
1459
+ const config = {};
1460
+ const checklist = [];
1461
+ const warnings = [];
1462
+ if (detection.packageName) {
1463
+ config["app"] = detection.packageName;
1464
+ } else {
1465
+ checklist.push("Set your package name: gpc config set app <package-name>");
1466
+ }
1467
+ if (detection.jsonKeyPath) {
1468
+ config["auth"] = { serviceAccount: detection.jsonKeyPath };
1469
+ } else {
1470
+ checklist.push("Configure authentication: gpc auth setup");
1471
+ }
1472
+ for (const lane of detection.lanes) {
1473
+ if (lane.gpcEquivalent) {
1474
+ checklist.push(`Replace Fastlane lane "${lane.name}" with: ${lane.gpcEquivalent}`);
1475
+ }
1476
+ if (lane.actions.includes("capture_android_screenshots")) {
1477
+ warnings.push(
1478
+ `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.`
1479
+ );
1480
+ }
1481
+ }
1482
+ if (detection.hasMetadata && detection.metadataLanguages.length > 0) {
1483
+ checklist.push(
1484
+ `Migrate metadata for ${detection.metadataLanguages.length} language(s): gpc listings pull --dir metadata`
1485
+ );
1486
+ checklist.push("Review and push metadata: gpc listings push --dir metadata");
1487
+ }
1488
+ checklist.push("Run gpc doctor to verify your setup");
1489
+ checklist.push("Test with --dry-run before making real changes");
1490
+ if (detection.hasGemfile) {
1491
+ checklist.push("Remove Fastlane from your Gemfile once migration is complete");
1492
+ }
1493
+ if (detection.lanes.some((l) => l.actions.includes("supply") || l.actions.includes("upload_to_play_store"))) {
1494
+ checklist.push("Update CI/CD pipelines to use gpc commands instead of Fastlane lanes");
1495
+ }
1496
+ return { config, checklist, warnings };
1497
+ }
1498
+ async function writeMigrationOutput(result, dir) {
1499
+ await mkdir2(dir, { recursive: true });
1500
+ const files = [];
1501
+ const configPath = join2(dir, ".gpcrc.json");
1502
+ await writeFile2(configPath, JSON.stringify(result.config, null, 2) + "\n", "utf-8");
1503
+ files.push(configPath);
1504
+ const migrationPath = join2(dir, "MIGRATION.md");
1505
+ const lines = [
1506
+ "# Fastlane to GPC Migration",
1507
+ "",
1508
+ "## Migration Checklist",
1509
+ ""
1510
+ ];
1511
+ for (const item of result.checklist) {
1512
+ lines.push(`- [ ] ${item}`);
1513
+ }
1514
+ if (result.warnings.length > 0) {
1515
+ lines.push("");
1516
+ lines.push("## Warnings");
1517
+ lines.push("");
1518
+ for (const warning of result.warnings) {
1519
+ lines.push(`- ${warning}`);
1520
+ }
1521
+ }
1522
+ lines.push("");
1523
+ lines.push("## Quick Reference");
1524
+ lines.push("");
1525
+ lines.push("| Fastlane | GPC |");
1526
+ lines.push("|----------|-----|");
1527
+ lines.push("| `fastlane supply` | `gpc releases upload` / `gpc listings push` |");
1528
+ lines.push("| `upload_to_play_store` | `gpc releases upload` |");
1529
+ lines.push('| `supply(track: "internal")` | `gpc releases upload --track internal` |');
1530
+ lines.push('| `supply(rollout: "0.1")` | `gpc releases promote --rollout 10` |');
1531
+ lines.push("| `capture_android_screenshots` | No equivalent (use separate tool) |");
1532
+ lines.push("");
1533
+ await writeFile2(migrationPath, lines.join("\n"), "utf-8");
1534
+ files.push(migrationPath);
1535
+ return files;
1536
+ }
1537
+
1128
1538
  // src/utils/release-notes.ts
1129
- import { readdir as readdir2, readFile as readFile3, stat as stat4 } from "fs/promises";
1130
- import { extname as extname3, basename, join as join2 } from "path";
1539
+ import { readdir as readdir3, readFile as readFile4, stat as stat4 } from "fs/promises";
1540
+ import { extname as extname3, basename, join as join3 } from "path";
1131
1541
  var MAX_NOTES_LENGTH = 500;
1132
1542
  async function readReleaseNotesFromDir(dir) {
1133
1543
  let entries;
1134
1544
  try {
1135
- entries = await readdir2(dir);
1545
+ entries = await readdir3(dir);
1136
1546
  } catch {
1137
1547
  throw new GpcError(
1138
1548
  `Release notes directory not found: ${dir}`,
@@ -1145,10 +1555,10 @@ async function readReleaseNotesFromDir(dir) {
1145
1555
  for (const entry of entries) {
1146
1556
  if (extname3(entry) !== ".txt") continue;
1147
1557
  const language = basename(entry, ".txt");
1148
- const filePath = join2(dir, entry);
1558
+ const filePath = join3(dir, entry);
1149
1559
  const stats = await stat4(filePath);
1150
1560
  if (!stats.isFile()) continue;
1151
- const text = (await readFile3(filePath, "utf-8")).trim();
1561
+ const text = (await readFile4(filePath, "utf-8")).trim();
1152
1562
  if (text.length === 0) continue;
1153
1563
  notes.push({ language, text });
1154
1564
  }
@@ -1633,8 +2043,8 @@ async function deactivateOffer(client, packageName, productId, basePlanId, offer
1633
2043
  }
1634
2044
 
1635
2045
  // src/commands/iap.ts
1636
- import { readdir as readdir3, readFile as readFile4 } from "fs/promises";
1637
- import { join as join3 } from "path";
2046
+ import { readdir as readdir4, readFile as readFile5 } from "fs/promises";
2047
+ import { join as join4 } from "path";
1638
2048
  import { paginateAll as paginateAll3 } from "@gpc-cli/api";
1639
2049
  async function listInAppProducts(client, packageName, options) {
1640
2050
  if (options?.limit || options?.nextPage) {
@@ -1671,14 +2081,59 @@ async function deleteInAppProduct(client, packageName, sku) {
1671
2081
  return client.inappproducts.delete(packageName, sku);
1672
2082
  }
1673
2083
  async function syncInAppProducts(client, packageName, dir, options) {
1674
- const files = await readdir3(dir);
1675
- const jsonFiles = files.filter((f) => f.endsWith(".json"));
1676
- if (jsonFiles.length === 0) {
2084
+ const localProducts = await readProductsFromDir(dir);
2085
+ if (localProducts.length === 0) {
1677
2086
  return { created: 0, updated: 0, unchanged: 0, skus: [] };
1678
2087
  }
2088
+ const response = await client.inappproducts.list(packageName);
2089
+ const remoteSkus = new Set((response.inappproduct || []).map((p) => p.sku));
2090
+ const toUpdate = localProducts.filter((p) => remoteSkus.has(p.sku));
2091
+ const toCreate = localProducts.filter((p) => !remoteSkus.has(p.sku));
2092
+ const skus = localProducts.map((p) => p.sku);
2093
+ if (options?.dryRun) {
2094
+ return { created: toCreate.length, updated: toUpdate.length, unchanged: 0, skus };
2095
+ }
2096
+ if (toUpdate.length > 1) {
2097
+ try {
2098
+ await batchUpdateProducts(client, packageName, toUpdate);
2099
+ } catch {
2100
+ for (const product of toUpdate) {
2101
+ await client.inappproducts.update(packageName, product.sku, product);
2102
+ }
2103
+ }
2104
+ } else {
2105
+ for (const product of toUpdate) {
2106
+ await client.inappproducts.update(packageName, product.sku, product);
2107
+ }
2108
+ }
2109
+ for (const product of toCreate) {
2110
+ await client.inappproducts.create(packageName, product);
2111
+ }
2112
+ return { created: toCreate.length, updated: toUpdate.length, unchanged: 0, skus };
2113
+ }
2114
+ var BATCH_CHUNK_SIZE = 100;
2115
+ async function batchUpdateProducts(client, packageName, products) {
2116
+ const results = [];
2117
+ for (let i = 0; i < products.length; i += BATCH_CHUNK_SIZE) {
2118
+ const chunk = products.slice(i, i + BATCH_CHUNK_SIZE);
2119
+ const request = {
2120
+ requests: chunk.map((p) => ({
2121
+ inappproduct: p,
2122
+ packageName,
2123
+ sku: p.sku
2124
+ }))
2125
+ };
2126
+ const response = await client.inappproducts.batchUpdate(packageName, request);
2127
+ results.push(...response.inappproducts || []);
2128
+ }
2129
+ return results;
2130
+ }
2131
+ async function readProductsFromDir(dir) {
2132
+ const files = await readdir4(dir);
2133
+ const jsonFiles = files.filter((f) => f.endsWith(".json"));
1679
2134
  const localProducts = [];
1680
2135
  for (const file of jsonFiles) {
1681
- const content = await readFile4(join3(dir, file), "utf-8");
2136
+ const content = await readFile5(join4(dir, file), "utf-8");
1682
2137
  try {
1683
2138
  localProducts.push(JSON.parse(content));
1684
2139
  } catch {
@@ -1690,27 +2145,56 @@ async function syncInAppProducts(client, packageName, dir, options) {
1690
2145
  );
1691
2146
  }
1692
2147
  }
2148
+ return localProducts;
2149
+ }
2150
+ async function batchSyncInAppProducts(client, packageName, dir, options) {
2151
+ const localProducts = await readProductsFromDir(dir);
2152
+ if (localProducts.length === 0) {
2153
+ return { created: 0, updated: 0, unchanged: 0, skus: [], batchUsed: false, batchErrors: 0 };
2154
+ }
1693
2155
  const response = await client.inappproducts.list(packageName);
1694
2156
  const remoteSkus = new Set((response.inappproduct || []).map((p) => p.sku));
1695
- let created = 0;
1696
- let updated = 0;
1697
- const unchanged = 0;
1698
- const skus = [];
1699
- for (const product of localProducts) {
1700
- skus.push(product.sku);
1701
- if (remoteSkus.has(product.sku)) {
1702
- if (!options?.dryRun) {
2157
+ const toUpdate = localProducts.filter((p) => remoteSkus.has(p.sku));
2158
+ const toCreate = localProducts.filter((p) => !remoteSkus.has(p.sku));
2159
+ const skus = localProducts.map((p) => p.sku);
2160
+ if (options?.dryRun) {
2161
+ return {
2162
+ created: toCreate.length,
2163
+ updated: toUpdate.length,
2164
+ unchanged: 0,
2165
+ skus,
2166
+ batchUsed: toUpdate.length > 1,
2167
+ batchErrors: 0
2168
+ };
2169
+ }
2170
+ let batchUsed = false;
2171
+ let batchErrors = 0;
2172
+ if (toUpdate.length > 1) {
2173
+ batchUsed = true;
2174
+ try {
2175
+ await batchUpdateProducts(client, packageName, toUpdate);
2176
+ } catch {
2177
+ batchErrors++;
2178
+ for (const product of toUpdate) {
1703
2179
  await client.inappproducts.update(packageName, product.sku, product);
1704
2180
  }
1705
- updated++;
1706
- } else {
1707
- if (!options?.dryRun) {
1708
- await client.inappproducts.create(packageName, product);
1709
- }
1710
- created++;
2181
+ }
2182
+ } else {
2183
+ for (const product of toUpdate) {
2184
+ await client.inappproducts.update(packageName, product.sku, product);
1711
2185
  }
1712
2186
  }
1713
- return { created, updated, unchanged, skus };
2187
+ for (const product of toCreate) {
2188
+ await client.inappproducts.create(packageName, product);
2189
+ }
2190
+ return {
2191
+ created: toCreate.length,
2192
+ updated: toUpdate.length,
2193
+ unchanged: 0,
2194
+ skus,
2195
+ batchUsed,
2196
+ batchErrors
2197
+ };
1714
2198
  }
1715
2199
 
1716
2200
  // src/commands/purchases.ts
@@ -1934,7 +2418,7 @@ function parseGrantArg(grantStr) {
1934
2418
  }
1935
2419
 
1936
2420
  // src/commands/testers.ts
1937
- import { readFile as readFile5 } from "fs/promises";
2421
+ import { readFile as readFile6 } from "fs/promises";
1938
2422
  async function listTesters(client, packageName, track) {
1939
2423
  const edit = await client.edits.insert(packageName);
1940
2424
  try {
@@ -1983,7 +2467,7 @@ async function removeTesters(client, packageName, track, groupEmails) {
1983
2467
  }
1984
2468
  }
1985
2469
  async function importTestersFromCsv(client, packageName, track, csvPath) {
1986
- const content = await readFile5(csvPath, "utf-8");
2470
+ const content = await readFile6(csvPath, "utf-8");
1987
2471
  const emails = content.split(/[,\n\r]+/).map((e) => e.trim()).filter((e) => e.length > 0 && e.includes("@"));
1988
2472
  if (emails.length === 0) {
1989
2473
  throw new GpcError(
@@ -2122,7 +2606,7 @@ async function addRecoveryTargeting(client, packageName, actionId, targeting) {
2122
2606
  }
2123
2607
 
2124
2608
  // src/commands/data-safety.ts
2125
- import { readFile as readFile6, writeFile as writeFile2 } from "fs/promises";
2609
+ import { readFile as readFile7, writeFile as writeFile3 } from "fs/promises";
2126
2610
  async function getDataSafety(client, packageName) {
2127
2611
  const edit = await client.edits.insert(packageName);
2128
2612
  try {
@@ -2150,16 +2634,21 @@ async function updateDataSafety(client, packageName, data) {
2150
2634
  }
2151
2635
  async function exportDataSafety(client, packageName, outputPath) {
2152
2636
  const dataSafety = await getDataSafety(client, packageName);
2153
- await writeFile2(outputPath, JSON.stringify(dataSafety, null, 2) + "\n", "utf-8");
2637
+ await writeFile3(outputPath, JSON.stringify(dataSafety, null, 2) + "\n", "utf-8");
2154
2638
  return dataSafety;
2155
2639
  }
2156
2640
  async function importDataSafety(client, packageName, filePath) {
2157
- const content = await readFile6(filePath, "utf-8");
2641
+ const content = await readFile7(filePath, "utf-8");
2158
2642
  let data;
2159
2643
  try {
2160
2644
  data = JSON.parse(content);
2161
2645
  } catch {
2162
- throw new Error(`Failed to parse data safety JSON from "${filePath}"`);
2646
+ throw new GpcError(
2647
+ `Failed to parse data safety JSON from "${filePath}"`,
2648
+ "INVALID_JSON",
2649
+ 1,
2650
+ "Ensure the file contains valid JSON matching the data safety schema."
2651
+ );
2163
2652
  }
2164
2653
  return updateDataSafety(client, packageName, data);
2165
2654
  }
@@ -2348,6 +2837,78 @@ async function deleteOneTimeOffer(client, packageName, productId, offerId) {
2348
2837
  }
2349
2838
  }
2350
2839
 
2840
+ // src/utils/spinner.ts
2841
+ import process2 from "process";
2842
+ var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
2843
+ var INTERVAL_MS = 80;
2844
+ function createSpinner(message) {
2845
+ const isTTY = process2.stderr.isTTY === true;
2846
+ let frameIndex = 0;
2847
+ let timer;
2848
+ let currentMessage = message;
2849
+ let started = false;
2850
+ function clearLine() {
2851
+ if (isTTY) {
2852
+ process2.stderr.write("\r\x1B[K");
2853
+ }
2854
+ }
2855
+ function renderFrame() {
2856
+ const frame = FRAMES[frameIndex % FRAMES.length];
2857
+ process2.stderr.write(`\r\x1B[K${frame} ${currentMessage}`);
2858
+ frameIndex++;
2859
+ }
2860
+ return {
2861
+ start() {
2862
+ if (started) return;
2863
+ started = true;
2864
+ if (!isTTY) {
2865
+ process2.stderr.write(`${currentMessage}
2866
+ `);
2867
+ return;
2868
+ }
2869
+ renderFrame();
2870
+ timer = setInterval(renderFrame, INTERVAL_MS);
2871
+ },
2872
+ stop(msg) {
2873
+ if (timer) {
2874
+ clearInterval(timer);
2875
+ timer = void 0;
2876
+ }
2877
+ const text = msg ?? currentMessage;
2878
+ if (isTTY) {
2879
+ clearLine();
2880
+ process2.stderr.write(`\u2714 ${text}
2881
+ `);
2882
+ } else if (!started) {
2883
+ process2.stderr.write(`${text}
2884
+ `);
2885
+ }
2886
+ started = false;
2887
+ },
2888
+ fail(msg) {
2889
+ if (timer) {
2890
+ clearInterval(timer);
2891
+ timer = void 0;
2892
+ }
2893
+ const text = msg ?? currentMessage;
2894
+ if (isTTY) {
2895
+ clearLine();
2896
+ process2.stderr.write(`\u2718 ${text}
2897
+ `);
2898
+ } else if (!started) {
2899
+ process2.stderr.write(`${text}
2900
+ `);
2901
+ }
2902
+ started = false;
2903
+ },
2904
+ update(msg) {
2905
+ currentMessage = msg;
2906
+ if (!isTTY || !started) return;
2907
+ renderFrame();
2908
+ }
2909
+ };
2910
+ }
2911
+
2351
2912
  // src/utils/safe-path.ts
2352
2913
  import { resolve, normalize } from "path";
2353
2914
  function safePath(userPath) {
@@ -2408,16 +2969,16 @@ function sortResults(items, sortSpec) {
2408
2969
  }
2409
2970
 
2410
2971
  // src/commands/plugin-scaffold.ts
2411
- import { mkdir as mkdir2, writeFile as writeFile3 } from "fs/promises";
2412
- import { join as join4 } from "path";
2972
+ import { mkdir as mkdir3, writeFile as writeFile4 } from "fs/promises";
2973
+ import { join as join5 } from "path";
2413
2974
  async function scaffoldPlugin(options) {
2414
2975
  const { name, dir, description = `GPC plugin: ${name}` } = options;
2415
2976
  const pluginName = name.startsWith("gpc-plugin-") ? name : `gpc-plugin-${name}`;
2416
2977
  const shortName = pluginName.replace(/^gpc-plugin-/, "");
2417
- const srcDir = join4(dir, "src");
2418
- const testDir = join4(dir, "tests");
2419
- await mkdir2(srcDir, { recursive: true });
2420
- await mkdir2(testDir, { recursive: true });
2978
+ const srcDir = join5(dir, "src");
2979
+ const testDir = join5(dir, "tests");
2980
+ await mkdir3(srcDir, { recursive: true });
2981
+ await mkdir3(testDir, { recursive: true });
2421
2982
  const files = [];
2422
2983
  const pkg = {
2423
2984
  name: pluginName,
@@ -2451,7 +3012,7 @@ async function scaffoldPlugin(options) {
2451
3012
  vitest: "^3.0.0"
2452
3013
  }
2453
3014
  };
2454
- await writeFile3(join4(dir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
3015
+ await writeFile4(join5(dir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
2455
3016
  files.push("package.json");
2456
3017
  const tsconfig = {
2457
3018
  compilerOptions: {
@@ -2467,7 +3028,7 @@ async function scaffoldPlugin(options) {
2467
3028
  },
2468
3029
  include: ["src"]
2469
3030
  };
2470
- await writeFile3(join4(dir, "tsconfig.json"), JSON.stringify(tsconfig, null, 2) + "\n");
3031
+ await writeFile4(join5(dir, "tsconfig.json"), JSON.stringify(tsconfig, null, 2) + "\n");
2471
3032
  files.push("tsconfig.json");
2472
3033
  const srcContent = `import { definePlugin } from "@gpc-cli/plugin-sdk";
2473
3034
  import type { CommandEvent, CommandResult } from "@gpc-cli/plugin-sdk";
@@ -2500,7 +3061,7 @@ export const plugin = definePlugin({
2500
3061
  },
2501
3062
  });
2502
3063
  `;
2503
- await writeFile3(join4(srcDir, "index.ts"), srcContent);
3064
+ await writeFile4(join5(srcDir, "index.ts"), srcContent);
2504
3065
  files.push("src/index.ts");
2505
3066
  const testContent = `import { describe, it, expect, vi } from "vitest";
2506
3067
  import { plugin } from "../src/index";
@@ -2525,14 +3086,14 @@ describe("${pluginName}", () => {
2525
3086
  });
2526
3087
  });
2527
3088
  `;
2528
- await writeFile3(join4(testDir, "plugin.test.ts"), testContent);
3089
+ await writeFile4(join5(testDir, "plugin.test.ts"), testContent);
2529
3090
  files.push("tests/plugin.test.ts");
2530
3091
  return { dir, files };
2531
3092
  }
2532
3093
 
2533
3094
  // src/audit.ts
2534
- import { appendFile, chmod, mkdir as mkdir3 } from "fs/promises";
2535
- import { join as join5 } from "path";
3095
+ import { appendFile, chmod, mkdir as mkdir4 } from "fs/promises";
3096
+ import { join as join6 } from "path";
2536
3097
  var auditDir = null;
2537
3098
  function initAudit(configDir) {
2538
3099
  auditDir = configDir;
@@ -2540,8 +3101,8 @@ function initAudit(configDir) {
2540
3101
  async function writeAuditLog(entry) {
2541
3102
  if (!auditDir) return;
2542
3103
  try {
2543
- await mkdir3(auditDir, { recursive: true, mode: 448 });
2544
- const logPath = join5(auditDir, "audit.log");
3104
+ await mkdir4(auditDir, { recursive: true, mode: 448 });
3105
+ const logPath = join6(auditDir, "audit.log");
2545
3106
  const redactedEntry = redactAuditArgs(entry);
2546
3107
  const line = JSON.stringify(redactedEntry) + "\n";
2547
3108
  await appendFile(logPath, line, { encoding: "utf-8", mode: 384 });
@@ -2724,7 +3285,7 @@ function detectFileType(filePath) {
2724
3285
  }
2725
3286
 
2726
3287
  // src/commands/generated-apks.ts
2727
- import { writeFile as writeFile4 } from "fs/promises";
3288
+ import { writeFile as writeFile5 } from "fs/promises";
2728
3289
  async function listGeneratedApks(client, packageName, versionCode) {
2729
3290
  if (!Number.isInteger(versionCode) || versionCode <= 0) {
2730
3291
  throw new GpcError(
@@ -2755,9 +3316,71 @@ async function downloadGeneratedApk(client, packageName, versionCode, apkId, out
2755
3316
  }
2756
3317
  const buffer = await client.generatedApks.download(packageName, versionCode, apkId);
2757
3318
  const bytes = new Uint8Array(buffer);
2758
- await writeFile4(outputPath, bytes);
3319
+ await writeFile5(outputPath, bytes);
2759
3320
  return { path: outputPath, sizeBytes: bytes.byteLength };
2760
3321
  }
3322
+
3323
+ // src/commands/purchase-options.ts
3324
+ async function listPurchaseOptions(client, packageName) {
3325
+ try {
3326
+ return await client.purchaseOptions.list(packageName);
3327
+ } catch (error) {
3328
+ throw new GpcError(
3329
+ `Failed to list purchase options: ${error instanceof Error ? error.message : String(error)}`,
3330
+ "PURCHASE_OPTIONS_LIST_FAILED",
3331
+ 4,
3332
+ "Check your package name and API permissions."
3333
+ );
3334
+ }
3335
+ }
3336
+ async function getPurchaseOption(client, packageName, purchaseOptionId) {
3337
+ try {
3338
+ return await client.purchaseOptions.get(packageName, purchaseOptionId);
3339
+ } catch (error) {
3340
+ throw new GpcError(
3341
+ `Failed to get purchase option "${purchaseOptionId}": ${error instanceof Error ? error.message : String(error)}`,
3342
+ "PURCHASE_OPTION_GET_FAILED",
3343
+ 4,
3344
+ "Check that the purchase option ID exists."
3345
+ );
3346
+ }
3347
+ }
3348
+ async function createPurchaseOption(client, packageName, data) {
3349
+ try {
3350
+ return await client.purchaseOptions.create(packageName, data);
3351
+ } catch (error) {
3352
+ throw new GpcError(
3353
+ `Failed to create purchase option: ${error instanceof Error ? error.message : String(error)}`,
3354
+ "PURCHASE_OPTION_CREATE_FAILED",
3355
+ 4,
3356
+ "Check your purchase option data and API permissions."
3357
+ );
3358
+ }
3359
+ }
3360
+ async function activatePurchaseOption(client, packageName, purchaseOptionId) {
3361
+ try {
3362
+ return await client.purchaseOptions.activate(packageName, purchaseOptionId);
3363
+ } catch (error) {
3364
+ throw new GpcError(
3365
+ `Failed to activate purchase option "${purchaseOptionId}": ${error instanceof Error ? error.message : String(error)}`,
3366
+ "PURCHASE_OPTION_ACTIVATE_FAILED",
3367
+ 4,
3368
+ "Check that the purchase option exists and is in a valid state for activation."
3369
+ );
3370
+ }
3371
+ }
3372
+ async function deactivatePurchaseOption(client, packageName, purchaseOptionId) {
3373
+ try {
3374
+ return await client.purchaseOptions.deactivate(packageName, purchaseOptionId);
3375
+ } catch (error) {
3376
+ throw new GpcError(
3377
+ `Failed to deactivate purchase option "${purchaseOptionId}": ${error instanceof Error ? error.message : String(error)}`,
3378
+ "PURCHASE_OPTION_DEACTIVATE_FAILED",
3379
+ 4,
3380
+ "Check that the purchase option exists and is in a valid state for deactivation."
3381
+ );
3382
+ }
3383
+ }
2761
3384
  export {
2762
3385
  ApiError,
2763
3386
  ConfigError,
@@ -2769,8 +3392,10 @@ export {
2769
3392
  acknowledgeProductPurchase,
2770
3393
  activateBasePlan,
2771
3394
  activateOffer,
3395
+ activatePurchaseOption,
2772
3396
  addRecoveryTargeting,
2773
3397
  addTesters,
3398
+ batchSyncInAppProducts,
2774
3399
  cancelRecoveryAction,
2775
3400
  cancelSubscriptionPurchase,
2776
3401
  checkThreshold,
@@ -2784,10 +3409,14 @@ export {
2784
3409
  createOffer,
2785
3410
  createOneTimeOffer,
2786
3411
  createOneTimeProduct,
3412
+ createPurchaseOption,
2787
3413
  createRecoveryAction,
3414
+ createSpinner,
2788
3415
  createSubscription,
3416
+ createTrack,
2789
3417
  deactivateBasePlan,
2790
3418
  deactivateOffer,
3419
+ deactivatePurchaseOption,
2791
3420
  deferSubscriptionPurchase,
2792
3421
  deleteBasePlan,
2793
3422
  deleteImage,
@@ -2798,6 +3427,7 @@ export {
2798
3427
  deleteOneTimeProduct,
2799
3428
  deleteSubscription,
2800
3429
  deployRecoveryAction,
3430
+ detectFastlane,
2801
3431
  detectOutputFormat,
2802
3432
  diffListings,
2803
3433
  diffListingsCommand,
@@ -2805,11 +3435,14 @@ export {
2805
3435
  downloadGeneratedApk,
2806
3436
  downloadReport,
2807
3437
  exportDataSafety,
3438
+ exportImages,
2808
3439
  exportReviews,
2809
3440
  formatCustomPayload,
2810
3441
  formatDiscordPayload,
3442
+ formatJunit,
2811
3443
  formatOutput,
2812
3444
  formatSlackPayload,
3445
+ generateMigrationPlan,
2813
3446
  generateNotesFromGit,
2814
3447
  getAppInfo,
2815
3448
  getCountryAvailability,
@@ -2822,6 +3455,7 @@ export {
2822
3455
  getOneTimeOffer,
2823
3456
  getOneTimeProduct,
2824
3457
  getProductPurchase,
3458
+ getPurchaseOption,
2825
3459
  getReleasesStatus,
2826
3460
  getReview,
2827
3461
  getSubscription,
@@ -2851,6 +3485,7 @@ export {
2851
3485
  listOffers,
2852
3486
  listOneTimeOffers,
2853
3487
  listOneTimeProducts,
3488
+ listPurchaseOptions,
2854
3489
  listRecoveryActions,
2855
3490
  listReports,
2856
3491
  listReviews,
@@ -2860,6 +3495,8 @@ export {
2860
3495
  listUsers,
2861
3496
  listVoidedPurchases,
2862
3497
  migratePrices,
3498
+ parseAppfile,
3499
+ parseFastfile,
2863
3500
  parseGrantArg,
2864
3501
  parseMonth,
2865
3502
  promoteRelease,
@@ -2891,7 +3528,9 @@ export {
2891
3528
  updateOneTimeProduct,
2892
3529
  updateRollout,
2893
3530
  updateSubscription,
3531
+ updateTrackConfig,
2894
3532
  updateUser,
3533
+ uploadExternallyHosted,
2895
3534
  uploadImage,
2896
3535
  uploadInternalSharing,
2897
3536
  uploadRelease,
@@ -2900,6 +3539,7 @@ export {
2900
3539
  validateReleaseNotes,
2901
3540
  validateUploadFile,
2902
3541
  writeAuditLog,
2903
- writeListingsToDir
3542
+ writeListingsToDir,
3543
+ writeMigrationOutput
2904
3544
  };
2905
3545
  //# sourceMappingURL=index.js.map