ubriot 1.2.0 → 2.0.0

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
@@ -38,12 +38,38 @@ const commander_1 = require("commander");
38
38
  const readline_1 = require("./readline");
39
39
  const config_1 = require("./config");
40
40
  const api_1 = require("./api");
41
+ const runtime_preflight_1 = require("./runtime-preflight");
42
+ const build_preflight_1 = require("./build-preflight");
43
+ const build_identity_1 = require("./build-identity");
41
44
  const migrate_1 = require("./migrate");
45
+ const signing_commands_1 = require("./signing-commands");
46
+ const child_env_1 = require("./child-env");
47
+ const unreal_detect_1 = require("./unreal-detect");
48
+ const publish_signing_1 = require("./publish-signing");
42
49
  const child_process_1 = require("child_process");
43
50
  const fs_1 = require("fs");
44
51
  const path_1 = require("path");
45
52
  const crypto_1 = require("crypto");
46
- commander_1.program.name("ubriot").description("Ubriot, mobile release infrastructure for teams that ship").version("1.2.0");
53
+ const JSON_OPTION_HELP = "Print the raw API response as JSON instead of human-readable output";
54
+ commander_1.program
55
+ .name("ubriot")
56
+ .description("Ubriot, mobile release infrastructure for teams that ship")
57
+ .version("2.0.0")
58
+ .enablePositionalOptions()
59
+ .option("--json", JSON_OPTION_HELP);
60
+ let jsonOutput = false;
61
+ commander_1.program.hook("preAction", (_program, actionCommand) => {
62
+ jsonOutput = commander_1.program.opts().json === true || actionCommand.opts().json === true;
63
+ });
64
+ function usingApiKey() {
65
+ return (0, config_1.envToken)()?.startsWith("ubk_") === true;
66
+ }
67
+ function wantsJson() {
68
+ return jsonOutput;
69
+ }
70
+ function printJson(data) {
71
+ console.log(JSON.stringify(data, null, 2));
72
+ }
47
73
  const MIME = {
48
74
  ".avif": "image/avif",
49
75
  ".bundle": "application/javascript",
@@ -312,8 +338,8 @@ function optionalGitShortSha() {
312
338
  return undefined;
313
339
  return result.stdout.trim() || undefined;
314
340
  }
315
- function runLocalCommand(command, args) {
316
- const result = (0, child_process_1.spawnSync)(command, args, { stdio: "inherit", env: process.env });
341
+ function runLocalCommand(command, args, extraEnv = {}) {
342
+ const result = (0, child_process_1.spawnSync)(command, args, { stdio: "inherit", env: (0, child_env_1.childProcessEnv)(extraEnv) });
317
343
  if (result.error)
318
344
  throw result.error;
319
345
  if (result.status !== 0)
@@ -336,6 +362,44 @@ function parsePositiveNumber(value, fallback, label) {
336
362
  throw new Error(`${label} must be a positive number`);
337
363
  return parsed;
338
364
  }
365
+ function parsePlayReleaseStatus(value) {
366
+ if (value === undefined)
367
+ return undefined;
368
+ const status = String(value).trim().toLowerCase();
369
+ if (status !== "draft" && status !== "completed") {
370
+ throw new Error('--release-status must be "draft" (staged in the Play Console) or "completed" (live release)');
371
+ }
372
+ return status;
373
+ }
374
+ function parseRolloutPercentage(value) {
375
+ if (value === undefined)
376
+ return undefined;
377
+ const parsed = Number(value);
378
+ if (!Number.isFinite(parsed) || parsed <= 0 || parsed >= 100) {
379
+ throw new Error("--rollout must be greater than 0 and less than 100. Drop it and use --release-status completed for a full rollout.");
380
+ }
381
+ return parsed;
382
+ }
383
+ function playSubmitBody(options) {
384
+ const body = {};
385
+ if (options.track)
386
+ body.track = options.track;
387
+ if (options.releaseStatus)
388
+ body.releaseStatus = options.releaseStatus;
389
+ if (options.rolloutPercentage !== undefined)
390
+ body.rolloutPercentage = options.rolloutPercentage;
391
+ if (options.releaseNotes)
392
+ body.releaseNotes = options.releaseNotes;
393
+ return body;
394
+ }
395
+ function readPlaySubmitOptions(opts) {
396
+ return {
397
+ track: opts.track,
398
+ releaseStatus: parsePlayReleaseStatus(opts.releaseStatus),
399
+ rolloutPercentage: parseRolloutPercentage(opts.rollout),
400
+ releaseNotes: opts.releaseNotes,
401
+ };
402
+ }
339
403
  function buildStatusLine(build) {
340
404
  const asc = build.ascSubmissionStatus ? `, TestFlight ${build.ascSubmissionStatus}` : "";
341
405
  const play = build.playSubmissionStatus ? `, Google Play ${build.playSubmissionStatus}` : "";
@@ -413,7 +477,7 @@ async function waitForTestFlight(client, buildId, intervalMs, timeoutMs) {
413
477
  }
414
478
  throw new Error(`Timed out waiting for TestFlight submit for build #${buildId}`);
415
479
  }
416
- async function queueGooglePlayIfNeeded(client, build, track) {
480
+ async function queueGooglePlayIfNeeded(client, build, options = {}) {
417
481
  if (build.platform !== "android")
418
482
  throw new Error("--submit-google-play is only valid for Android builds");
419
483
  if (build.playSubmissionStatus === "succeeded") {
@@ -424,7 +488,7 @@ async function queueGooglePlayIfNeeded(client, build, track) {
424
488
  console.log(`Build #${build.id} already has Google Play submission ${build.playSubmissionStatus}.`);
425
489
  return build;
426
490
  }
427
- const { data } = await client.post(`/ubriot/builds/${build.id}/submit-google-play`, track ? { track } : {});
491
+ const { data } = await client.post(`/ubriot/builds/${build.id}/submit-google-play`, playSubmitBody(options));
428
492
  console.log(`Build #${build.id} queued for Google Play submit.`);
429
493
  return data;
430
494
  }
@@ -534,6 +598,7 @@ async function buildOtaPlatformPayload(input) {
534
598
  },
535
599
  extra: {
536
600
  expoClient: input.expoConfig,
601
+ ubriot: { ...input.ubriotExtra, platform: input.platform },
537
602
  },
538
603
  };
539
604
  return {
@@ -542,13 +607,148 @@ async function buildOtaPlatformPayload(input) {
542
607
  assets: uploadedForDb,
543
608
  };
544
609
  }
610
+ function runLocalCommandIn(cwd, command, args) {
611
+ const result = (0, child_process_1.spawnSync)(command, args, { stdio: "inherit", env: (0, child_env_1.childProcessEnv)(), cwd });
612
+ if (result.error)
613
+ throw result.error;
614
+ if (result.status !== 0)
615
+ throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.status ?? "unknown"}`);
616
+ }
617
+ function detectBareEntryFile(projectDir) {
618
+ const pkgPath = (0, path_1.join)(projectDir, "package.json");
619
+ if ((0, fs_1.existsSync)(pkgPath)) {
620
+ const pkg = readJsonFile(pkgPath);
621
+ if (typeof pkg.main === "string" && pkg.main.trim() && /\.[cm]?[jt]sx?$/.test(pkg.main)) {
622
+ if ((0, fs_1.existsSync)((0, path_1.join)(projectDir, pkg.main)))
623
+ return pkg.main;
624
+ }
625
+ }
626
+ for (const candidate of ["index.js", "index.ts", "index.tsx"]) {
627
+ if ((0, fs_1.existsSync)((0, path_1.join)(projectDir, candidate)))
628
+ return candidate;
629
+ }
630
+ return "index.js";
631
+ }
632
+ function isBareRnProject(projectDir) {
633
+ const pkgPath = (0, path_1.join)(projectDir, "package.json");
634
+ if ((0, fs_1.existsSync)(pkgPath)) {
635
+ const pkg = readJsonFile(pkgPath);
636
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
637
+ if (deps.expo)
638
+ return false;
639
+ }
640
+ const appJsonPath = (0, path_1.join)(projectDir, "app.json");
641
+ if ((0, fs_1.existsSync)(appJsonPath)) {
642
+ const appJson = readJsonFile(appJsonPath);
643
+ if (appJson.expo)
644
+ return false;
645
+ }
646
+ return true;
647
+ }
648
+ function collectBundledAssets(platformDir, bundleFileName) {
649
+ const files = [];
650
+ const walk = (dir) => {
651
+ for (const entry of (0, fs_1.readdirSync)(dir, { withFileTypes: true })) {
652
+ const full = (0, path_1.join)(dir, entry.name);
653
+ if (entry.isDirectory())
654
+ walk(full);
655
+ else
656
+ files.push(full);
657
+ }
658
+ };
659
+ walk(platformDir);
660
+ return files.filter((full) => {
661
+ const name = (0, path_1.basename)(full);
662
+ return name !== bundleFileName && name !== `${bundleFileName}.map`;
663
+ });
664
+ }
665
+ function exportBareRnBundle(input) {
666
+ const projectDir = (0, path_1.resolve)(input.projectDir);
667
+ const outDir = (0, path_1.resolve)(input.outDir);
668
+ const entryFile = input.entryFile ?? detectBareEntryFile(projectDir);
669
+ const platforms = input.platform === "all" ? ["ios", "android"] : [input.platform];
670
+ const bundleFileName = "index.bundle";
671
+ const fileMetadata = {};
672
+ for (const platform of platforms) {
673
+ if (platform !== "ios" && platform !== "android")
674
+ throw new Error("--platform must be ios, android, or all");
675
+ const platformDir = (0, path_1.join)(outDir, platform);
676
+ (0, fs_1.mkdirSync)(platformDir, { recursive: true });
677
+ const bundleOutput = (0, path_1.join)(platformDir, bundleFileName);
678
+ runLocalCommandIn(projectDir, "npx", [
679
+ "react-native",
680
+ "bundle",
681
+ "--platform",
682
+ platform,
683
+ "--dev",
684
+ "false",
685
+ "--entry-file",
686
+ entryFile,
687
+ "--bundle-output",
688
+ bundleOutput,
689
+ "--assets-dest",
690
+ platformDir,
691
+ ]);
692
+ if (!(0, fs_1.existsSync)(bundleOutput))
693
+ throw new Error(`react-native bundle did not produce ${bundleOutput}`);
694
+ const assets = collectBundledAssets(platformDir, bundleFileName).map((full) => {
695
+ const ext = (0, path_1.extname)(full).replace(/^\./, "");
696
+ return { path: (0, path_1.relative)(outDir, full), ext: ext || null };
697
+ });
698
+ fileMetadata[platform] = { bundle: (0, path_1.relative)(outDir, bundleOutput), assets };
699
+ }
700
+ (0, fs_1.mkdirSync)(outDir, { recursive: true });
701
+ const metadata = { version: 0, bundler: "metro", fileMetadata };
702
+ (0, fs_1.writeFileSync)((0, path_1.join)(outDir, "metadata.json"), JSON.stringify(metadata, null, 2));
703
+ return { outDir, entryFile, metadata };
704
+ }
705
+ async function confirmRuntimePreflight(client, input) {
706
+ const result = await (0, runtime_preflight_1.fetchRuntimePreflight)(client, input);
707
+ if (!result) {
708
+ console.log("Runtime reach check unavailable, continuing without it.");
709
+ return true;
710
+ }
711
+ for (const line of (0, runtime_preflight_1.renderPreflight)(result))
712
+ console.log(line);
713
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
714
+ if ((0, runtime_preflight_1.preflightDecision)(result, { yes: input.yes, interactive }) === "continue")
715
+ return true;
716
+ const answer = await (0, readline_1.readlineSync)("Publish anyway? [y/N] ");
717
+ return /^y(es)?$/i.test(answer.trim());
718
+ }
719
+ async function confirmBuildPreflight(client, input) {
720
+ if (input.platform !== "ios" && input.platform !== "android")
721
+ return true;
722
+ const result = await (0, build_preflight_1.fetchBuildPreflight)(client, { app: input.app, platform: input.platform, runtime: (0, build_preflight_1.detectProjectRuntime)(input.projectDir) });
723
+ if (!result)
724
+ return true;
725
+ const print = wantsJson() ? console.error : console.log;
726
+ for (const line of (0, build_preflight_1.renderBuildPreflight)(result))
727
+ print(line);
728
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
729
+ if ((0, build_preflight_1.buildPreflightDecision)(result, { yes: input.yes, interactive }) === "continue")
730
+ return true;
731
+ const answer = await (0, readline_1.readlineSync)("Queue the build anyway? [y/N] ");
732
+ return /^y(es)?$/i.test(answer.trim());
733
+ }
734
+ async function confirmLocalNode(projectDir, yes) {
735
+ const problem = (0, build_preflight_1.localNodeProblem)((0, build_preflight_1.detectProjectRuntime)(projectDir), process.versions.node);
736
+ if (!problem)
737
+ return true;
738
+ console.log(`Warning: ${problem}`);
739
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
740
+ if (yes || !interactive)
741
+ return true;
742
+ const answer = await (0, readline_1.readlineSync)("Export anyway? [y/N] ");
743
+ return /^y(es)?$/i.test(answer.trim());
744
+ }
545
745
  async function publishExportedUpdate(input) {
546
746
  const exportDir = (0, path_1.resolve)(input.exportDir);
547
747
  const metadataPath = (0, path_1.join)(exportDir, "metadata.json");
548
748
  if (!(0, fs_1.existsSync)(metadataPath))
549
749
  throw new Error(`No metadata.json found in ${exportDir}. Run: npx expo export --output-dir ${input.exportDir}`);
550
750
  const metadataJson = readJsonFile(metadataPath);
551
- const expoConfig = readExpoConfig(input.expoConfigPath);
751
+ const expoConfig = input.expoConfig;
552
752
  const publishId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
553
753
  const selectedPlatforms = input.platform === "all" ? ["ios", "android"] : [input.platform];
554
754
  const platforms = [];
@@ -566,6 +766,7 @@ async function publishExportedUpdate(input) {
566
766
  expoConfig,
567
767
  metadataJson,
568
768
  publicApiBase: input.publicApiBase,
769
+ ubriotExtra: input.ubriotExtra,
569
770
  });
570
771
  if (payload)
571
772
  platforms.push(payload);
@@ -576,7 +777,7 @@ async function publishExportedUpdate(input) {
576
777
  channel: input.channel,
577
778
  runtimeVersion: input.runtimeVersion,
578
779
  message: input.message,
579
- platforms,
780
+ platforms: (0, publish_signing_1.signPlatforms)(platforms, input.signingMaterials),
580
781
  }, { timeout: 120000 });
581
782
  return { data, platforms };
582
783
  }
@@ -586,13 +787,17 @@ commander_1.program
586
787
  .description("Log in with your Ubriot credentials")
587
788
  .option("--api-base <url>", "API base URL")
588
789
  .action(async (opts) => {
790
+ if (!process.stdin.isTTY) {
791
+ console.error(`ubriot login needs an interactive terminal. For scripts, CI and agents, set UBRIOT_TOKEN to an API key from ${config_1.API_KEYS_URL}.`);
792
+ process.exit(1);
793
+ }
589
794
  if (opts.apiBase)
590
795
  (0, config_1.writeConfig)({ apiBase: opts.apiBase });
591
796
  const email = await (0, readline_1.readlineSync)("Email: ");
592
797
  const password = await (0, readline_1.readlineSync)("Password: ", true);
593
798
  try {
594
799
  const client = (0, api_1.makeClient)();
595
- const { data } = await client.post("/auth/login", { email, password });
800
+ const { data } = await client.post("/auth/login", { email, password }, { headers: { "X-Ubriot-Client": "cli" } });
596
801
  const token = data?.user?.token;
597
802
  if (!token)
598
803
  throw new Error("No token in response");
@@ -607,7 +812,7 @@ commander_1.program
607
812
  .command("logout")
608
813
  .description("Clear saved credentials")
609
814
  .action(async () => {
610
- const cfg = (0, config_1.readConfig)();
815
+ const cfg = (0, config_1.readSavedConfig)();
611
816
  if (cfg.refreshToken) {
612
817
  // Best-effort server-side revoke; ignore failures.
613
818
  try {
@@ -623,9 +828,15 @@ commander_1.program
623
828
  commander_1.program
624
829
  .command("whoami")
625
830
  .description("Show current user")
831
+ .option("--json", JSON_OPTION_HELP)
626
832
  .action(() => {
627
833
  const cfg = (0, config_1.readConfig)();
628
- if (cfg.email)
834
+ const source = (0, config_1.envToken)() ? "env" : cfg.token ? "config" : null;
835
+ if (wantsJson())
836
+ printJson({ email: cfg.email, source });
837
+ else if (source === "env")
838
+ console.log("Using UBRIOT_TOKEN from the environment.");
839
+ else if (cfg.email)
629
840
  console.log(cfg.email);
630
841
  else
631
842
  console.log("Not logged in.");
@@ -643,10 +854,17 @@ apps
643
854
  .option("--ios-bundle <id>", "iOS bundle identifier")
644
855
  .option("--google-play-track <track>", "Default Google Play track", "internal")
645
856
  .option("--expo-project-root <path>", "Path to the Expo app inside the repo (monorepos), e.g. products/myapp/mobile")
857
+ .option("--unreal", "Unreal Engine project (Android builds); reads the .uproject in the project root")
858
+ .option("--engine-version <x.y>", "Unreal Engine version, defaults to the .uproject EngineAssociation")
859
+ .option("--unreal-configuration <name>", "Unreal build configuration: Development or Shipping (default Shipping)")
860
+ .option("--package-format <format>", "Unreal Android package: aab or apk (default aab)")
861
+ .option("--accept-unreal-eula", "Confirm you hold an Unreal Engine licence for this version and accept the Unreal Engine EULA")
646
862
  .action(async (opts) => {
647
863
  (0, config_1.requireAuth)();
648
864
  try {
865
+ const unreal = (0, unreal_detect_1.unrealAppFields)(opts, (0, path_1.resolve)(opts.expoProjectRoot ?? "."));
649
866
  const { data } = await (0, api_1.makeClient)().post("/ubriot/apps", {
867
+ ...(unreal ?? {}),
650
868
  name: opts.name,
651
869
  slug: opts.slug,
652
870
  repoUrl: opts.repo,
@@ -665,10 +883,15 @@ apps
665
883
  apps
666
884
  .command("list")
667
885
  .description("List your release apps")
886
+ .option("--json", JSON_OPTION_HELP)
668
887
  .action(async () => {
669
888
  (0, config_1.requireAuth)();
670
889
  try {
671
890
  const { data } = await (0, api_1.makeClient)().get("/ubriot/apps");
891
+ if (wantsJson()) {
892
+ printJson(data);
893
+ return;
894
+ }
672
895
  if (!data.length) {
673
896
  console.log("No apps yet.");
674
897
  return;
@@ -692,15 +915,20 @@ apps
692
915
  .option("--android-id <id>", "Android application ID / package name")
693
916
  .option("--asc-app-id <id>", "App Store Connect app id (from ASC app URL)")
694
917
  .option("--clear-asc-app-id", "Remove stored ASC app id (use bundle id lookup)", false)
695
- .option("--auto-submit-testflight", "Enable auto-submit after each successful iOS build", false)
696
- .option("--no-auto-submit-testflight", "Disable auto-submit", false)
918
+ .option("--auto-submit-testflight", "Enable auto-submit after each successful iOS build")
919
+ .option("--no-auto-submit-testflight", "Disable auto-submit")
697
920
  .option("--google-play-track <track>", "Default Google Play track, e.g. internal, alpha, beta, production")
698
- .option("--auto-submit-google-play", "Enable auto-submit to Google Play after each successful Android build", false)
699
- .option("--no-auto-submit-google-play", "Disable Google Play auto-submit", false)
921
+ .option("--auto-submit-google-play", "Enable auto-submit to Google Play after each successful Android build")
922
+ .option("--no-auto-submit-google-play", "Disable Google Play auto-submit")
923
+ .option("--unreal", "Switch to (or update) an Unreal Engine project; reads the .uproject in the project root")
924
+ .option("--engine-version <x.y>", "Unreal Engine version, defaults to the .uproject EngineAssociation")
925
+ .option("--unreal-configuration <name>", "Unreal build configuration: Development or Shipping")
926
+ .option("--package-format <format>", "Unreal Android package: aab or apk")
927
+ .option("--accept-unreal-eula", "Confirm you hold an Unreal Engine licence for this version and accept the Unreal Engine EULA")
700
928
  .action(async (opts) => {
701
929
  (0, config_1.requireAuth)();
702
930
  try {
703
- const body = {};
931
+ const body = { ...((0, unreal_detect_1.unrealAppFields)(opts, (0, path_1.resolve)(opts.expoProjectRoot ?? ".")) ?? {}) };
704
932
  if (opts.iosBundle !== undefined)
705
933
  body.iosBundleIdentifier = opts.iosBundle || null;
706
934
  if (opts.expoProjectRoot !== undefined)
@@ -711,18 +939,14 @@ apps
711
939
  body.appStoreConnectAppId = null;
712
940
  else if (opts.ascAppId !== undefined)
713
941
  body.appStoreConnectAppId = opts.ascAppId;
714
- if (opts.autoSubmitTestflight)
715
- body.autoSubmitTestflight = true;
716
- if (opts.noAutoSubmitTestflight)
717
- body.autoSubmitTestflight = false;
942
+ if (opts.autoSubmitTestflight !== undefined)
943
+ body.autoSubmitTestflight = opts.autoSubmitTestflight;
718
944
  if (opts.googlePlayTrack !== undefined)
719
945
  body.googlePlayTrack = opts.googlePlayTrack || null;
720
- if (opts.autoSubmitGooglePlay)
721
- body.autoSubmitGooglePlay = true;
722
- if (opts.noAutoSubmitGooglePlay)
723
- body.autoSubmitGooglePlay = false;
946
+ if (opts.autoSubmitGooglePlay !== undefined)
947
+ body.autoSubmitGooglePlay = opts.autoSubmitGooglePlay;
724
948
  if (!Object.keys(body).length) {
725
- console.log("Nothing to update, pass --ios-bundle, --android-id, --asc-app-id, --google-play-track, and/or auto-submit flags");
949
+ console.log("Nothing to update, pass --ios-bundle, --android-id, --asc-app-id, --google-play-track, --unreal, and/or auto-submit flags");
726
950
  return;
727
951
  }
728
952
  const { data } = await (0, api_1.makeClient)().patch(`/ubriot/apps/${opts.app}`, body);
@@ -732,6 +956,37 @@ apps
732
956
  (0, api_1.handleError)(err);
733
957
  }
734
958
  });
959
+ apps
960
+ .command("activity <app>")
961
+ .description("Show who changed what on a release app, newest first")
962
+ .option("--limit <n>", "Number of entries (max 200)", "50")
963
+ .option("--offset <n>", "Skip this many newer entries", "0")
964
+ .option("--json", JSON_OPTION_HELP)
965
+ .action(async (app, opts) => {
966
+ (0, config_1.requireAuth)();
967
+ try {
968
+ const { data } = await (0, api_1.makeClient)().get(`/ubriot/apps/${encodeURIComponent(app)}/activity`, {
969
+ params: { limit: opts.limit, offset: opts.offset },
970
+ });
971
+ if (wantsJson()) {
972
+ printJson(data);
973
+ return;
974
+ }
975
+ if (!data.items.length) {
976
+ console.log("No activity yet.");
977
+ return;
978
+ }
979
+ for (const entry of data.items) {
980
+ console.log(` ${new Date(entry.createdAt).toISOString()} ${entry.actor.padEnd(24)} ${entry.summary}`);
981
+ }
982
+ const shown = Number(opts.offset) + data.items.length;
983
+ if (shown < data.total)
984
+ console.log(` … ${data.total - shown} older, use --offset ${shown}`);
985
+ }
986
+ catch (err) {
987
+ (0, api_1.handleError)(err);
988
+ }
989
+ });
735
990
  // ─── Releases ────────────────────────────────────────────────────────────────
736
991
  commander_1.program
737
992
  .command("ship <platform>")
@@ -742,11 +997,15 @@ commander_1.program
742
997
  .option("--branch <branch>", "Branch to build")
743
998
  .option("--local [path]", "Build from a local directory instead of cloning from remote (path defaults to CWD)")
744
999
  .option("--track <track>", "Google Play track for Android", "internal")
1000
+ .option("--release-status <status>", "Google Play release status: draft (default on production) or completed for a live release")
1001
+ .option("--rollout <percent>", "Staged rollout percentage for a live release, greater than 0 and less than 100")
1002
+ .option("--release-notes <text>", "Release notes to attach to the Google Play release")
745
1003
  .option("--no-submit", "Build only; do not submit to TestFlight / Google Play")
746
1004
  .option("--reprovision-ios", "Force-regenerate the iOS provisioning profile before building", false)
747
1005
  .option("--ios-capability <type...>", "Apple capability to ensure on the bundle ID (e.g. HEALTHKIT); regenerates the profile if newly added")
748
1006
  .option("--poll-interval <seconds>", "Polling interval while waiting", "15")
749
1007
  .option("--timeout <minutes>", "Maximum wait time for the build and store submit", "180")
1008
+ .option("--yes", "Queue without confirming toolchain warnings", false)
750
1009
  .action(async (platform, opts) => {
751
1010
  (0, config_1.requireAuth)();
752
1011
  try {
@@ -762,6 +1021,7 @@ commander_1.program
762
1021
  const client = (0, api_1.makeClient)();
763
1022
  const pollIntervalMs = parsePositiveNumber(opts.pollInterval, 15, "--poll-interval") * 1000;
764
1023
  const timeoutMs = parsePositiveNumber(opts.timeout, 180, "--timeout") * 60 * 1000;
1024
+ const playSubmitOptions = readPlaySubmitOptions(opts);
765
1025
  // --local builds from a file:// path so the worker clones locally without needing a remote push
766
1026
  let repoUrl = opts.repo;
767
1027
  let branch = opts.branch;
@@ -778,6 +1038,12 @@ commander_1.program
778
1038
  }
779
1039
  console.log(`Building from local path: ${localPath} (branch: ${branch ?? "default"})`);
780
1040
  }
1041
+ const projectDir = opts.local !== undefined && opts.local !== true ? (0, path_1.resolve)(opts.local) : process.cwd();
1042
+ if (!(await confirmBuildPreflight(client, { app, platform: normalizedPlatform, projectDir, yes: opts.yes === true }))) {
1043
+ console.log("Aborted. No build was queued.");
1044
+ process.exitCode = 1;
1045
+ return;
1046
+ }
781
1047
  // EAS-style auto-provisioning: ensure a provisioning profile exists for iOS.
782
1048
  if (normalizedPlatform === "ios") {
783
1049
  // Auto-detect capabilities from the local Expo config so users don't have
@@ -804,21 +1070,27 @@ commander_1.program
804
1070
  provisionBody.force = true;
805
1071
  if (capabilities.length)
806
1072
  provisionBody.capabilities = capabilities;
807
- try {
808
- const { data: prov } = await client.post(`/ubriot/apps/${encodeURIComponent(app)}/provision-ios`, provisionBody);
809
- if (prov?.status === "provisioned") {
810
- const caps = prov.capabilitiesAdded?.length ? ` [capabilities added: ${prov.capabilitiesAdded.join(", ")}]` : "";
811
- console.log(`Auto-provisioned iOS signing: ${prov.profileName} (${prov.bundleId})${caps}`);
812
- }
813
- }
814
- catch (provErr) {
815
- const msg = provErr?.response?.data?.message ?? provErr?.message ?? "";
816
- // When reprovisioning or capabilities were explicitly requested, a provisioning
817
- // failure means the build is doomed to fail signing, stop here instead.
1073
+ if (usingApiKey()) {
818
1074
  if (provisionExplicit)
819
- throw new Error(`iOS provisioning failed: ${msg}`);
820
- console.log(`⚠ Auto-provisioning skipped: ${msg}`);
1075
+ throw new Error("iOS provisioning needs a signed-in session and does not accept API keys. Run ubriot login on a machine with a terminal, or drop --reprovision-ios and --ios-capability.");
1076
+ console.log("Auto-provisioning skipped: it needs a signed-in session, and UBRIOT_TOKEN is an API key. The build uses the provisioning profile already stored for this app.");
821
1077
  }
1078
+ else
1079
+ try {
1080
+ const { data: prov } = await client.post(`/ubriot/apps/${encodeURIComponent(app)}/provision-ios`, provisionBody);
1081
+ if (prov?.status === "provisioned") {
1082
+ const caps = prov.capabilitiesAdded?.length ? ` [capabilities added: ${prov.capabilitiesAdded.join(", ")}]` : "";
1083
+ console.log(`Auto-provisioned iOS signing: ${prov.profileName} (${prov.bundleId})${caps}`);
1084
+ }
1085
+ }
1086
+ catch (provErr) {
1087
+ const msg = provErr?.response?.data?.message ?? provErr?.message ?? "";
1088
+ // When reprovisioning or capabilities were explicitly requested, a provisioning
1089
+ // failure means the build is doomed to fail signing, stop here instead.
1090
+ if (provisionExplicit)
1091
+ throw new Error(`iOS provisioning failed: ${msg}`);
1092
+ console.log(`⚠ Auto-provisioning skipped: ${msg}`);
1093
+ }
822
1094
  }
823
1095
  const { data } = await client.post(`/ubriot/apps/${encodeURIComponent(app)}/builds`, {
824
1096
  platform: normalizedPlatform,
@@ -836,7 +1108,7 @@ commander_1.program
836
1108
  console.log(`Build #${submitted.id} is valid in TestFlight.`);
837
1109
  }
838
1110
  else {
839
- const submitted = await queueGooglePlayIfNeeded(client, finished, opts.track);
1111
+ const submitted = await queueGooglePlayIfNeeded(client, finished, playSubmitOptions);
840
1112
  await waitForGooglePlay(client, submitted.id, pollIntervalMs, timeoutMs);
841
1113
  console.log(`Build #${submitted.id} was submitted to Google Play track "${submitted.playTrack ?? opts.track}".`);
842
1114
  }
@@ -859,14 +1131,20 @@ builds
859
1131
  .option("--submit-testflight", "For iOS: wait for the build, submit to TestFlight, and wait for App Store Connect", false)
860
1132
  .option("--submit-google-play", "For Android: wait for the build, submit to Google Play, and wait for commit", false)
861
1133
  .option("--track <track>", "Google Play track for --submit-google-play", "internal")
1134
+ .option("--release-status <status>", "Google Play release status: draft (default on production) or completed for a live release")
1135
+ .option("--rollout <percent>", "Staged rollout percentage for a live release, greater than 0 and less than 100")
1136
+ .option("--release-notes <text>", "Release notes to attach to the Google Play release")
862
1137
  .option("--poll-interval <seconds>", "Polling interval while waiting", "15")
863
1138
  .option("--timeout <minutes>", "Maximum wait time for the build and store submit", "180")
1139
+ .option("--yes", "Queue without confirming toolchain warnings", false)
1140
+ .option("--json", JSON_OPTION_HELP)
864
1141
  .action(async (opts) => {
865
1142
  (0, config_1.requireAuth)();
866
1143
  try {
867
1144
  const client = (0, api_1.makeClient)();
868
1145
  const pollIntervalMs = parsePositiveNumber(opts.pollInterval, 15, "--poll-interval") * 1000;
869
1146
  const timeoutMs = parsePositiveNumber(opts.timeout, 180, "--timeout") * 60 * 1000;
1147
+ const playSubmitOptions = readPlaySubmitOptions(opts);
870
1148
  const shouldWait = Boolean(opts.wait || opts.submitTestflight || opts.submitGooglePlay);
871
1149
  if (opts.submitTestflight && opts.platform !== "ios") {
872
1150
  throw new Error("--submit-testflight can only be used with --platform ios");
@@ -877,12 +1155,21 @@ builds
877
1155
  if (opts.submitGooglePlay && opts.profile === "preview") {
878
1156
  throw new Error("--submit-google-play requires a production AAB. Use --wait without --submit-google-play for preview Android builds.");
879
1157
  }
1158
+ if (!(await confirmBuildPreflight(client, { app: opts.app, platform: opts.platform, projectDir: process.cwd(), yes: opts.yes === true }))) {
1159
+ console.log("Aborted. No build was queued.");
1160
+ process.exitCode = 1;
1161
+ return;
1162
+ }
880
1163
  const { data } = await client.post(`/ubriot/apps/${opts.app}/builds`, {
881
1164
  platform: opts.platform,
882
1165
  profile: opts.profile,
883
1166
  repoUrl: opts.repo,
884
1167
  branch: opts.branch,
885
1168
  });
1169
+ if (!shouldWait && wantsJson()) {
1170
+ printJson(data);
1171
+ return;
1172
+ }
886
1173
  console.log(`Queued build #${data.id} (${data.platform} / ${data.profile}), status: ${data.status}`);
887
1174
  if (!shouldWait) {
888
1175
  if (data.platform === "ios") {
@@ -895,7 +1182,7 @@ builds
895
1182
  }
896
1183
  const finished = await waitForBuild(client, data.id, pollIntervalMs, timeoutMs);
897
1184
  if (opts.submitGooglePlay) {
898
- const submitted = await queueGooglePlayIfNeeded(client, finished, opts.track);
1185
+ const submitted = await queueGooglePlayIfNeeded(client, finished, playSubmitOptions);
899
1186
  await waitForGooglePlay(client, submitted.id, pollIntervalMs, timeoutMs);
900
1187
  console.log(`Build #${submitted.id} was submitted to Google Play track "${submitted.playTrack ?? opts.track}".`);
901
1188
  return;
@@ -914,11 +1201,16 @@ builds
914
1201
  .command("list")
915
1202
  .description("List recent builds")
916
1203
  .option("--app <appId>", "Filter by app ID")
1204
+ .option("--json", JSON_OPTION_HELP)
917
1205
  .action(async (opts) => {
918
1206
  (0, config_1.requireAuth)();
919
1207
  try {
920
1208
  const url = opts.app ? `/ubriot/apps/${opts.app}/builds` : "/ubriot/builds";
921
1209
  const { data } = await (0, api_1.makeClient)().get(url);
1210
+ if (wantsJson()) {
1211
+ printJson(data);
1212
+ return;
1213
+ }
922
1214
  if (!data.length) {
923
1215
  console.log("No builds found.");
924
1216
  return;
@@ -932,6 +1224,67 @@ builds
932
1224
  (0, api_1.handleError)(err);
933
1225
  }
934
1226
  });
1227
+ builds
1228
+ .command("queue")
1229
+ .description("Explain why your queued builds have not started: queue position, worker availability, rough start time")
1230
+ .option("--json", JSON_OPTION_HELP)
1231
+ .action(async () => {
1232
+ (0, config_1.requireAuth)();
1233
+ try {
1234
+ const { data } = await (0, api_1.makeClient)().get("/ubriot/builds/queue");
1235
+ if (wantsJson()) {
1236
+ printJson(data);
1237
+ return;
1238
+ }
1239
+ const rows = data?.builds ?? [];
1240
+ if (!rows.length) {
1241
+ console.log("No queued builds.");
1242
+ return;
1243
+ }
1244
+ for (const q of rows) {
1245
+ console.log(` #${q.buildId} ${q.platform} position ${q.position}, waiting ${q.waitingMinutes} min`);
1246
+ console.log(` ${q.headline}`);
1247
+ console.log(` ${q.detail}`);
1248
+ for (const c of q.commands ?? [])
1249
+ console.log(` $ ${c}`);
1250
+ }
1251
+ }
1252
+ catch (err) {
1253
+ (0, api_1.handleError)(err);
1254
+ }
1255
+ });
1256
+ builds
1257
+ .command("last-good")
1258
+ .description("Show the last successful build per app, platform and profile, and how many builds have failed since")
1259
+ .option("--app <appId>", "Only this app id")
1260
+ .option("--json", JSON_OPTION_HELP)
1261
+ .action(async (opts) => {
1262
+ (0, config_1.requireAuth)();
1263
+ try {
1264
+ const params = opts.app ? { appId: opts.app } : undefined;
1265
+ const { data } = await (0, api_1.makeClient)().get("/ubriot/builds/last-good", { params });
1266
+ if (wantsJson()) {
1267
+ printJson(data);
1268
+ return;
1269
+ }
1270
+ const lanes = data?.lanes ?? [];
1271
+ if (!lanes.length) {
1272
+ console.log("No builds yet.");
1273
+ return;
1274
+ }
1275
+ for (const lane of lanes) {
1276
+ console.log(` app ${lane.releaseAppId} ${lane.platform}/${lane.profile} ${lane.state.replace(/_/g, " ")}, latest #${lane.latestBuildId} ${lane.latestStatus}`);
1277
+ console.log(` ${lane.headline}`);
1278
+ if (lane.detail)
1279
+ console.log(` ${lane.detail}`);
1280
+ for (const c of lane.commands ?? [])
1281
+ console.log(` $ ${c}`);
1282
+ }
1283
+ }
1284
+ catch (err) {
1285
+ (0, api_1.handleError)(err);
1286
+ }
1287
+ });
935
1288
  builds
936
1289
  .command("show <buildId>")
937
1290
  .description("Show build details")
@@ -1079,10 +1432,13 @@ builds
1079
1432
  .command("submit-google-play <buildId>")
1080
1433
  .description("Queue upload of the Android .aab to Google Play; requires a Google service account on the app")
1081
1434
  .option("--track <track>", "Google Play track", "internal")
1435
+ .option("--release-status <status>", "Google Play release status: draft (default on production) or completed for a live release")
1436
+ .option("--rollout <percent>", "Staged rollout percentage for a live release, greater than 0 and less than 100")
1437
+ .option("--release-notes <text>", "Release notes to attach to the Google Play release")
1082
1438
  .action(async (buildId, opts) => {
1083
1439
  (0, config_1.requireAuth)();
1084
1440
  try {
1085
- const { data } = await (0, api_1.makeClient)().post(`/ubriot/builds/${buildId}/submit-google-play`, { track: opts.track });
1441
+ const { data } = await (0, api_1.makeClient)().post(`/ubriot/builds/${buildId}/submit-google-play`, playSubmitBody(readPlaySubmitOptions(opts)));
1086
1442
  console.log(`Build #${buildId} queued for Google Play submit, playSubmissionStatus: ${data.playSubmissionStatus}`);
1087
1443
  }
1088
1444
  catch (err) {
@@ -1105,10 +1461,15 @@ builds
1105
1461
  builds
1106
1462
  .command("artifacts <buildId>")
1107
1463
  .description("List artifacts for a build")
1464
+ .option("--json", JSON_OPTION_HELP)
1108
1465
  .action(async (buildId) => {
1109
1466
  (0, config_1.requireAuth)();
1110
1467
  try {
1111
1468
  const { data } = await (0, api_1.makeClient)().get(`/ubriot/builds/${buildId}/artifacts`);
1469
+ if (wantsJson()) {
1470
+ printJson(data);
1471
+ return;
1472
+ }
1112
1473
  if (!data.length) {
1113
1474
  console.log("No artifacts yet.");
1114
1475
  return;
@@ -1237,10 +1598,15 @@ creds
1237
1598
  .command("list")
1238
1599
  .description("List credentials for an app")
1239
1600
  .requiredOption("--app <appId>")
1601
+ .option("--json", JSON_OPTION_HELP)
1240
1602
  .action(async (opts) => {
1241
1603
  (0, config_1.requireAuth)();
1242
1604
  try {
1243
1605
  const { data } = await (0, api_1.makeClient)().get(`/ubriot/apps/${opts.app}/credentials`);
1606
+ if (wantsJson()) {
1607
+ printJson(data);
1608
+ return;
1609
+ }
1244
1610
  if (!data.length) {
1245
1611
  console.log("No credentials.");
1246
1612
  return;
@@ -1383,11 +1749,18 @@ creds
1383
1749
  (0, api_1.handleError)(err);
1384
1750
  }
1385
1751
  });
1752
+ function uploadAppleIdCommand(app) {
1753
+ return `ubriot credentials upload-apple-id --app ${app} --name <name> --apple-id <email> --app-specific-password <password>`;
1754
+ }
1386
1755
  creds
1387
1756
  .command("setup-testflight")
1388
1757
  .description("Interactive guided setup for TestFlight submission (like EAS, walks you through it)")
1389
1758
  .requiredOption("--app <appId>", "App ID (run 'ubriot apps list' if unsure)")
1390
1759
  .action(async (opts) => {
1760
+ if (!process.stdin.isTTY) {
1761
+ console.error(`ubriot credentials setup-testflight needs an interactive terminal. Use instead: ${uploadAppleIdCommand(opts.app)}`);
1762
+ process.exit(1);
1763
+ }
1391
1764
  (0, config_1.requireAuth)();
1392
1765
  try {
1393
1766
  // 1. Confirm the app exists
@@ -1401,7 +1774,7 @@ creds
1401
1774
  // 2. Get Apple ID
1402
1775
  const appleId = await (0, readline_1.readlineSync)("Enter your Apple developer email: ");
1403
1776
  if (!appleId.includes("@")) {
1404
- console.error("That doesn't look like a valid email. Run the command again.");
1777
+ console.error(`That doesn't look like a valid email. Run the command again, or use: ${uploadAppleIdCommand(opts.app)}`);
1405
1778
  process.exit(1);
1406
1779
  }
1407
1780
  // 3. Guide them to generate an app-specific password
@@ -1656,10 +2029,15 @@ push
1656
2029
  .command("list-server-keys")
1657
2030
  .description("List app-scoped Ubriot Push server keys")
1658
2031
  .requiredOption("--app <appId>")
2032
+ .option("--json", JSON_OPTION_HELP)
1659
2033
  .action(async (opts) => {
1660
2034
  (0, config_1.requireAuth)();
1661
2035
  try {
1662
2036
  const { data } = await (0, api_1.makeClient)().get(`/ubriot/apps/${opts.app}/push/server-keys`);
2037
+ if (wantsJson()) {
2038
+ printJson(data);
2039
+ return;
2040
+ }
1663
2041
  if (!data.length) {
1664
2042
  console.log("No push server keys.");
1665
2043
  return;
@@ -1691,10 +2069,15 @@ const channels = commander_1.program.command("channels").description("Manage OTA
1691
2069
  channels
1692
2070
  .command("list")
1693
2071
  .requiredOption("--app <appId>")
2072
+ .option("--json", JSON_OPTION_HELP)
1694
2073
  .action(async (opts) => {
1695
2074
  (0, config_1.requireAuth)();
1696
2075
  try {
1697
2076
  const { data } = await (0, api_1.makeClient)().get(`/ubriot/apps/${opts.app}/channels`);
2077
+ if (wantsJson()) {
2078
+ printJson(data);
2079
+ return;
2080
+ }
1698
2081
  if (!data.length) {
1699
2082
  console.log("No channels.");
1700
2083
  return;
@@ -1740,27 +2123,83 @@ channels
1740
2123
  });
1741
2124
  commander_1.program
1742
2125
  .command("update")
1743
- .description("Export and publish an OTA update from the current Expo app")
2126
+ .description("Export and publish an OTA update from the current Expo or bare React Native app")
1744
2127
  .option("--app <appIdOrSlug>", "App ID or slug; defaults to expo.slug from app.json")
1745
2128
  .option("--channel <channel>", "OTA channel", "production")
1746
2129
  .option("--runtime-version <version>", "Runtime version; defaults to runtimeVersion from app.json")
1747
2130
  .option("--message <msg>", "Release note; defaults to the current git short SHA")
1748
2131
  .option("--platform <platform>", "ios, android, or all", "all")
1749
- .option("--export-dir <dir>", "Expo export output directory", "dist")
1750
- .option("--expo-config <path>", "Path to app.json or exported Expo config JSON", "app.json")
2132
+ .option("--export-dir <dir>", "Export output directory", "dist")
2133
+ .option("--expo-config <path>", "Path to app.json or exported Expo config JSON; defaults to the resolved app.config.js or app.json")
1751
2134
  .option("--public-url <url>", "Public API base URL used in generated asset URLs")
1752
- .option("--no-export", "Publish an existing export directory without running expo export")
2135
+ .option("--bare", "Bundle a bare (non-Expo) React Native app with the RN bundler instead of expo export")
2136
+ .option("--entry-file <file>", "Bare RN entry file (defaults to package.json main or index.js)")
2137
+ .option("--no-export", "Publish an existing export directory without exporting again")
2138
+ .option("--yes", "Publish without confirming the runtime reach warning or the local Node.js version warning", false)
2139
+ .option("--private-key-path <path>", "OTA signing private key PEM (or set UBRIOT_OTA_PRIVATE_KEY)")
2140
+ .option("--certificate-path <path>", "OTA signing certificate PEM matching the private key")
2141
+ .option("--sign-with <keyid>", "Sign with this local key; repeat during a rotation", (value, previous) => [...previous, value], [])
1753
2142
  .action(async (opts) => {
1754
2143
  (0, config_1.requireAuth)();
1755
2144
  try {
1756
- const expoConfig = readExpoConfig(opts.expoConfig);
1757
- const app = resolveAppSlug(opts.app, expoConfig);
2145
+ const projectDir = process.cwd();
2146
+ const bare = opts.bare === true || (opts.bare === undefined && isBareRnProject(projectDir));
2147
+ const staticConfig = readExpoConfig(opts.expoConfig);
2148
+ const app = resolveAppSlug(opts.app, staticConfig);
2149
+ const client = (0, api_1.makeClient)();
2150
+ const releaseApp = bare ? null : (await client.get(`/ubriot/apps/${encodeURIComponent(app)}`)).data;
2151
+ const identityEnv = releaseApp ? (0, build_identity_1.buildIdentityEnv)(releaseApp) : {};
2152
+ const dynamicConfig = !bare && !opts.expoConfig && (0, build_identity_1.hasDynamicExpoConfig)(projectDir);
2153
+ const expoConfig = dynamicConfig ? (0, build_identity_1.resolveExpoConfig)(projectDir, identityEnv) : staticConfig;
1758
2154
  const runtimeVersion = resolveRuntimeVersion(opts.runtimeVersion, expoConfig);
2155
+ if (releaseApp)
2156
+ (0, build_identity_1.assertUpdateMatchesApp)(expoConfig, releaseApp, runtimeVersion);
1759
2157
  const message = opts.message ?? optionalGitShortSha();
1760
- const client = (0, api_1.makeClient)();
2158
+ console.log(`Build identity: ${(0, build_identity_1.describeBuildIdentity)(identityEnv)}. ${dynamicConfig ? "Resolved app config" : "App config"}: ${expoConfig.name ?? "unnamed"}, runtime ${runtimeVersion}.`);
1761
2159
  const publicApiBase = (opts.publicUrl || (0, config_1.readConfig)().apiBase).replace(/\/$/, "");
2160
+ const signing = await (0, publish_signing_1.prepareSigning)({
2161
+ client,
2162
+ app,
2163
+ projectDir,
2164
+ env: process.env,
2165
+ privateKeyPath: opts.privateKeyPath,
2166
+ certificatePath: opts.certificatePath,
2167
+ signWith: opts.signWith,
2168
+ runtimeVersion,
2169
+ });
2170
+ for (const warning of signing.warnings)
2171
+ console.log(`Warning: ${warning}`);
2172
+ const identity = await (0, publish_signing_1.fetchAppIdentity)(client, app);
2173
+ const proceed = await confirmRuntimePreflight(client, {
2174
+ app,
2175
+ runtimeVersion,
2176
+ channel: opts.channel,
2177
+ platform: opts.platform,
2178
+ yes: opts.yes === true,
2179
+ });
2180
+ if (!proceed) {
2181
+ console.log("Aborted. Nothing was published.");
2182
+ process.exitCode = 1;
2183
+ return;
2184
+ }
2185
+ if (opts.export !== false && !(await confirmLocalNode(projectDir, opts.yes === true))) {
2186
+ console.log("Aborted. Nothing was published.");
2187
+ process.exitCode = 1;
2188
+ return;
2189
+ }
1762
2190
  if (opts.export !== false) {
1763
- runLocalCommand("npx", ["expo", "export", "--platform", opts.platform, "--output-dir", opts.exportDir]);
2191
+ if (bare) {
2192
+ const { entryFile } = exportBareRnBundle({
2193
+ projectDir,
2194
+ platform: opts.platform,
2195
+ outDir: (0, path_1.resolve)(opts.exportDir),
2196
+ entryFile: opts.entryFile,
2197
+ });
2198
+ console.log(`Bundled bare React Native app (entry: ${entryFile}) into ${opts.exportDir}`);
2199
+ }
2200
+ else {
2201
+ runLocalCommand("npx", ["expo", "export", "--platform", opts.platform, "--output-dir", opts.exportDir], identityEnv);
2202
+ }
1764
2203
  }
1765
2204
  const { data, platforms } = await publishExportedUpdate({
1766
2205
  client,
@@ -1769,11 +2208,13 @@ commander_1.program
1769
2208
  runtimeVersion,
1770
2209
  message,
1771
2210
  exportDir: opts.exportDir,
1772
- expoConfigPath: opts.expoConfig,
2211
+ expoConfig,
1773
2212
  platform: opts.platform,
1774
2213
  publicApiBase,
2214
+ ubriotExtra: (0, publish_signing_1.ubriotManifestExtra)(identity, app, opts.channel),
2215
+ signingMaterials: signing.materials,
1775
2216
  });
1776
- console.log(`Published update group #${data.updateGroupId} on channel "${data.channel}" (${platforms.map((p) => p.platform).join(", ")})`);
2217
+ console.log(`Published update group #${data.updateGroupId} on channel "${data.channel}" (${platforms.map((p) => p.platform).join(", ")})${signing.materials.length ? `, signed with ${signing.materials.map((m) => m.keyid).join(", ")}` : ""}`);
1777
2218
  }
1778
2219
  catch (err) {
1779
2220
  (0, api_1.handleError)(err);
@@ -1784,10 +2225,15 @@ const updates = commander_1.program.command("updates").description("Manage OTA u
1784
2225
  updates
1785
2226
  .command("list")
1786
2227
  .requiredOption("--app <appId>")
2228
+ .option("--json", JSON_OPTION_HELP)
1787
2229
  .action(async (opts) => {
1788
2230
  (0, config_1.requireAuth)();
1789
2231
  try {
1790
2232
  const { data } = await (0, api_1.makeClient)().get(`/ubriot/apps/${opts.app}/updates`);
2233
+ if (wantsJson()) {
2234
+ printJson(data);
2235
+ return;
2236
+ }
1791
2237
  if (!data.length) {
1792
2238
  console.log("No updates.");
1793
2239
  return;
@@ -1812,22 +2258,62 @@ updates
1812
2258
  .option("--public-url <url>", "Public API base URL used in generated asset URLs")
1813
2259
  .option("--manifest-android <path>", "Path to android manifest JSON")
1814
2260
  .option("--manifest-ios <path>", "Path to ios manifest JSON")
2261
+ .option("--yes", "Publish without confirming runtime reach warnings", false)
2262
+ .option("--private-key-path <path>", "OTA signing private key PEM (or set UBRIOT_OTA_PRIVATE_KEY)")
2263
+ .option("--certificate-path <path>", "OTA signing certificate PEM matching the private key")
2264
+ .option("--sign-with <keyid>", "Sign with this local key; repeat during a rotation", (value, previous) => [...previous, value], [])
1815
2265
  .action(async (opts) => {
1816
2266
  (0, config_1.requireAuth)();
1817
2267
  try {
1818
2268
  const platforms = [];
1819
2269
  const client = (0, api_1.makeClient)();
2270
+ const releaseApp = (await client.get(`/ubriot/apps/${encodeURIComponent(opts.app)}`)).data;
1820
2271
  const publicApiBase = (opts.publicUrl || (0, config_1.readConfig)().apiBase).replace(/\/$/, "");
2272
+ const signing = await (0, publish_signing_1.prepareSigning)({
2273
+ client,
2274
+ app: opts.app,
2275
+ projectDir: process.cwd(),
2276
+ env: process.env,
2277
+ privateKeyPath: opts.privateKeyPath,
2278
+ certificatePath: opts.certificatePath,
2279
+ signWith: opts.signWith,
2280
+ runtimeVersion: opts.runtimeVersion,
2281
+ });
2282
+ for (const warning of signing.warnings)
2283
+ console.log(`Warning: ${warning}`);
2284
+ const preflightPlatform = opts.exportDir
2285
+ ? opts.platform
2286
+ : opts.manifestAndroid && !opts.manifestIos
2287
+ ? "android"
2288
+ : opts.manifestIos && !opts.manifestAndroid
2289
+ ? "ios"
2290
+ : "all";
2291
+ const proceed = await confirmRuntimePreflight(client, {
2292
+ app: opts.app,
2293
+ runtimeVersion: opts.runtimeVersion,
2294
+ channel: opts.channel,
2295
+ platform: preflightPlatform,
2296
+ yes: opts.yes === true,
2297
+ });
2298
+ if (!proceed) {
2299
+ console.log("Aborted. Nothing was published.");
2300
+ process.exitCode = 1;
2301
+ return;
2302
+ }
1821
2303
  if (opts.exportDir) {
1822
2304
  const exportDir = (0, path_1.resolve)(opts.exportDir);
1823
2305
  const metadataPath = (0, path_1.join)(exportDir, "metadata.json");
1824
2306
  if (!(0, fs_1.existsSync)(metadataPath))
1825
2307
  throw new Error(`No metadata.json found in ${exportDir}. Run: npx expo export --output-dir ${opts.exportDir}`);
1826
2308
  const metadataJson = readJsonFile(metadataPath);
1827
- const configJson = (0, fs_1.existsSync)((0, path_1.resolve)(opts.expoConfig)) ? readJsonFile((0, path_1.resolve)(opts.expoConfig)) : {};
2309
+ if (!(0, fs_1.existsSync)((0, path_1.resolve)(opts.expoConfig)))
2310
+ throw new Error(`Expo config not found: ${(0, path_1.resolve)(opts.expoConfig)}`);
2311
+ const configJson = readJsonFile((0, path_1.resolve)(opts.expoConfig));
1828
2312
  const expoConfig = configJson.expo ?? configJson;
2313
+ (0, build_identity_1.assertUpdateMatchesApp)(expoConfig, releaseApp, opts.runtimeVersion);
1829
2314
  const publishId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1830
2315
  const selectedPlatforms = opts.platform === "all" ? ["ios", "android"] : [opts.platform];
2316
+ const ubriotExtra = (0, publish_signing_1.ubriotManifestExtra)(await (0, publish_signing_1.fetchAppIdentity)(client, opts.app), opts.app, opts.channel);
1831
2317
  for (const platform of selectedPlatforms) {
1832
2318
  if (platform !== "ios" && platform !== "android")
1833
2319
  throw new Error("--platform must be ios, android, or all");
@@ -1842,6 +2328,7 @@ updates
1842
2328
  expoConfig,
1843
2329
  metadataJson,
1844
2330
  publicApiBase,
2331
+ ubriotExtra,
1845
2332
  });
1846
2333
  if (payload)
1847
2334
  platforms.push(payload);
@@ -1850,10 +2337,16 @@ updates
1850
2337
  else {
1851
2338
  if (opts.manifestAndroid) {
1852
2339
  const manifestJson = (0, fs_1.readFileSync)(opts.manifestAndroid, "utf8");
2340
+ const manifest = JSON.parse(manifestJson);
2341
+ if (manifest?.extra?.expoClient)
2342
+ (0, build_identity_1.assertUpdateMatchesApp)(manifest.extra.expoClient, releaseApp, opts.runtimeVersion);
1853
2343
  platforms.push({ platform: "android", manifestJson, assets: [] });
1854
2344
  }
1855
2345
  if (opts.manifestIos) {
1856
2346
  const manifestJson = (0, fs_1.readFileSync)(opts.manifestIos, "utf8");
2347
+ const manifest = JSON.parse(manifestJson);
2348
+ if (manifest?.extra?.expoClient)
2349
+ (0, build_identity_1.assertUpdateMatchesApp)(manifest.extra.expoClient, releaseApp, opts.runtimeVersion);
1857
2350
  platforms.push({ platform: "ios", manifestJson, assets: [] });
1858
2351
  }
1859
2352
  }
@@ -1865,14 +2358,15 @@ updates
1865
2358
  channel: opts.channel,
1866
2359
  runtimeVersion: opts.runtimeVersion,
1867
2360
  message: opts.message,
1868
- platforms,
2361
+ platforms: (0, publish_signing_1.signPlatforms)(platforms, signing.materials),
1869
2362
  }, { timeout: 120000 });
1870
- console.log(`Published update group #${data.updateGroupId} on channel "${data.channel}" (${platforms.map((p) => p.platform).join(", ")})`);
2363
+ console.log(`Published update group #${data.updateGroupId} on channel "${data.channel}" (${platforms.map((p) => p.platform).join(", ")})${signing.materials.length ? `, signed with ${signing.materials.map((m) => m.keyid).join(", ")}` : ""}`);
1871
2364
  }
1872
2365
  catch (err) {
1873
2366
  (0, api_1.handleError)(err);
1874
2367
  }
1875
2368
  });
2369
+ (0, signing_commands_1.registerSigningCommands)(commander_1.program, updates);
1876
2370
  // ─── Migrate ─────────────────────────────────────────────────────────────────
1877
2371
  const migrate = commander_1.program.command("migrate").description("Migrate onto Ubriot from other services");
1878
2372
  migrate
@@ -1947,4 +2441,21 @@ migrate
1947
2441
  (0, api_1.handleError)(err);
1948
2442
  }
1949
2443
  });
2444
+ commander_1.program
2445
+ .command("ask <question>")
2446
+ .description("Ask the Release Copilot about your builds, failures, release health and readiness")
2447
+ .action(async (question) => {
2448
+ (0, config_1.requireAuth)();
2449
+ try {
2450
+ const { data } = await (0, api_1.makeClient)().post("/ubriot/copilot/chat", { message: question, history: [] });
2451
+ process.stdout.write(`\n${data.reply}\n`);
2452
+ if (Array.isArray(data.toolsUsed) && data.toolsUsed.length > 0) {
2453
+ const seen = Array.from(new Set(data.toolsUsed));
2454
+ process.stdout.write(`\nLooked at: ${seen.join(", ")}\n`);
2455
+ }
2456
+ }
2457
+ catch (err) {
2458
+ (0, api_1.handleError)(err);
2459
+ }
2460
+ });
1950
2461
  commander_1.program.parse(process.argv);