@catalyst-cloud/cli 0.14.6 → 0.14.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +7 -5
  3. package/dist/args.js +68 -18
  4. package/dist/browser.js +37 -3
  5. package/dist/cli.js +58 -12
  6. package/dist/config.js +17 -5
  7. package/dist/consent-browser.js +58 -0
  8. package/dist/daemon-authority-lifetime.js +171 -0
  9. package/dist/daemon-authority.js +216 -0
  10. package/dist/daemon-cache.js +348 -0
  11. package/dist/daemon-store.js +370 -0
  12. package/dist/daemon-supervisor.js +460 -0
  13. package/dist/env/onboard-draft-store.js +250 -0
  14. package/dist/env/onboard-draft.js +33 -0
  15. package/dist/env/selected-import-writer.js +163 -0
  16. package/dist/env/settings-authority.js +205 -0
  17. package/dist/events-cloud-tail.js +146 -0
  18. package/dist/events-cloud.js +619 -0
  19. package/dist/events.js +186 -31
  20. package/dist/oauth.js +129 -34
  21. package/dist/onboard-accounts.js +259 -0
  22. package/dist/onboard-automation-management.js +15 -0
  23. package/dist/onboard-bootstrap.js +162 -0
  24. package/dist/onboard-capabilities.js +15 -7
  25. package/dist/onboard-capacity.js +226 -0
  26. package/dist/onboard-existing.js +106 -32
  27. package/dist/onboard-file-snapshot.js +45 -0
  28. package/dist/onboard-github.js +14 -3
  29. package/dist/onboard-login-candidate.js +261 -0
  30. package/dist/onboard-next.js +139 -0
  31. package/dist/onboard-personal.js +14 -3
  32. package/dist/onboard-projects.js +350 -0
  33. package/dist/onboard-repositories.js +146 -37
  34. package/dist/onboard-runtime.js +108 -13
  35. package/dist/onboard-settings-context.js +141 -0
  36. package/dist/onboard-settings-import.js +127 -0
  37. package/dist/onboard-settings.js +234 -0
  38. package/dist/onboard-ui.js +85 -59
  39. package/dist/onboard-workflow.js +316 -0
  40. package/dist/onboard-workspace.js +14 -3
  41. package/dist/onboard.js +422 -39
  42. package/dist/owned-fetch.js +182 -0
  43. package/dist/pagination.js +13 -1
  44. package/dist/query.js +104 -31
  45. package/dist/ready.js +143 -35
  46. package/dist/replica.js +11 -4
  47. package/dist/repository-id.js +5 -0
  48. package/dist/sdk.js +33 -0
  49. package/dist/watch.js +2 -2
  50. package/package.json +3 -2
  51. package/skills/catalyst-github/SKILL.md +4 -2
  52. package/skills/catalyst-github/references/is-it-mergeable.md +1 -1
  53. package/skills/catalyst-github/references/local-replica.md +78 -0
  54. package/skills/catalyst-github/references/reading-from-the-cloud.md +24 -0
  55. package/skills/catalyst-github/references/what-a-pr-accumulates.md +1 -1
  56. package/skills/catalyst-github/scripts/is-it-mergeable.mjs +210 -45
  57. package/skills/catalyst-github/scripts/read-pr.mjs +51 -15
  58. package/skills/catalyst-linear/SKILL.md +5 -3
  59. package/skills/catalyst-linear/references/local-replica.md +91 -0
  60. package/skills/catalyst-linear/references/reading-a-ticket.md +3 -3
  61. package/skills/catalyst-linear/references/reading-from-the-cloud.md +38 -0
  62. package/skills/catalyst-linear/scripts/read-ticket.mjs +37 -12
  63. package/skills/catalyst-onboard/SKILL.md +6 -4
  64. package/skills/catalyst-onboard/references/local-replica.md +40 -0
  65. package/skills/catalyst-onboard/references/local-sync.md +8 -4
  66. package/skills/catalyst-onboard/scripts/local-sync.mjs +2 -2
  67. package/skills/catalyst-onboard/scripts/where-am-i.mjs +1 -1
  68. package/skills/run-this-project/SKILL.md +1 -1
  69. package/skills/unstick/SKILL.md +1 -1
  70. package/skills/what-needs-me/SKILL.md +1 -1
  71. package/skills/what-this-repo-needs/SKILL.md +1 -1
  72. package/skills/whats-happening/SKILL.md +4 -2
  73. package/skills/whats-happening/references/local-replica.md +51 -0
  74. package/skills/whats-happening/references/reading-from-the-cloud.md +21 -0
  75. package/skills/whats-happening/references/status-reply.md +1 -1
  76. package/skills/whats-happening/scripts/snapshot.mjs +104 -26
  77. package/vendor/settings/index.d.ts +4 -0
  78. package/vendor/settings/index.js +2937 -0
  79. package/vendor/settings/provenance.json +148 -0
package/dist/onboard.js CHANGED
@@ -5,7 +5,11 @@ import { createInterface } from "node:readline/promises";
5
5
  import { dirname, isAbsolute, join } from "node:path";
6
6
  import { parseMachinePaths, resolveCatalystPath, } from "../vendor/paths/index.js";
7
7
  import { machinePathsFile } from "../vendor/paths/node.js";
8
+ import { configPathFor } from "./config.js";
8
9
  import { CliError, UsageError } from "./errors.js";
10
+ import { onboardFileSnapshot } from "./onboard-file-snapshot.js";
11
+ import { onboardReasonText, onboardTeamKey, savedOnboardBaseUrl, } from "./onboard-next.js";
12
+ import { parseBootstrapPlan, bootstrapPlanHash, bootstrapPlanLines, } from "./onboard-bootstrap.js";
9
13
  export const ONBOARD_STEPS = [
10
14
  "machine",
11
15
  "cli",
@@ -30,6 +34,26 @@ export const ONBOARD_STEPS = [
30
34
  "first-ticket",
31
35
  "ready",
32
36
  ];
37
+ export function onboardIdentityLines(identity) {
38
+ if (!identity)
39
+ return [
40
+ "Use an existing Catalyst account or accept your invitation before approving sign-in.",
41
+ ];
42
+ const label = (text) => text
43
+ .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ")
44
+ .replace(/\s+/g, " ")
45
+ .trim()
46
+ .slice(0, 180);
47
+ const display = identity.display;
48
+ const person = label(display?.personLabel || identity.membershipId);
49
+ const email = display?.email ? label(display.email) : "";
50
+ const workspace = label(display?.workspaceName || identity.account);
51
+ const slug = display?.workspaceSlug ? label(display.workspaceSlug) : "";
52
+ return [
53
+ `Signed in as ${person}${email ? ` (${email})` : ""}`,
54
+ `Workspace: ${workspace}${slug ? ` (${slug})` : ""} · ${identity.role}`,
55
+ ];
56
+ }
33
57
  const EXIT_FAILED = 10;
34
58
  const EXIT_WAITING = 11;
35
59
  const EXIT_REFUSED = 12;
@@ -79,15 +103,14 @@ export const ONBOARD_DEPENDENCIES = {
79
103
  values: ["settings"],
80
104
  capacity: ["projects"],
81
105
  daemon: ["signin"],
106
+ // What dispatch needs. Environment approval gates only implement and pr, and the first phase
107
+ // needs no imported values, so repository settings and local sync do not hold a first ticket.
82
108
  "first-ticket": [
83
109
  "projects",
84
110
  "linear.adopt",
85
111
  "linear.automations",
86
112
  "accounts",
87
- "settings",
88
- "values",
89
113
  "capacity",
90
- "daemon",
91
114
  ],
92
115
  };
93
116
  const ADMIN_STEPS = new Set([
@@ -519,19 +542,27 @@ function planJournal(journal) {
519
542
  }),
520
543
  };
521
544
  }
522
- function printPlan(ctx, journal) {
545
+ function printPlan(ctx, journal, identity) {
523
546
  const value = planJournal(journal);
524
547
  ctx.stdout("Catalyst setup plan");
548
+ if (identity !== undefined)
549
+ for (const line of onboardIdentityLines(identity))
550
+ ctx.stdout(line);
525
551
  for (const step of value.steps)
526
552
  ctx.stdout(` ${step.state === "done" ? "✓" : "·"} ${ONBOARD_TITLES[step.id]}`);
553
+ ctx.stdout("Use cloud reads by default. Local sync is optional for SQL or sustained local reads.");
554
+ ctx.stdout("Setup may check one stored Claude account using a one-token provider request. This may use Claude quota. It does not refresh Codex credentials.");
527
555
  ctx.stdout(`Next: ${value.steps.find((step) => step.state === "pending")?.id ?? "ready"}`);
528
556
  }
529
557
  async function confirmContinue(ctx, args, deps) {
530
558
  if (args.flags.yes === true)
531
559
  return true;
532
- if (!(deps.isTty ?? (() => Boolean(process.stdin.isTTY && process.stdout.isTTY)))()) {
533
- ctx.stderr("No terminal is attached; using the default answer Yes. Pass --dry-run to inspect without changes.");
534
- return true;
560
+ if (args.json ||
561
+ !(deps.isTty ?? (() => Boolean(process.stdin.isTTY && process.stdout.isTTY)))()) {
562
+ ctx.stderr(args.json
563
+ ? "JSON mode needs --yes to accept the displayed setup plan. Pass --dry-run to inspect it."
564
+ : "No terminal is attached. Pass --yes to accept the displayed setup plan, or --dry-run to inspect it.");
565
+ return false;
535
566
  }
536
567
  if (deps.confirm)
537
568
  return deps.confirm("Continue? [Y/n] ");
@@ -569,7 +600,44 @@ function stepSatisfied(step) {
569
600
  (step?.state === "skipped" &&
570
601
  (step.id === "legacy" ||
571
602
  step.reason === "member_scope" ||
572
- (step.id === "daemon" && step.reason === "local_sync_not_selected"))));
603
+ (step.id === "daemon" && step.reason === "local_sync_not_selected") ||
604
+ (step.id === "linear.automations" &&
605
+ step.reason === "automation_management_unavailable"))));
606
+ }
607
+ /** Steps whose wait does not hold a full run's exit code: work runs without them. A failure still
608
+ * fails the run, and the receipt's `complete` still needs them satisfied. Never used for gating. */
609
+ export const ONBOARD_DEFERRED_STEPS = new Set([
610
+ "settings",
611
+ "values",
612
+ "housekeeping",
613
+ "first-ticket",
614
+ ]);
615
+ /** A full run that exited 0 with deferred steps still open: ready for work, not complete. */
616
+ export function onboardReadyForWork(journal, only) {
617
+ return !only && journal.exit === 0 && !journal.complete;
618
+ }
619
+ /** A recorded step's next action; a prerequisite wait names the first unfinished step it needs. */
620
+ export function onboardStepAction(journal, step, baseUrl) {
621
+ if (!step.reason)
622
+ return "Not checked yet. Run catalyst onboard.";
623
+ const waitsFor = journal &&
624
+ (ONBOARD_DEPENDENCIES[step.id] ?? []).find((parent) => !stepSatisfied(journalStep(journal, parent)));
625
+ return onboardReasonText(step, {
626
+ baseUrl,
627
+ journal,
628
+ ...(waitsFor ? { waitsFor: ONBOARD_TITLES[waitsFor] } : {}),
629
+ });
630
+ }
631
+ /** "<title>: <action>" for each recorded step still blocking onboarding, then the first-ticket hint
632
+ * once the steps a ticket needs are done. Display only: completeness and exits stay with the engine. */
633
+ export function onboardNextActions(journal, baseUrl, only) {
634
+ const lines = journal.steps
635
+ .filter((step) => (!only || step.id === only) && !stepSatisfied(step))
636
+ .sort((a, b) => ONBOARD_STEPS.indexOf(a.id) - ONBOARD_STEPS.indexOf(b.id))
637
+ .map((step) => `${ONBOARD_TITLES[step.id]}: ${onboardStepAction(journal, step, baseUrl)}`);
638
+ if (["projects", "accounts", "linear.adopt", "capacity"].every((id) => journalStep(journal, id)?.state === "done"))
639
+ lines.push(`Move a ticket in ${onboardTeamKey(journal) ?? "<TEAM KEY>"} to Todo; \`catalyst explain <ticket>\` says why it is or is not starting.`);
640
+ return lines;
573
641
  }
574
642
  function requireMatchingIdentity(journal, identity) {
575
643
  const account = journal.account ?? journal.tenant;
@@ -604,14 +672,72 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
604
672
  for (const id of [only, resumeFrom])
605
673
  if (id && !ONBOARD_STEPS.includes(id))
606
674
  throw new UsageError(`unknown onboarding step: ${id}`);
675
+ const bootstrap = deps.bootstrap;
676
+ const bootstrapPlan = bootstrap
677
+ ? parseBootstrapPlan(bootstrap.plan)
678
+ : undefined;
679
+ const reviewedBootstrapHash = bootstrapPlan
680
+ ? bootstrapPlanHash(bootstrapPlan)
681
+ : undefined;
682
+ if (bootstrapPlan &&
683
+ (only ||
684
+ resumeFrom ||
685
+ bootstrapPlan.home !== ctx.home ||
686
+ bootstrapPlan.statePath !== onboardStatePath(ctx.home, ctx.env)))
687
+ throw new CliError("The staged installation belongs to another setup path. Run setup again.", "onboard-bootstrap-binding", EXIT_REFUSED);
688
+ const requireBootstrapIdentity = (identity) => {
689
+ if (bootstrapPlan &&
690
+ (!identity || identity.baseUrl !== bootstrapPlan.origin))
691
+ throw new CliError("The staged installation and verified person use different clouds. Run setup again.", "onboard-bootstrap-binding", EXIT_REFUSED);
692
+ };
693
+ const showBootstrap = () => {
694
+ if (!bootstrapPlan)
695
+ return;
696
+ for (const line of bootstrapPlanLines(bootstrapPlan)) {
697
+ if (deps.ui)
698
+ deps.ui.message(line);
699
+ else
700
+ ctx.stderr(line);
701
+ }
702
+ };
703
+ const recheckBootstrap = async () => {
704
+ if (!bootstrap || !bootstrapPlan)
705
+ return;
706
+ if (bootstrapPlanHash(bootstrap.plan) !== reviewedBootstrapHash)
707
+ throw new CliError("The installation plan changed during review. Run setup again.", "onboard-bootstrap-changed", EXIT_WAITING);
708
+ await bootstrap.recheck();
709
+ if (bootstrapPlanHash(bootstrap.plan) !== reviewedBootstrapHash)
710
+ throw new CliError("The installation plan changed during review. Run setup again.", "onboard-bootstrap-changed", EXIT_WAITING);
711
+ };
607
712
  const statePath = onboardStatePath(ctx.home, ctx.env);
713
+ const reviewedReceipt = deps.stageSignin
714
+ ? onboardFileSnapshot(statePath)
715
+ : undefined;
716
+ const scopeNeedsSignin = (id) => id === "signin" || (ONBOARD_DEPENDENCIES[id] ?? []).some(scopeNeedsSignin);
717
+ const personalScope = !only || scopeNeedsSignin(only);
718
+ const plainSigninAllowed = personalScope &&
719
+ (args.flags.yes === true ||
720
+ (!args.json &&
721
+ (deps.isTty ??
722
+ (() => Boolean(process.stdin.isTTY && process.stdout.isTTY)))()));
608
723
  let journal = readOnboardJournal(statePath, cliVersion, (deps.now ?? ctx.now)()) ??
609
724
  freshJournal(ctx, cliVersion, deps);
725
+ const originalBinding = {
726
+ account: journal.account,
727
+ tenant: journal.tenant,
728
+ membershipId: journal.membershipId,
729
+ baseUrl: journal.baseUrl,
730
+ };
731
+ const wasBound = Boolean(journal.account ||
732
+ journal.tenant ||
733
+ journal.membershipId ||
734
+ journal.baseUrl);
610
735
  if (args.flags["dry-run"] === true) {
611
736
  if (args.json)
612
737
  ctx.stdout(JSON.stringify(planJournal(journal)));
613
738
  else
614
739
  printPlan(ctx, journal);
740
+ showBootstrap();
615
741
  return 0;
616
742
  }
617
743
  // Check tenant binding before even a local cleanup mutation. Never overwrite a mismatched receipt.
@@ -623,36 +749,153 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
623
749
  }
624
750
  }
625
751
  catch (error) {
626
- const code = error instanceof CliError ? error.exitCode : EXIT_FAILED;
627
- ctx.stderr("Could not verify your Catalyst membership. Setup was not changed. Sign in with the original account to resume.");
628
- if (args.json)
629
- ctx.stdout(JSON.stringify({
630
- ...journal,
631
- mode: "run",
632
- exit: code,
633
- complete: false,
634
- }));
635
- return code;
752
+ const canRenew = error instanceof CliError &&
753
+ error.code === "onboard-login-refresh-required" &&
754
+ deps.stageSignin &&
755
+ (deps.ui || plainSigninAllowed);
756
+ if (!canRenew) {
757
+ const code = error instanceof CliError ? error.exitCode : EXIT_FAILED;
758
+ ctx.stderr(error instanceof CliError &&
759
+ error.code === "onboard-login-refresh-required"
760
+ ? "Renew your login with catalyst login, then run catalyst onboard. Setup was not changed."
761
+ : "Could not verify your Catalyst membership. Setup was not changed. Sign in with the original account to resume.");
762
+ if (args.json)
763
+ ctx.stdout(JSON.stringify({
764
+ ...journal,
765
+ mode: "run",
766
+ exit: code,
767
+ complete: false,
768
+ }));
769
+ return code;
770
+ }
771
+ const renewalMessage = "Your saved login needs renewal. Sign in before reviewing this plan.";
772
+ if (deps.ui)
773
+ deps.ui.message(renewalMessage);
774
+ else
775
+ ctx.stderr(renewalMessage);
636
776
  }
777
+ const identityTuple = (value) => value
778
+ ? Object.freeze({
779
+ account: value.account,
780
+ membershipId: value.membershipId,
781
+ baseUrl: value.baseUrl,
782
+ role: value.role,
783
+ })
784
+ : null;
785
+ let reviewedIdentity = identityTuple(identity);
786
+ if (identity)
787
+ requireBootstrapIdentity(identity);
637
788
  if (deps.ui)
638
- deps.ui.plan(journal);
789
+ deps.ui.plan(journal, identity);
639
790
  else if (!args.json)
640
- printPlan(ctx, journal);
641
- const inheritedConsent = resume === "install" && Boolean(ctx.env.CATALYST_INSTALL_LOCK_TOKEN);
791
+ printPlan(ctx, journal, identity);
792
+ else
793
+ printPlan({ ...ctx, stdout: ctx.stderr }, journal, identity);
794
+ showBootstrap();
642
795
  let localSync = args.flags["local-sync"] === true || journal.localSync === true;
643
- const consent = inheritedConsent
644
- ? true
796
+ let candidate = null;
797
+ // A fresh interactive sign-in needs browser approval, then one review of the verified
798
+ // person and workspace. Do not ask to approve a plan whose identity is still unknown.
799
+ if (!identity &&
800
+ deps.stageSignin &&
801
+ !deps.ui &&
802
+ personalScope &&
803
+ !plainSigninAllowed) {
804
+ ctx.stderr("Sign in with catalyst login, then run catalyst onboard, or use --yes to allow browser sign-in before the displayed setup plan.");
805
+ if (args.json)
806
+ ctx.stdout(JSON.stringify({ ...planJournal(journal), exit: EXIT_WAITING }));
807
+ return EXIT_WAITING;
808
+ }
809
+ const stageFirst = Boolean(deps.stageSignin && !identity && (deps.ui || plainSigninAllowed));
810
+ if (stageFirst) {
811
+ const message = "Sign in in your browser first. Then review your person, workspace and setup plan. Your saved connection stays unchanged until you approve that plan.";
812
+ if (deps.ui)
813
+ deps.ui.message(message);
814
+ else
815
+ ctx.stderr(message);
816
+ }
817
+ let consent = stageFirst
818
+ ? { proceed: false, localSync, signin: true }
645
819
  : deps.ui
646
- ? await deps.ui.confirmPlan(localSync)
820
+ ? await deps.ui.confirmPlan(localSync, deps.stageSignin
821
+ ? identity
822
+ ? "saved"
823
+ : "required"
824
+ : "unavailable")
647
825
  : await confirmContinue(ctx, args, deps);
826
+ if (typeof consent !== "boolean" && consent.signin) {
827
+ if (!deps.stageSignin)
828
+ throw new Error("onboard_signin_stage_unavailable");
829
+ try {
830
+ const stagingSignals = [
831
+ ...(deps.ui ? [deps.ui.signal] : []),
832
+ ...(deps.signal ? [deps.signal] : []),
833
+ ];
834
+ const stagingSignal = stagingSignals.length > 1
835
+ ? AbortSignal.any(stagingSignals)
836
+ : stagingSignals[0];
837
+ if (stagingSignal?.aborted)
838
+ throw new CliError("Sign-in was cancelled. Your saved connection was not changed.", "onboard-signin-cancelled", EXIT_WAITING);
839
+ candidate = await deps.stageSignin(stagingSignal);
840
+ // A saved receipt may renew only its existing identity. A fresh preview can change it.
841
+ if (wasBound)
842
+ requireMatchingIdentity({ ...journal, ...originalBinding }, candidate.identity);
843
+ else
844
+ Object.assign(journal, {
845
+ account: undefined,
846
+ tenant: null,
847
+ membershipId: undefined,
848
+ baseUrl: undefined,
849
+ });
850
+ identity = candidate.identity;
851
+ reviewedIdentity = identityTuple(identity);
852
+ requireMatchingIdentity(journal, identity);
853
+ requireBootstrapIdentity(identity);
854
+ if (deps.ui)
855
+ deps.ui.plan(journal, identity);
856
+ else if (!args.json)
857
+ printPlan(ctx, journal, identity);
858
+ else
859
+ printPlan({ ...ctx, stdout: ctx.stderr }, journal, identity);
860
+ showBootstrap();
861
+ consent = deps.ui
862
+ ? await deps.ui.confirmPlan(localSync, "unavailable")
863
+ : await confirmContinue(ctx, args, deps);
864
+ }
865
+ catch (error) {
866
+ const message = error instanceof CliError
867
+ ? error.message
868
+ : "Sign-in could not be verified. Your saved connection was not changed.";
869
+ if (deps.ui)
870
+ deps.ui.message(message);
871
+ else
872
+ ctx.stderr(message);
873
+ deps.ui?.dispose();
874
+ const code = error instanceof CliError ? error.exitCode : EXIT_WAITING;
875
+ if (args.json)
876
+ ctx.stdout(JSON.stringify({ ...planJournal(journal), exit: code }));
877
+ return code;
878
+ }
879
+ }
648
880
  if (typeof consent !== "boolean")
649
881
  localSync = consent.localSync;
650
882
  if (!(typeof consent === "boolean" ? consent : consent.proceed)) {
651
- ctx.stderr("Nothing was changed. Run the same command when ready.");
883
+ const missingConsent = !deps.ui &&
884
+ args.flags.yes !== true &&
885
+ (args.json ||
886
+ !(deps.isTty ??
887
+ (() => Boolean(process.stdin.isTTY && process.stdout.isTTY)))());
888
+ const code = deps.ui?.signal.aborted || missingConsent ? EXIT_WAITING : 0;
889
+ ctx.stderr(missingConsent
890
+ ? "Setup is waiting for your approval. Run with --yes to accept the displayed plan."
891
+ : "Nothing was changed. Run the same command when ready.");
652
892
  if (args.json)
653
- ctx.stdout(JSON.stringify({ ...journal, complete: false }));
654
- return deps.ui?.signal.aborted ? EXIT_WAITING : 0;
893
+ ctx.stdout(JSON.stringify({ ...journal, exit: code, complete: false }));
894
+ return code;
655
895
  }
896
+ if (bootstrap)
897
+ requireBootstrapIdentity(identity);
898
+ await recheckBootstrap();
656
899
  let lock;
657
900
  try {
658
901
  lock = acquireLock(ctx, deps);
@@ -685,8 +928,11 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
685
928
  })),
686
929
  };
687
930
  let current = null;
931
+ let mayRecordProgress = candidate === null;
688
932
  const interrupted = () => {
689
933
  stop.abort();
934
+ if (!mayRecordProgress)
935
+ return;
690
936
  if (current)
691
937
  setStep(journal, {
692
938
  id: current,
@@ -709,7 +955,10 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
709
955
  process.on(name, interrupted);
710
956
  const finish = (code) => {
711
957
  journal.exit = code;
712
- journal.complete = !only && code === 0;
958
+ journal.complete =
959
+ !only &&
960
+ code === 0 &&
961
+ ONBOARD_STEPS.every((id) => stepSatisfied(journalStep(journal, id)));
713
962
  writeOnboardJournal(statePath, journal);
714
963
  if (args.json)
715
964
  ctx.stdout(JSON.stringify(journal));
@@ -718,15 +967,54 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
718
967
  else {
719
968
  if (journal.complete)
720
969
  ctx.stdout("Onboarding complete.");
970
+ else if (onboardReadyForWork(journal, only)) {
971
+ ctx.stdout("Ready for work.");
972
+ ctx.stdout("Next, when you want:");
973
+ for (const line of onboardNextActions(journal, savedOnboardBaseUrl(ctx.home)))
974
+ ctx.stdout(line);
975
+ }
721
976
  else if (only && code === 0)
722
977
  ctx.stdout(`${ONBOARD_TITLES[only]} finished. Onboarding still has other steps.`);
723
- else
978
+ else {
724
979
  ctx.stdout(`Setup still needs ${journal.steps.filter((step) => !stepSatisfied(step)).length} checks.`);
725
- ctx.stdout("resume: catalyst onboard");
980
+ for (const line of onboardNextActions(journal, savedOnboardBaseUrl(ctx.home), only))
981
+ ctx.stdout(line);
982
+ }
983
+ ctx.stdout(bootstrap && journalStep(journal, "machine")?.state !== "done"
984
+ ? "resume: run the original setup command"
985
+ : "resume: catalyst onboard");
726
986
  }
727
987
  return code;
728
988
  };
729
989
  try {
990
+ await recheckBootstrap();
991
+ if (candidate) {
992
+ // Re-read while holding the receipt lock before accepting the staged connection. Config
993
+ // and receipt publication are ordered, not a promised transaction against other writers.
994
+ if (onboardFileSnapshot(statePath) !== reviewedReceipt)
995
+ throw new CliError("Your setup record changed during review. Run catalyst onboard again.", "onboard-receipt-changed", EXIT_WAITING);
996
+ const currentReceipt = readOnboardJournal(statePath, cliVersion, (deps.now ?? ctx.now)());
997
+ if (currentReceipt)
998
+ requireMatchingIdentity(currentReceipt, candidate.identity);
999
+ try {
1000
+ await candidate.accept(signal, () => {
1001
+ if (onboardFileSnapshot(statePath) !== reviewedReceipt)
1002
+ throw new CliError("Your setup record changed during sign-in. Run catalyst onboard again.", "onboard-receipt-changed", EXIT_WAITING);
1003
+ });
1004
+ }
1005
+ catch (error) {
1006
+ ctx.stderr(candidate.accepted
1007
+ ? "Your connection was accepted, but setup could not be recorded. Run catalyst onboard to resume."
1008
+ : error instanceof CliError
1009
+ ? error.message
1010
+ : "Sign-in was not accepted. Your saved connection was not changed.");
1011
+ if (args.json)
1012
+ ctx.stdout(JSON.stringify({ ...planJournal(journal), exit: EXIT_WAITING }));
1013
+ return EXIT_WAITING;
1014
+ }
1015
+ }
1016
+ if (candidate && onboardFileSnapshot(statePath) !== reviewedReceipt)
1017
+ throw new CliError("Your setup record changed during sign-in. Run catalyst onboard again.", "onboard-receipt-changed", EXIT_WAITING);
730
1018
  journal =
731
1019
  readOnboardJournal(statePath, cliVersion, (deps.now ?? ctx.now)()) ??
732
1020
  journal;
@@ -741,6 +1029,82 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
741
1029
  setStep(journal, { id, state: "pending" });
742
1030
  journal.steps.sort((a, b) => ONBOARD_STEPS.indexOf(a.id) - ONBOARD_STEPS.indexOf(b.id));
743
1031
  journal.operations ??= {};
1032
+ if (candidate) {
1033
+ writeOnboardJournal(statePath, journal);
1034
+ mayRecordProgress = true;
1035
+ }
1036
+ if (bootstrap && bootstrapPlan && reviewedBootstrapHash) {
1037
+ if (!identity || identity.baseUrl !== bootstrapPlan.origin)
1038
+ throw new CliError("The staged installation and verified person use different clouds. Run setup again.", "onboard-bootstrap-binding", EXIT_REFUSED);
1039
+ if (signal.aborted)
1040
+ return finish(EXIT_WAITING);
1041
+ await recheckBootstrap();
1042
+ if (!deps.identity || !reviewedIdentity)
1043
+ throw new CliError("The verified person could not be checked before installation. Run setup again.", "onboard-bootstrap-identity", EXIT_WAITING);
1044
+ const beforeConfig = onboardFileSnapshot(configPathFor(ctx.home));
1045
+ const freshIdentity = await deps.identity(journal);
1046
+ if (!freshIdentity ||
1047
+ beforeConfig !== onboardFileSnapshot(configPathFor(ctx.home)) ||
1048
+ freshIdentity.account !== reviewedIdentity.account ||
1049
+ freshIdentity.membershipId !== reviewedIdentity.membershipId ||
1050
+ freshIdentity.baseUrl !== reviewedIdentity.baseUrl ||
1051
+ freshIdentity.role !== reviewedIdentity.role)
1052
+ throw new CliError("Your person or workspace changed during review. Run setup again.", "onboard-bootstrap-identity", EXIT_REFUSED);
1053
+ requireMatchingIdentity(journal, freshIdentity);
1054
+ identity = freshIdentity;
1055
+ if (signal.aborted)
1056
+ return finish(EXIT_WAITING);
1057
+ bootstrap.assertCurrent();
1058
+ // The capability stays in native memory; the child protocol must transport it over a private FD.
1059
+ // The continuation contract joins owned children before returning, so finally cannot release early.
1060
+ current = "machine";
1061
+ setStep(journal, {
1062
+ id: "machine",
1063
+ state: "running",
1064
+ at: isoNow(ctx, deps),
1065
+ });
1066
+ writeOnboardJournal(statePath, journal);
1067
+ try {
1068
+ await bootstrap.continue(Object.freeze({
1069
+ plan: bootstrapPlan,
1070
+ planHash: reviewedBootstrapHash,
1071
+ account: identity.account,
1072
+ person: identity.membershipId,
1073
+ origin: identity.baseUrl,
1074
+ role: identity.role,
1075
+ localSync,
1076
+ runId: journal.runId,
1077
+ lockPath: lock.path,
1078
+ ownerPid: lock.owner.pid,
1079
+ ownerToken: lock.owner.token,
1080
+ }), signal);
1081
+ }
1082
+ catch {
1083
+ setStep(journal, {
1084
+ id: "machine",
1085
+ state: signal.aborted ? "waiting" : "failed",
1086
+ reason: signal.aborted ? "interrupted" : "bootstrap_install_failed",
1087
+ at: isoNow(ctx, deps),
1088
+ });
1089
+ ctx.stderr("Installation stopped. Completed files remain. Run the original setup command to resume.");
1090
+ return finish(signal.aborted ? EXIT_WAITING : EXIT_FAILED);
1091
+ }
1092
+ current = null;
1093
+ if (signal.aborted) {
1094
+ setStep(journal, {
1095
+ id: "machine",
1096
+ state: "waiting",
1097
+ reason: "interrupted",
1098
+ at: isoNow(ctx, deps),
1099
+ });
1100
+ return finish(EXIT_WAITING);
1101
+ }
1102
+ if (deps.identity) {
1103
+ const refreshed = await deps.identity(journal);
1104
+ requireMatchingIdentity(journal, refreshed);
1105
+ identity = refreshed;
1106
+ }
1107
+ }
744
1108
  let refused = false;
745
1109
  const needed = new Set();
746
1110
  const include = (id) => {
@@ -769,7 +1133,7 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
769
1133
  reason: "member_scope",
770
1134
  at: isoNow(ctx, deps),
771
1135
  });
772
- deps.ui?.stepEnd(journalStep(journal, id));
1136
+ deps.ui?.stepEnd(journalStep(journal, id), journal);
773
1137
  continue;
774
1138
  }
775
1139
  const missing = (ONBOARD_DEPENDENCIES[id] ?? []).find((parent) => !stepSatisfied(journalStep(journal, parent)));
@@ -781,7 +1145,7 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
781
1145
  at: isoNow(ctx, deps),
782
1146
  });
783
1147
  writeOnboardJournal(statePath, journal);
784
- deps.ui?.stepEnd(journalStep(journal, id));
1148
+ deps.ui?.stepEnd(journalStep(journal, id), journal);
785
1149
  continue;
786
1150
  }
787
1151
  let adapter = deps.adapters?.[id];
@@ -805,7 +1169,7 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
805
1169
  at: isoNow(ctx, deps),
806
1170
  });
807
1171
  writeOnboardJournal(statePath, journal);
808
- deps.ui?.stepEnd(journalStep(journal, id));
1172
+ deps.ui?.stepEnd(journalStep(journal, id), journal);
809
1173
  continue;
810
1174
  }
811
1175
  try {
@@ -825,7 +1189,7 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
825
1189
  at: isoNow(ctx, deps),
826
1190
  });
827
1191
  writeOnboardJournal(statePath, journal);
828
- deps.ui?.stepEnd(journalStep(journal, id));
1192
+ deps.ui?.stepEnd(journalStep(journal, id), journal);
829
1193
  continue;
830
1194
  }
831
1195
  }
@@ -852,7 +1216,7 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
852
1216
  at: isoNow(ctx, deps),
853
1217
  });
854
1218
  writeOnboardJournal(statePath, journal);
855
- deps.ui?.stepEnd(journalStep(journal, id));
1219
+ deps.ui?.stepEnd(journalStep(journal, id), journal);
856
1220
  continue;
857
1221
  }
858
1222
  }
@@ -862,6 +1226,9 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
862
1226
  requireMatchingIdentity(journal, identity);
863
1227
  if (!identity)
864
1228
  result = { state: "failed", reason: "membership_not_verified" };
1229
+ else
1230
+ for (const line of onboardIdentityLines(identity))
1231
+ stepCtx.stdout(line);
865
1232
  }
866
1233
  if (result.state === "refused")
867
1234
  refused = true;
@@ -882,13 +1249,17 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
882
1249
  /^[a-z][a-z0-9_-]{1,63}$/.test(error.code)
883
1250
  ? error.code.replaceAll("-", "_")
884
1251
  : "step_failed";
1252
+ const renewLogin = error instanceof CliError &&
1253
+ error.code === "onboard-login-refresh-required";
885
1254
  setStep(journal, {
886
1255
  id,
887
- state: "failed",
1256
+ state: renewLogin ? "waiting" : "failed",
888
1257
  reason,
889
1258
  at: isoNow(ctx, deps),
890
1259
  });
891
- stepCtx.stderr(`✗ ${ONBOARD_TITLES[id]}: ${reason}. The safe reason is saved; run the same command to resume.`);
1260
+ stepCtx.stderr(renewLogin
1261
+ ? "Renew your login with catalyst login, then run catalyst onboard to resume."
1262
+ : `✗ ${ONBOARD_TITLES[id]}: ${reason}. The safe reason is saved; run the same command to resume.`);
892
1263
  }
893
1264
  if (signal.aborted) {
894
1265
  setStep(journal, {
@@ -899,8 +1270,11 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
899
1270
  });
900
1271
  return finish(EXIT_WAITING);
901
1272
  }
902
- deps.ui?.stepEnd(journalStep(journal, id));
1273
+ deps.ui?.stepEnd(journalStep(journal, id), journal);
903
1274
  writeOnboardJournal(statePath, journal);
1275
+ // Later steps share the same login; preserve the actionable cause instead of cascading failures.
1276
+ if (journalStep(journal, id)?.reason?.endsWith("_login_refresh_required"))
1277
+ return finish(EXIT_WAITING);
904
1278
  if (refused)
905
1279
  break;
906
1280
  }
@@ -910,15 +1284,24 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
910
1284
  ? EXIT_REFUSED
911
1285
  : scope.some((id) => journalStep(journal, id)?.state === "failed")
912
1286
  ? EXIT_FAILED
913
- : scope.every((id) => stepSatisfied(journalStep(journal, id)))
1287
+ : scope.every((id) => stepSatisfied(journalStep(journal, id)) ||
1288
+ (!only && ONBOARD_DEFERRED_STEPS.has(id)))
914
1289
  ? 0
915
1290
  : EXIT_WAITING;
916
1291
  return finish(code);
917
1292
  }
918
1293
  catch (error) {
1294
+ if (candidate?.accepted && !mayRecordProgress) {
1295
+ ctx.stderr("Your connection was accepted, but setup could not be recorded. Run catalyst onboard to resume.");
1296
+ if (args.json)
1297
+ ctx.stdout(JSON.stringify({ ...planJournal(journal), exit: EXIT_WAITING }));
1298
+ return EXIT_WAITING;
1299
+ }
919
1300
  // Identity can change while the lock is held; stop before the next action.
920
1301
  if (error instanceof CliError) {
921
1302
  ctx.stderr(error.message);
1303
+ if (args.json)
1304
+ ctx.stdout(JSON.stringify({ ...planJournal(journal), exit: error.exitCode }));
922
1305
  return error.exitCode;
923
1306
  }
924
1307
  throw error;