@catalyst-cloud/cli 0.14.9 → 0.15.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +41 -0
  3. package/dist/args.js +34 -2
  4. package/dist/capabilities.js +2 -1
  5. package/dist/cli-update-notice.js +76 -0
  6. package/dist/cli.js +267 -94
  7. package/dist/oauth.js +80 -18
  8. package/dist/onboard-accounts.js +34 -5
  9. package/dist/onboard-automation-management.js +27 -9
  10. package/dist/onboard-capacity.js +10 -1
  11. package/dist/onboard-existing.js +132 -31
  12. package/dist/onboard-github.js +171 -6
  13. package/dist/onboard-headless.js +647 -0
  14. package/dist/onboard-interrupts.js +41 -0
  15. package/dist/onboard-next.js +215 -0
  16. package/dist/onboard-permissions.js +100 -0
  17. package/dist/onboard-personal.js +46 -10
  18. package/dist/onboard-runner-cloud.js +213 -0
  19. package/dist/onboard-runner-egress.js +67 -0
  20. package/dist/onboard-runner.js +780 -0
  21. package/dist/onboard-runtime.js +48 -8
  22. package/dist/onboard-standalone-copy.js +110 -0
  23. package/dist/onboard-team-create.js +267 -0
  24. package/dist/onboard-ui.js +623 -29
  25. package/dist/onboard-workflow.js +311 -168
  26. package/dist/onboard-workspace.js +52 -5
  27. package/dist/onboard.js +359 -207
  28. package/dist/setup-events.js +100 -0
  29. package/dist/setup-onboard-copy.js +438 -0
  30. package/dist/setup-prompt-frame.js +94 -0
  31. package/dist/setup-prompts.js +108 -0
  32. package/dist/setup-render.js +339 -0
  33. package/dist/setup-signin.js +187 -0
  34. package/dist/setup.js +426 -0
  35. package/package.json +7 -3
  36. package/skills/catalyst-github/SKILL.md +1 -1
  37. package/skills/catalyst-linear/SKILL.md +1 -1
  38. package/skills/catalyst-onboard/SKILL.md +3 -2
  39. package/skills/run-this-project/SKILL.md +1 -1
  40. package/skills/unstick/SKILL.md +1 -1
  41. package/skills/what-needs-me/SKILL.md +1 -1
  42. package/skills/what-this-repo-needs/SKILL.md +1 -1
  43. package/skills/whats-happening/SKILL.md +1 -1
  44. package/vendor/README.md +4 -0
  45. package/vendor/self-host/compose.yaml +110 -0
  46. package/vendor/self-host/linux-session-egress/catalyst-session-egress-attest.service +14 -0
  47. package/vendor/self-host/linux-session-egress/catalyst-session-egress-attest.timer +10 -0
  48. package/vendor/self-host/linux-session-egress/catalyst-session-egress.service +19 -0
  49. package/vendor/self-host/linux-session-egress/session-egress.py +291 -0
  50. package/vendor/self-host/provenance.json +11 -0
package/dist/onboard.js CHANGED
@@ -6,6 +6,8 @@ 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
8
  import { configPathFor } from "./config.js";
9
+ import { onboardJsonView } from "./setup-onboard-copy.js";
10
+ import { standalonePlan, standalonePlanNotes, } from "./onboard-standalone-copy.js";
9
11
  import { CliError, UsageError } from "./errors.js";
10
12
  import { onboardFileSnapshot } from "./onboard-file-snapshot.js";
11
13
  import { onboardReasonText, onboardTeamKey, savedOnboardBaseUrl, } from "./onboard-next.js";
@@ -16,6 +18,8 @@ export const ONBOARD_STEPS = [
16
18
  "skills",
17
19
  "legacy",
18
20
  "signin",
21
+ "daemon",
22
+ "housekeeping",
19
23
  "linear.workspace",
20
24
  "linear.personal",
21
25
  "linear.team",
@@ -26,11 +30,10 @@ export const ONBOARD_STEPS = [
26
30
  "github.repos",
27
31
  "projects",
28
32
  "accounts",
33
+ "capacity",
34
+ "runner",
29
35
  "settings",
30
36
  "values",
31
- "capacity",
32
- "daemon",
33
- "housekeeping",
34
37
  "first-ticket",
35
38
  "ready",
36
39
  ];
@@ -102,6 +105,8 @@ export const ONBOARD_DEPENDENCIES = {
102
105
  settings: ["projects"],
103
106
  values: ["settings"],
104
107
  capacity: ["projects"],
108
+ // The join token names the selected team's pool.
109
+ runner: ["linear.team"],
105
110
  daemon: ["signin"],
106
111
  // What dispatch needs. Environment approval gates only implement and pr, and the first phase
107
112
  // needs no imported values, so repository settings and local sync do not hold a first ticket.
@@ -123,6 +128,7 @@ const ADMIN_STEPS = new Set([
123
128
  "settings",
124
129
  "values",
125
130
  "capacity",
131
+ "runner",
126
132
  "first-ticket",
127
133
  ]);
128
134
  export const ONBOARD_TITLES = {
@@ -144,6 +150,7 @@ export const ONBOARD_TITLES = {
144
150
  settings: "Review repository settings",
145
151
  values: "Import selected local values",
146
152
  capacity: "Check runner capacity",
153
+ runner: "Run Catalyst's work on this machine",
147
154
  daemon: "Check optional local sync",
148
155
  housekeeping: "Schedule the daily update",
149
156
  "first-ticket": "Start a first ticket",
@@ -215,6 +222,8 @@ function normalizeStep(value, fallbackAt) {
215
222
  if (!state)
216
223
  return null;
217
224
  const step = { id, state };
225
+ if (state === "failed" && row.refused === true)
226
+ step.refused = true;
218
227
  if (typeof row.at === "string")
219
228
  step.at = row.at;
220
229
  else if (typeof row.updatedAt === "string")
@@ -263,6 +272,21 @@ function normalizeStep(value, fallbackAt) {
263
272
  "passed",
264
273
  "team",
265
274
  "teamKey",
275
+ "selected",
276
+ "hostName",
277
+ "hostId",
278
+ "capacity",
279
+ "failing",
280
+ "image",
281
+ "automations",
282
+ "workspaceSlug",
283
+ // CTC-4629: an outdated connection's grant, scopes, action URL and who takes it.
284
+ "grant",
285
+ "granted",
286
+ "missing",
287
+ "org",
288
+ "url",
289
+ "actor",
266
290
  ]) {
267
291
  const item = evidence?.[key];
268
292
  if (item === null ||
@@ -522,13 +546,13 @@ function freshJournal(ctx, cliVersion, deps) {
522
546
  }
523
547
  /** A refusal before loading state must not reuse or copy an unreadable receipt. */
524
548
  export function onboardErrorJournal(ctx, cliVersion, exit) {
525
- return {
549
+ return onboardJsonView({
526
550
  ...freshJournal(ctx, cliVersion, {}),
527
551
  scope: "onboarding",
528
552
  mode: "run",
529
553
  exit,
530
554
  complete: false,
531
- };
555
+ }, savedOnboardBaseUrl(ctx.home));
532
556
  }
533
557
  function planJournal(journal) {
534
558
  return {
@@ -542,17 +566,21 @@ function planJournal(journal) {
542
566
  }),
543
567
  };
544
568
  }
545
- function printPlan(ctx, journal, identity) {
546
- const value = planJournal(journal);
569
+ function printPlan(ctx, journal, identity, options) {
547
570
  ctx.stdout("Catalyst setup plan");
548
571
  if (identity !== undefined)
549
572
  for (const line of onboardIdentityLines(identity))
550
573
  ctx.stdout(line);
551
- for (const step of value.steps)
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.");
555
- ctx.stdout(`Next: ${value.steps.find((step) => step.state === "pending")?.id ?? "ready"}`);
574
+ let group;
575
+ for (const row of standalonePlan(journal, options?.scope, options?.runner)) {
576
+ if (row.group !== group) {
577
+ ctx.stdout("");
578
+ ctx.stdout(row.group);
579
+ group = row.group;
580
+ }
581
+ ctx.stdout(` ${row.number} ${row.title} ${row.detail}`);
582
+ }
583
+ ctx.stdout(standalonePlanNotes(options?.localSync ?? journal.localSync === true));
556
584
  }
557
585
  async function confirmContinue(ctx, args, deps) {
558
586
  if (args.flags.yes === true)
@@ -591,16 +619,18 @@ function sanitizedResult(id, result, at) {
591
619
  id,
592
620
  ...result,
593
621
  state: result.state === "refused" ? "failed" : result.state,
622
+ ...(result.state === "refused" ? { refused: true } : {}),
594
623
  }, at);
595
624
  row.at = at;
596
625
  return row;
597
626
  }
598
- function stepSatisfied(step) {
627
+ export function stepSatisfied(step) {
599
628
  return (step?.state === "done" ||
600
629
  (step?.state === "skipped" &&
601
630
  (step.id === "legacy" ||
602
631
  step.reason === "member_scope" ||
603
632
  (step.id === "daemon" && step.reason === "local_sync_not_selected") ||
633
+ (step.id === "runner" && step.reason === "runner_not_selected") ||
604
634
  (step.id === "linear.automations" &&
605
635
  step.reason === "automation_management_unavailable"))));
606
636
  }
@@ -609,6 +639,8 @@ function stepSatisfied(step) {
609
639
  export const ONBOARD_DEFERRED_STEPS = new Set([
610
640
  "settings",
611
641
  "values",
642
+ // Optional: work also runs on the workspace's other runner hosts.
643
+ "runner",
612
644
  "housekeeping",
613
645
  "first-ticket",
614
646
  ]);
@@ -616,7 +648,8 @@ export const ONBOARD_DEFERRED_STEPS = new Set([
616
648
  * check is that same wait, so readiness waits too instead of reporting a new failure. */
617
649
  export function onboardRequiredStepsWaiting(journal) {
618
650
  return !!journal?.steps.some((step) => step.id !== "ready" &&
619
- !ONBOARD_DEFERRED_STEPS.has(step.id) &&
651
+ (!ONBOARD_DEFERRED_STEPS.has(step.id) ||
652
+ (step.id === "runner" && step.evidence?.selected === true)) &&
620
653
  step.state !== "failed" &&
621
654
  !stepSatisfied(step));
622
655
  }
@@ -664,8 +697,32 @@ function requireMatchingIdentity(journal, identity) {
664
697
  export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
665
698
  if (args.subcommand !== null || args.rest.length > 0)
666
699
  throw new UsageError("onboard takes no positional arguments");
667
- if (args.json || args.flags.yes === true || args.flags["dry-run"] === true)
700
+ if (args.json ||
701
+ (args.flags.yes === true &&
702
+ !deps.reviewedSetup &&
703
+ deps.ui?.interactive !== false) ||
704
+ args.flags["dry-run"] === true)
668
705
  deps = { ...deps, ui: undefined };
706
+ const interactiveUi = Boolean(deps.ui && deps.ui.interactive !== false);
707
+ let activeRunSignal;
708
+ if (args.json) {
709
+ const output = ctx.stdout;
710
+ ctx = {
711
+ ...ctx,
712
+ stdout: (line) => {
713
+ const value = JSON.parse(line);
714
+ const paused = Boolean(activeRunSignal?.aborted ||
715
+ deps.signal?.aborted ||
716
+ deps.ui?.signal.aborted);
717
+ output(JSON.stringify(onboardJsonView(value, savedOnboardBaseUrl(ctx.home), paused, {
718
+ requiredSteps: args.flags.runner === true ? ["runner"] : [],
719
+ only: typeof args.flags.only === "string"
720
+ ? args.flags.only
721
+ : undefined,
722
+ })));
723
+ },
724
+ };
725
+ }
669
726
  const only = typeof args.flags.only === "string"
670
727
  ? args.flags.only
671
728
  : undefined;
@@ -680,6 +737,24 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
680
737
  for (const id of [only, resumeFrom])
681
738
  if (id && !ONBOARD_STEPS.includes(id))
682
739
  throw new UsageError(`unknown onboarding step: ${id}`);
740
+ const reviewedScope = new Set();
741
+ const includeReviewed = (id) => {
742
+ if (reviewedScope.has(id))
743
+ return;
744
+ reviewedScope.add(id);
745
+ for (const dependency of ONBOARD_DEPENDENCIES[id] ?? [])
746
+ includeReviewed(dependency);
747
+ };
748
+ if (only)
749
+ includeReviewed(only);
750
+ else
751
+ for (const id of ONBOARD_STEPS)
752
+ reviewedScope.add(id);
753
+ const planOptions = (journal) => ({
754
+ runner: args.flags.runner === true,
755
+ localSync: args.flags["local-sync"] === true || journal.localSync === true,
756
+ scope: ONBOARD_STEPS.filter((id) => reviewedScope.has(id)),
757
+ });
683
758
  const bootstrap = deps.bootstrap;
684
759
  const bootstrapPlan = bootstrap
685
760
  ? parseBootstrapPlan(bootstrap.plan)
@@ -744,8 +819,10 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
744
819
  if (args.json)
745
820
  ctx.stdout(JSON.stringify(planJournal(journal)));
746
821
  else
747
- printPlan(ctx, journal);
822
+ printPlan(ctx, journal, undefined, planOptions(journal));
748
823
  showBootstrap();
824
+ if (!args.json)
825
+ ctx.stdout(`Next: run catalyst onboard${only ? ` --only ${only}` : ""}`);
749
826
  return 0;
750
827
  }
751
828
  // Check tenant binding before even a local cleanup mutation. Never overwrite a mismatched receipt.
@@ -760,7 +837,7 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
760
837
  const canRenew = error instanceof CliError &&
761
838
  error.code === "onboard-login-refresh-required" &&
762
839
  deps.stageSignin &&
763
- (deps.ui || plainSigninAllowed);
840
+ (interactiveUi || plainSigninAllowed);
764
841
  if (!canRenew) {
765
842
  const code = error instanceof CliError ? error.exitCode : EXIT_FAILED;
766
843
  ctx.stderr(error instanceof CliError &&
@@ -772,6 +849,7 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
772
849
  ...journal,
773
850
  mode: "run",
774
851
  exit: code,
852
+ errorCode: error instanceof CliError ? error.code : undefined,
775
853
  complete: false,
776
854
  }));
777
855
  return code;
@@ -794,11 +872,11 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
794
872
  if (identity)
795
873
  requireBootstrapIdentity(identity);
796
874
  if (deps.ui)
797
- deps.ui.plan(journal, identity);
875
+ deps.ui.plan(journal, identity, planOptions(journal));
798
876
  else if (!args.json)
799
- printPlan(ctx, journal, identity);
877
+ printPlan(ctx, journal, identity, planOptions(journal));
800
878
  else
801
- printPlan({ ...ctx, stdout: ctx.stderr }, journal, identity);
879
+ printPlan({ ...ctx, stdout: ctx.stderr }, journal, identity, planOptions(journal));
802
880
  showBootstrap();
803
881
  let localSync = args.flags["local-sync"] === true || journal.localSync === true;
804
882
  let candidate = null;
@@ -806,7 +884,7 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
806
884
  // person and workspace. Do not ask to approve a plan whose identity is still unknown.
807
885
  if (!identity &&
808
886
  deps.stageSignin &&
809
- !deps.ui &&
887
+ !interactiveUi &&
810
888
  personalScope &&
811
889
  !plainSigninAllowed) {
812
890
  ctx.stderr("Sign in with catalyst login, then run catalyst onboard, or use --yes to allow browser sign-in before the displayed setup plan.");
@@ -814,9 +892,11 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
814
892
  ctx.stdout(JSON.stringify({ ...planJournal(journal), exit: EXIT_WAITING }));
815
893
  return EXIT_WAITING;
816
894
  }
817
- const stageFirst = Boolean(deps.stageSignin && !identity && (deps.ui || plainSigninAllowed));
895
+ const stageFirst = Boolean(deps.stageSignin && !identity && (interactiveUi || plainSigninAllowed));
818
896
  if (stageFirst) {
819
- 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.";
897
+ const message = deps.reviewedSetup
898
+ ? "Sign in in your browser to continue setup."
899
+ : "Sign in in your browser first. Then review your person, workspace and setup plan. Your saved connection stays unchanged until you approve that plan.";
820
900
  if (deps.ui)
821
901
  deps.ui.message(message);
822
902
  else
@@ -824,7 +904,7 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
824
904
  }
825
905
  let consent = stageFirst
826
906
  ? { proceed: false, localSync, signin: true }
827
- : deps.ui
907
+ : deps.ui && interactiveUi
828
908
  ? await deps.ui.confirmPlan(localSync, deps.stageSignin
829
909
  ? identity
830
910
  ? "saved"
@@ -859,27 +939,32 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
859
939
  reviewedIdentity = identityTuple(identity);
860
940
  requireMatchingIdentity(journal, identity);
861
941
  requireBootstrapIdentity(identity);
942
+ deps.ui?.stagedSigninEnd?.("done");
862
943
  if (deps.ui)
863
- deps.ui.plan(journal, identity);
944
+ deps.ui.plan(journal, identity, planOptions(journal));
864
945
  else if (!args.json)
865
- printPlan(ctx, journal, identity);
946
+ printPlan(ctx, journal, identity, planOptions(journal));
866
947
  else
867
- printPlan({ ...ctx, stdout: ctx.stderr }, journal, identity);
948
+ printPlan({ ...ctx, stdout: ctx.stderr }, journal, identity, planOptions(journal));
868
949
  showBootstrap();
869
- consent = deps.ui
870
- ? await deps.ui.confirmPlan(localSync, "unavailable")
871
- : await confirmContinue(ctx, args, deps);
950
+ consent =
951
+ deps.ui && interactiveUi
952
+ ? await deps.ui.confirmPlan(localSync, "unavailable")
953
+ : await confirmContinue(ctx, args, deps);
872
954
  }
873
955
  catch (error) {
874
956
  const message = error instanceof CliError
875
957
  ? error.message
876
958
  : "Sign-in could not be verified. Your saved connection was not changed.";
877
- if (deps.ui)
878
- deps.ui.message(message);
879
- else
880
- ctx.stderr(message);
881
- deps.ui?.dispose();
882
959
  const code = error instanceof CliError ? error.exitCode : EXIT_WAITING;
960
+ const rendered = deps.ui?.stagedSigninEnd?.(code === EXIT_WAITING ? "waiting" : "failed", message);
961
+ if (!rendered) {
962
+ if (deps.ui)
963
+ deps.ui.message(message);
964
+ else
965
+ ctx.stderr(message);
966
+ }
967
+ deps.ui?.dispose();
883
968
  if (args.json)
884
969
  ctx.stdout(JSON.stringify({ ...planJournal(journal), exit: code }));
885
970
  return code;
@@ -888,7 +973,7 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
888
973
  if (typeof consent !== "boolean")
889
974
  localSync = consent.localSync;
890
975
  if (!(typeof consent === "boolean" ? consent : consent.proceed)) {
891
- const missingConsent = !deps.ui &&
976
+ const missingConsent = !interactiveUi &&
892
977
  args.flags.yes !== true &&
893
978
  (args.json ||
894
979
  !(deps.isTty ??
@@ -913,7 +998,7 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
913
998
  throw error;
914
999
  ctx.stderr(error.message);
915
1000
  if (args.json)
916
- ctx.stdout(JSON.stringify({ ...planJournal(journal), exit: error.exitCode }));
1001
+ ctx.stdout(JSON.stringify({ ...planJournal(journal), exit: error.exitCode, errorCode: error.code }));
917
1002
  return error.exitCode;
918
1003
  }
919
1004
  const stop = new AbortController();
@@ -922,6 +1007,7 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
922
1007
  ...(deps.signal ? [deps.signal] : []),
923
1008
  ...(deps.ui ? [deps.ui.signal] : []),
924
1009
  ]);
1010
+ activeRunSignal = signal;
925
1011
  const stepCtx = {
926
1012
  ...ctx,
927
1013
  stdout: deps.ui
@@ -937,17 +1023,33 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
937
1023
  };
938
1024
  let current = null;
939
1025
  let mayRecordProgress = candidate === null;
1026
+ const recordStep = (next) => {
1027
+ const prior = journalStep(journal, next.id);
1028
+ // Once a create was sent, generic capability, identity, scope and interruption results
1029
+ // cannot erase the recovery key. Only a verified selection or explicit team evidence can
1030
+ // replace it. This also retains a confirmed team's ID while adoption remains unfinished.
1031
+ if (next.id === "linear.team" &&
1032
+ next.state !== "done" &&
1033
+ !next.evidence?.teamKey &&
1034
+ (prior?.reason === "team_create_unverified" ||
1035
+ prior?.reason === "team_created_not_adopted") &&
1036
+ prior.evidence?.teamKey)
1037
+ setStep(journal, { ...prior, state: "waiting", at: next.at });
1038
+ else
1039
+ setStep(journal, next);
1040
+ };
1041
+ const interruptStep = (id) => recordStep({
1042
+ id,
1043
+ state: "failed",
1044
+ reason: "interrupted",
1045
+ at: isoNow(ctx, deps),
1046
+ });
940
1047
  const interrupted = () => {
941
1048
  stop.abort();
942
1049
  if (!mayRecordProgress)
943
1050
  return;
944
1051
  if (current)
945
- setStep(journal, {
946
- id: current,
947
- state: "failed",
948
- reason: "interrupted",
949
- at: isoNow(ctx, deps),
950
- });
1052
+ interruptStep(current);
951
1053
  journal.exit = EXIT_WAITING;
952
1054
  journal.complete = false;
953
1055
  try {
@@ -958,7 +1060,7 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
958
1060
  }
959
1061
  };
960
1062
  const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
961
- if (deps.bindSignals !== false)
1063
+ if (deps.bindSignals !== false && deps.ui?.handlesSignals !== true)
962
1064
  for (const name of signals)
963
1065
  process.on(name, interrupted);
964
1066
  const finish = (code) => {
@@ -975,7 +1077,8 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
975
1077
  else {
976
1078
  if (journal.complete)
977
1079
  ctx.stdout("Onboarding complete.");
978
- else if (onboardReadyForWork(journal, only)) {
1080
+ else if (onboardReadyForWork(journal, only) &&
1081
+ (deps.requiredSteps ?? []).every((id) => journalStep(journal, id)?.state === "done")) {
979
1082
  ctx.stdout("Ready for work.");
980
1083
  ctx.stdout("Next, when you want:");
981
1084
  for (const line of onboardNextActions(journal, savedOnboardBaseUrl(ctx.home)))
@@ -984,7 +1087,8 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
984
1087
  else if (only && code === 0)
985
1088
  ctx.stdout(`${ONBOARD_TITLES[only]} finished. Onboarding still has other steps.`);
986
1089
  else {
987
- ctx.stdout(`Setup still needs ${journal.steps.filter((step) => !stepSatisfied(step)).length} checks.`);
1090
+ ctx.stdout(`Setup still needs ${journal.steps.filter((step) => !stepSatisfied(step) ||
1091
+ (deps.requiredSteps?.includes(step.id) && step.state !== "done")).length} checks.`);
988
1092
  for (const line of onboardNextActions(journal, savedOnboardBaseUrl(ctx.home), only))
989
1093
  ctx.stdout(line);
990
1094
  }
@@ -1113,7 +1217,6 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
1113
1217
  identity = refreshed;
1114
1218
  }
1115
1219
  }
1116
- let refused = false;
1117
1220
  const needed = new Set();
1118
1221
  const include = (id) => {
1119
1222
  if (needed.has(id))
@@ -1128,175 +1231,224 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
1128
1231
  for (const id of ONBOARD_STEPS)
1129
1232
  needed.add(id);
1130
1233
  const fromIndex = resumeFrom ? ONBOARD_STEPS.indexOf(resumeFrom) : 0;
1131
- for (const id of ONBOARD_STEPS.filter((id) => needed.has(id))) {
1132
- if (signal.aborted)
1133
- return finish(EXIT_WAITING);
1134
- current = id;
1135
- deps.ui?.stepStart(id);
1136
- // Member onboarding keeps administration outside its scope, with an explicit recorded reason.
1137
- if (identity?.role === "member" && memberScopeSkips(id)) {
1138
- setStep(journal, {
1139
- id,
1140
- state: "skipped",
1141
- reason: "member_scope",
1142
- at: isoNow(ctx, deps),
1143
- });
1144
- deps.ui?.stepEnd(journalStep(journal, id), journal);
1145
- continue;
1146
- }
1147
- const missing = (ONBOARD_DEPENDENCIES[id] ?? []).find((parent) => !stepSatisfied(journalStep(journal, parent)));
1148
- if (missing) {
1149
- setStep(journal, {
1150
- id,
1151
- state: "waiting",
1152
- reason: "prerequisite_not_ready",
1153
- at: isoNow(ctx, deps),
1154
- });
1155
- writeOnboardJournal(statePath, journal);
1156
- deps.ui?.stepEnd(journalStep(journal, id), journal);
1157
- continue;
1158
- }
1159
- let adapter = deps.adapters?.[id];
1160
- // Compatibility for the initial legacy adapter, which performs its own before/after checks.
1161
- if (!adapter && id === "legacy") {
1162
- let result = deps.runStep
1163
- ? { state: "pending" }
1164
- : { state: "skipped", reason: "legacy_not_selected" };
1165
- adapter = {
1166
- check: async () => result,
1167
- act: deps.runStep
1168
- ? async () => (result = await deps.runStep(id, stepCtx))
1169
- : undefined,
1170
- };
1171
- }
1172
- if (!adapter) {
1173
- setStep(journal, {
1174
- id,
1175
- state: "waiting",
1176
- reason: "step_not_available_in_this_release",
1177
- at: isoNow(ctx, deps),
1178
- });
1179
- writeOnboardJournal(statePath, journal);
1180
- deps.ui?.stepEnd(journalStep(journal, id), journal);
1181
- continue;
1182
- }
1183
- try {
1184
- let result = await adapter.check(stepCtx, journal, signal);
1185
- if (result.state === "pending" &&
1186
- adapter.act &&
1187
- (!only || id === only) &&
1188
- ONBOARD_STEPS.indexOf(id) >= fromIndex) {
1189
- if (deps.identity) {
1190
- identity = await deps.identity(journal);
1191
- requireMatchingIdentity(journal, identity);
1192
- if (identity?.role === "member" && memberScopeSkips(id)) {
1193
- setStep(journal, {
1194
- id,
1195
- state: "skipped",
1196
- reason: "member_scope",
1197
- at: isoNow(ctx, deps),
1198
- });
1199
- writeOnboardJournal(statePath, journal);
1200
- deps.ui?.stepEnd(journalStep(journal, id), journal);
1201
- continue;
1202
- }
1203
- }
1204
- journal.operations[id] ??= `${journal.runId}:${id}`;
1205
- setStep(journal, { id, state: "running", at: isoNow(ctx, deps) });
1206
- journal.exit = null;
1234
+ let rerun = null;
1235
+ for (;;) {
1236
+ let refused = false;
1237
+ for (const id of ONBOARD_STEPS.filter((id) => needed.has(id) && (!rerun || rerun.has(id)))) {
1238
+ if (signal.aborted)
1239
+ return finish(EXIT_WAITING);
1240
+ // No step is running while the session refreshes, so an interrupt here marks none of them.
1241
+ current = null;
1242
+ await deps.beforeStep?.(id);
1243
+ if (signal.aborted)
1244
+ return finish(EXIT_WAITING);
1245
+ current = id;
1246
+ deps.ui?.stepStart(id);
1247
+ const stepSignal = deps.ui?.stepSignal
1248
+ ? AbortSignal.any([signal, deps.ui.stepSignal])
1249
+ : signal;
1250
+ // Member onboarding keeps administration outside its scope, with an explicit recorded reason.
1251
+ if (identity?.role === "member" && memberScopeSkips(id)) {
1252
+ recordStep({
1253
+ id,
1254
+ state: "skipped",
1255
+ reason: "member_scope",
1256
+ ...(id === "runner" && args.flags.runner === true
1257
+ ? { evidence: { selected: true } }
1258
+ : {}),
1259
+ at: isoNow(ctx, deps),
1260
+ });
1261
+ deps.ui?.stepEnd(journalStep(journal, id), journal);
1262
+ continue;
1263
+ }
1264
+ const missing = (ONBOARD_DEPENDENCIES[id] ?? []).find((parent) => !stepSatisfied(journalStep(journal, parent)));
1265
+ if (missing) {
1266
+ recordStep({
1267
+ id,
1268
+ state: "waiting",
1269
+ reason: "prerequisite_not_ready",
1270
+ at: isoNow(ctx, deps),
1271
+ });
1272
+ writeOnboardJournal(statePath, journal);
1273
+ deps.ui?.stepEnd(journalStep(journal, id), journal);
1274
+ continue;
1275
+ }
1276
+ let adapter = deps.adapters?.[id];
1277
+ // Compatibility for the initial legacy adapter, which performs its own before/after checks.
1278
+ if (!adapter && id === "legacy") {
1279
+ let result = deps.runStep
1280
+ ? { state: "pending" }
1281
+ : { state: "skipped", reason: "legacy_not_selected" };
1282
+ adapter = {
1283
+ check: async () => result,
1284
+ act: deps.runStep
1285
+ ? async () => (result = await deps.runStep(id, stepCtx))
1286
+ : undefined,
1287
+ };
1288
+ }
1289
+ if (!adapter) {
1290
+ recordStep({
1291
+ id,
1292
+ state: "waiting",
1293
+ reason: "step_not_available_in_this_release",
1294
+ at: isoNow(ctx, deps),
1295
+ });
1207
1296
  writeOnboardJournal(statePath, journal);
1208
- const action = await adapter.act(stepCtx, journal, signal);
1209
- if (action.state === "done") {
1210
- result = await adapter.check(stepCtx, journal, signal);
1211
- if (result.state === "pending")
1212
- result = { state: "waiting", reason: "verification_pending" };
1297
+ deps.ui?.stepEnd(journalStep(journal, id), journal);
1298
+ continue;
1299
+ }
1300
+ try {
1301
+ let result = await adapter.check(stepCtx, journal, stepSignal);
1302
+ if (result.state === "pending" &&
1303
+ adapter.act &&
1304
+ (!only || id === only) &&
1305
+ ONBOARD_STEPS.indexOf(id) >= fromIndex) {
1306
+ if (deps.identity) {
1307
+ identity = await deps.identity(journal);
1308
+ requireMatchingIdentity(journal, identity);
1309
+ if (identity?.role === "member" && memberScopeSkips(id)) {
1310
+ recordStep({
1311
+ id,
1312
+ state: "skipped",
1313
+ reason: "member_scope",
1314
+ at: isoNow(ctx, deps),
1315
+ });
1316
+ writeOnboardJournal(statePath, journal);
1317
+ deps.ui?.stepEnd(journalStep(journal, id), journal);
1318
+ continue;
1319
+ }
1320
+ }
1321
+ journal.operations[id] ??= `${journal.runId}:${id}`;
1322
+ const prior = journalStep(journal, id);
1323
+ const recoveringTeam = id === "linear.team" &&
1324
+ (prior?.reason === "team_create_unverified" ||
1325
+ prior?.reason === "team_created_not_adopted");
1326
+ recordStep({
1327
+ ...(recoveringTeam ? prior : {}),
1328
+ id,
1329
+ state: "running",
1330
+ at: isoNow(ctx, deps),
1331
+ });
1332
+ journal.exit = null;
1333
+ writeOnboardJournal(statePath, journal);
1334
+ const action = await adapter.act(stepCtx, journal, stepSignal);
1335
+ if (action.state === "done") {
1336
+ result = await adapter.check(stepCtx, journal, stepSignal);
1337
+ if (result.state === "pending")
1338
+ result = { state: "waiting", reason: "verification_pending" };
1339
+ }
1340
+ else
1341
+ result = action;
1342
+ if (id === "signin" && result.state === "done" && deps.identity) {
1343
+ identity = await deps.identity(journal);
1344
+ requireMatchingIdentity(journal, identity);
1345
+ if (identity?.role === "member" && memberScopeSkips(id)) {
1346
+ recordStep({
1347
+ id,
1348
+ state: "skipped",
1349
+ reason: "member_scope",
1350
+ at: isoNow(ctx, deps),
1351
+ });
1352
+ writeOnboardJournal(statePath, journal);
1353
+ deps.ui?.stepEnd(journalStep(journal, id), journal);
1354
+ continue;
1355
+ }
1356
+ }
1213
1357
  }
1214
- else
1215
- result = action;
1216
1358
  if (id === "signin" && result.state === "done" && deps.identity) {
1217
1359
  identity = await deps.identity(journal);
1218
1360
  requireMatchingIdentity(journal, identity);
1219
- if (identity?.role === "member" && memberScopeSkips(id)) {
1220
- setStep(journal, {
1221
- id,
1222
- state: "skipped",
1223
- reason: "member_scope",
1224
- at: isoNow(ctx, deps),
1225
- });
1226
- writeOnboardJournal(statePath, journal);
1227
- deps.ui?.stepEnd(journalStep(journal, id), journal);
1228
- continue;
1229
- }
1361
+ if (!identity)
1362
+ result = { state: "failed", reason: "membership_not_verified" };
1363
+ else
1364
+ for (const line of onboardIdentityLines(identity))
1365
+ stepCtx.stdout(line);
1230
1366
  }
1367
+ if (result.state === "refused")
1368
+ refused = true;
1369
+ if (result.state === "pending")
1370
+ result = {
1371
+ ...result,
1372
+ state: "waiting",
1373
+ reason: result.reason ?? "action_required",
1374
+ };
1375
+ recordStep(sanitizedResult(id, result, isoNow(ctx, deps)));
1231
1376
  }
1232
- if (id === "signin" && result.state === "done" && deps.identity) {
1233
- identity = await deps.identity(journal);
1234
- requireMatchingIdentity(journal, identity);
1235
- if (!identity)
1236
- result = { state: "failed", reason: "membership_not_verified" };
1237
- else
1238
- for (const line of onboardIdentityLines(identity))
1239
- stepCtx.stdout(line);
1377
+ catch (error) {
1378
+ if (error instanceof CliError && error.exitCode === EXIT_REFUSED)
1379
+ refused = true;
1380
+ const reason = stepSignal.aborted
1381
+ ? "interrupted"
1382
+ : error instanceof CliError &&
1383
+ /^[a-z][a-z0-9_-]{1,63}$/.test(error.code)
1384
+ ? error.code.replaceAll("-", "_")
1385
+ : "step_failed";
1386
+ const renewLogin = error instanceof CliError &&
1387
+ error.code === "onboard-login-refresh-required";
1388
+ recordStep({
1389
+ id,
1390
+ state: renewLogin || stepSignal.aborted ? "waiting" : "failed",
1391
+ ...(error instanceof CliError && error.exitCode === EXIT_REFUSED && !stepSignal.aborted ? { refused: true } : {}),
1392
+ reason,
1393
+ at: isoNow(ctx, deps),
1394
+ });
1395
+ stepCtx.stderr(renewLogin
1396
+ ? "Renew your login with catalyst login, then run catalyst onboard to resume."
1397
+ : `✗ ${ONBOARD_TITLES[id]}: ${reason}. The safe reason is saved; run the same command to resume.`);
1240
1398
  }
1241
- if (result.state === "refused")
1242
- refused = true;
1243
- if (result.state === "pending")
1244
- result = {
1245
- ...result,
1246
- state: "waiting",
1247
- reason: result.reason ?? "action_required",
1248
- };
1249
- setStep(journal, sanitizedResult(id, result, isoNow(ctx, deps)));
1250
- }
1251
- catch (error) {
1252
- if (error instanceof CliError && error.exitCode === EXIT_REFUSED)
1253
- refused = true;
1254
- const reason = signal.aborted
1255
- ? "interrupted"
1256
- : error instanceof CliError &&
1257
- /^[a-z][a-z0-9_-]{1,63}$/.test(error.code)
1258
- ? error.code.replaceAll("-", "_")
1259
- : "step_failed";
1260
- const renewLogin = error instanceof CliError &&
1261
- error.code === "onboard-login-refresh-required";
1262
- setStep(journal, {
1263
- id,
1264
- state: renewLogin ? "waiting" : "failed",
1265
- reason,
1266
- at: isoNow(ctx, deps),
1267
- });
1268
- stepCtx.stderr(renewLogin
1269
- ? "Renew your login with catalyst login, then run catalyst onboard to resume."
1270
- : `✗ ${ONBOARD_TITLES[id]}: ${reason}. The safe reason is saved; run the same command to resume.`);
1399
+ if (signal.aborted) {
1400
+ const settled = journalStep(journal, id);
1401
+ if (settled?.state !== "done" && settled?.state !== "failed")
1402
+ recordStep({
1403
+ id,
1404
+ state: "waiting",
1405
+ reason: "interrupted",
1406
+ at: isoNow(ctx, deps),
1407
+ });
1408
+ deps.ui?.stepEnd(journalStep(journal, id), journal);
1409
+ return finish(EXIT_WAITING);
1410
+ }
1411
+ deps.ui?.stepEnd(journalStep(journal, id), journal);
1412
+ writeOnboardJournal(statePath, journal);
1413
+ // Later steps share the same login; preserve the actionable cause instead of cascading failures.
1414
+ if (journalStep(journal, id)?.reason?.endsWith("_login_refresh_required"))
1415
+ return finish(EXIT_WAITING);
1416
+ if (refused)
1417
+ break;
1271
1418
  }
1272
- if (signal.aborted) {
1273
- setStep(journal, {
1274
- id,
1275
- state: "failed",
1276
- reason: "interrupted",
1277
- at: isoNow(ctx, deps),
1278
- });
1279
- return finish(EXIT_WAITING);
1419
+ current = null;
1420
+ const scope = only ? [only] : [...ONBOARD_STEPS];
1421
+ const requiredStepRefused = scope.some((id) => deps.requiredSteps?.includes(id) && journalStep(journal, id)?.reason === "member_scope");
1422
+ const required = new Set(deps.requiredSteps ?? []);
1423
+ if (args.flags.runner === true || journalStep(journal, "runner")?.evidence?.selected === true)
1424
+ required.add("runner");
1425
+ const code = refused || requiredStepRefused
1426
+ ? EXIT_REFUSED
1427
+ : [...needed].some((id) => journalStep(journal, id)?.state === "failed")
1428
+ ? EXIT_FAILED
1429
+ : scope.every((id) => (stepSatisfied(journalStep(journal, id)) &&
1430
+ !(required.has(id) && journalStep(journal, id)?.state !== "done")) ||
1431
+ (!only &&
1432
+ ONBOARD_DEFERRED_STEPS.has(id) &&
1433
+ !required.has(id)))
1434
+ ? 0
1435
+ : EXIT_WAITING;
1436
+ journal.exit = code;
1437
+ journal.complete =
1438
+ !only &&
1439
+ code === 0 &&
1440
+ ONBOARD_STEPS.every((id) => stepSatisfied(journalStep(journal, id)));
1441
+ const unfinished = journal.steps.filter((step) => !stepSatisfied(step));
1442
+ if (!only &&
1443
+ !refused &&
1444
+ unfinished.length &&
1445
+ !signal.aborted &&
1446
+ (await deps.ui?.checkAgain?.(journal))) {
1447
+ rerun = new Set([...unfinished.map((step) => step.id), "ready"]);
1448
+ continue;
1280
1449
  }
1281
- deps.ui?.stepEnd(journalStep(journal, id), journal);
1282
- writeOnboardJournal(statePath, journal);
1283
- // Later steps share the same login; preserve the actionable cause instead of cascading failures.
1284
- if (journalStep(journal, id)?.reason?.endsWith("_login_refresh_required"))
1285
- return finish(EXIT_WAITING);
1286
- if (refused)
1287
- break;
1450
+ return finish(signal.aborted ? EXIT_WAITING : code);
1288
1451
  }
1289
- current = null;
1290
- const scope = only ? [only] : [...ONBOARD_STEPS];
1291
- const code = refused
1292
- ? EXIT_REFUSED
1293
- : scope.some((id) => journalStep(journal, id)?.state === "failed")
1294
- ? EXIT_FAILED
1295
- : scope.every((id) => stepSatisfied(journalStep(journal, id)) ||
1296
- (!only && ONBOARD_DEFERRED_STEPS.has(id)))
1297
- ? 0
1298
- : EXIT_WAITING;
1299
- return finish(code);
1300
1452
  }
1301
1453
  catch (error) {
1302
1454
  if (candidate?.accepted && !mayRecordProgress) {
@@ -1309,13 +1461,13 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
1309
1461
  if (error instanceof CliError) {
1310
1462
  ctx.stderr(error.message);
1311
1463
  if (args.json)
1312
- ctx.stdout(JSON.stringify({ ...planJournal(journal), exit: error.exitCode }));
1464
+ ctx.stdout(JSON.stringify({ ...planJournal(journal), exit: error.exitCode, errorCode: error.code }));
1313
1465
  return error.exitCode;
1314
1466
  }
1315
1467
  throw error;
1316
1468
  }
1317
1469
  finally {
1318
- if (deps.bindSignals !== false)
1470
+ if (deps.bindSignals !== false && deps.ui?.handlesSignals !== true)
1319
1471
  for (const name of signals)
1320
1472
  process.off(name, interrupted);
1321
1473
  releaseLock(lock);