@catalyst-cloud/cli 0.14.1 → 0.14.2

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/onboard.js CHANGED
@@ -2,9 +2,8 @@ import { randomBytes, randomUUID } from "node:crypto";
2
2
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
3
  import { createReadStream, createWriteStream } from "node:fs";
4
4
  import { createInterface } from "node:readline/promises";
5
- import { spawnSync } from "node:child_process";
6
5
  import { dirname, isAbsolute, join } from "node:path";
7
- import { parseMachinePaths, resolveCatalystPath, } from "../vendor/paths/index.js";
6
+ import { parseMachinePaths, resolveCatalystPath } from "../vendor/paths/index.js";
8
7
  import { machinePathsFile } from "../vendor/paths/node.js";
9
8
  import { CliError, UsageError } from "./errors.js";
10
9
  export const ONBOARD_STEPS = [
@@ -19,6 +18,7 @@ export const ONBOARD_STEPS = [
19
18
  "linear.adopt",
20
19
  "linear.automations",
21
20
  "github.install",
21
+ "github.personal",
22
22
  "github.repos",
23
23
  "projects",
24
24
  "accounts",
@@ -41,15 +41,21 @@ export function onboardStateRoot(home, env = {}) {
41
41
  throw new CliError("bootstrap state directory must be absolute", "onboard-state-path", EXIT_REFUSED);
42
42
  return bootstrap;
43
43
  }
44
- const pathsFile = machinePathsFile({ env: { ...env, HOME: home } });
45
- const machine = pathsFile && existsSync(pathsFile)
46
- ? parseMachinePaths(JSON.parse(readFileSync(pathsFile, "utf8")))
47
- : undefined;
48
- if (env.CATALYST_PATHS_FILE && !machine)
49
- throw new CliError("the selected machine paths file is missing", "onboard-state-path", EXIT_REFUSED);
50
- if (env.CATALYST_STATE_DIR !== undefined || machine)
51
- return resolveCatalystPath("state", { env, machine });
52
- return join(env.XDG_STATE_HOME ?? join(home, ".local", "state"), "catalyst");
44
+ try {
45
+ const pathsFile = machinePathsFile({ env: { ...env, HOME: home } });
46
+ const machine = pathsFile && existsSync(pathsFile)
47
+ ? parseMachinePaths(JSON.parse(readFileSync(pathsFile, "utf8"))) : undefined;
48
+ if (env.CATALYST_PATHS_FILE && !machine)
49
+ throw new CliError("the selected machine paths file is missing", "onboard-state-path", EXIT_REFUSED);
50
+ if (env.CATALYST_STATE_DIR !== undefined || machine)
51
+ return resolveCatalystPath("state", { env, machine });
52
+ return join(env.XDG_STATE_HOME ?? join(home, ".local", "state"), "catalyst");
53
+ }
54
+ catch (error) {
55
+ if (error instanceof CliError)
56
+ throw error;
57
+ throw new CliError("the selected setup paths are invalid; keep the file and correct it before resuming", "onboard-state-path", EXIT_REFUSED);
58
+ }
53
59
  }
54
60
  export function onboardStatePath(home, env = {}) {
55
61
  return join(onboardStateRoot(home, env), "install", "last-run.json");
@@ -58,63 +64,29 @@ export function onboardLockPath(home, env = {}) {
58
64
  return join(onboardStateRoot(home, env), "install.lock");
59
65
  }
60
66
  export const ONBOARD_DEPENDENCIES = {
61
- "linear.workspace": ["signin"],
62
- "linear.personal": ["signin", "linear.workspace"],
67
+ "linear.workspace": ["signin"], "linear.personal": ["signin", "linear.workspace"],
63
68
  "linear.team": ["signin", "linear.workspace", "linear.personal"],
64
- "linear.adopt": ["linear.team"],
65
- "linear.automations": ["linear.team", "linear.personal"],
66
- "github.install": ["signin"],
67
- "github.repos": ["github.install"],
68
- projects: ["linear.team", "github.repos"],
69
- accounts: ["signin"],
70
- settings: ["projects"],
71
- values: ["settings"],
72
- capacity: ["projects"],
73
- daemon: ["signin"],
74
- "first-ticket": [
75
- "projects",
76
- "accounts",
77
- "settings",
78
- "values",
79
- "capacity",
80
- "daemon",
81
- ],
69
+ "linear.adopt": ["linear.team"], "linear.automations": ["linear.team", "linear.personal"],
70
+ "github.install": ["signin"], "github.personal": ["signin"], "github.repos": ["github.install", "linear.team"],
71
+ projects: ["linear.team", "github.repos"], accounts: ["signin"], settings: ["projects"],
72
+ values: ["settings"], capacity: ["projects"], daemon: ["signin"],
73
+ "first-ticket": ["projects", "linear.adopt", "linear.automations", "accounts", "settings", "values", "capacity", "daemon"],
82
74
  };
83
75
  const ADMIN_STEPS = new Set([
84
- "linear.workspace",
85
- "linear.team",
86
- "linear.adopt",
87
- "linear.automations",
88
- "github.install",
89
- "github.repos",
90
- "projects",
91
- "settings",
92
- "values",
93
- "capacity",
94
- "first-ticket",
76
+ "linear.workspace", "linear.adopt", "linear.automations", "github.install",
77
+ "github.repos", "projects", "settings", "values", "capacity", "first-ticket",
95
78
  ]);
96
79
  export const ONBOARD_TITLES = {
97
- machine: "Check this computer",
98
- cli: "Check the catalyst command",
99
- skills: "Check the Catalyst skills",
100
- legacy: "Remove earlier Catalyst installs",
101
- signin: "Sign in to Catalyst",
102
- "linear.workspace": "Connect your Linear workspace",
103
- "linear.personal": "Connect your Linear account",
104
- "linear.team": "Choose a Linear team",
105
- "linear.adopt": "Apply the Catalyst workflow",
106
- "linear.automations": "Check Linear's pull request automations",
107
- "github.install": "Install Catalyst on GitHub",
108
- "github.repos": "Choose repositories",
109
- projects: "Register your projects",
110
- accounts: "Check coding accounts",
111
- settings: "Review repository settings",
112
- values: "Import selected local values",
113
- capacity: "Check runner capacity",
114
- daemon: "Start background sync",
115
- housekeeping: "Schedule the daily update",
116
- "first-ticket": "Start a first ticket",
117
- ready: "Check onboarding readiness",
80
+ machine: "Check this computer", cli: "Check the catalyst command", skills: "Check the Catalyst skills",
81
+ legacy: "Remove earlier Catalyst installs", signin: "Sign in to Catalyst",
82
+ "linear.workspace": "Connect your Linear workspace", "linear.personal": "Connect your Linear account",
83
+ "linear.team": "Choose a Linear team", "linear.adopt": "Apply the Catalyst workflow",
84
+ "linear.automations": "Check Linear's pull request automations", "github.install": "Install Catalyst on GitHub",
85
+ "github.personal": "Connect your GitHub account",
86
+ "github.repos": "Choose repositories", projects: "Register your projects", accounts: "Check coding accounts",
87
+ settings: "Review repository settings", values: "Import selected local values", capacity: "Check runner capacity",
88
+ daemon: "Check optional local sync", housekeeping: "Schedule the daily update",
89
+ "first-ticket": "Start a first ticket", ready: "Check onboarding readiness",
118
90
  };
119
91
  function isoNow(ctx, deps) {
120
92
  return (deps.now ?? ctx.now)().toISOString();
@@ -123,13 +95,16 @@ function safeRead(path) {
123
95
  try {
124
96
  if (lstatSync(path).isSymbolicLink())
125
97
  throw new CliError(`the saved setup record at ${path} cannot be a symbolic link`, "onboard-state-symlink", EXIT_REFUSED);
126
- return JSON.parse(readFileSync(path, "utf8"));
98
+ const value = JSON.parse(readFileSync(path, "utf8"));
99
+ if (value === null)
100
+ throw new CliError("the saved setup record has an unknown shape; keep it and move it aside before trying again", "onboard-state-shape", EXIT_REFUSED);
101
+ return value;
127
102
  }
128
103
  catch (err) {
129
104
  if (err instanceof CliError)
130
105
  throw err;
131
106
  if (object(err)?.code === "ENOENT")
132
- return undefined;
107
+ return null;
133
108
  throw new CliError(`the saved setup record at ${path} is not valid JSON; keep it and move it aside before trying again`, "onboard-state-corrupt", EXIT_REFUSED);
134
109
  }
135
110
  }
@@ -197,33 +172,10 @@ function normalizeStep(value, fallbackAt) {
197
172
  "found",
198
173
  "remaining",
199
174
  "count",
200
- "version",
201
- "account",
202
- "membershipId",
203
- "checkedAt",
204
- "project",
205
- "repository",
206
- "ticket",
207
- "cursor",
208
- "lag",
209
- "heartbeatAgeMs",
210
- "supervised",
211
- "streaming",
212
- "scheduled",
213
- "validated",
214
- "available",
215
- "phaseStarted",
216
- "linearComment",
217
- "fleetActivity",
218
- "requiredValues",
219
- "paused",
220
- "revision",
221
- "hash",
222
- "path",
223
- "provider",
224
- "scope",
225
- "checks",
226
- "passed",
175
+ "version", "account", "membershipId", "checkedAt", "project", "repository", "ticket",
176
+ "cursor", "lag", "heartbeatAgeMs", "supervised", "streaming", "scheduled", "validated",
177
+ "available", "phaseStarted", "linearComment", "fleetActivity", "requiredValues", "paused",
178
+ "revision", "hash", "path", "provider", "scope", "checks", "passed", "team", "teamKey",
227
179
  ]) {
228
180
  const item = evidence?.[key];
229
181
  if (item === null ||
@@ -237,9 +189,9 @@ function normalizeStep(value, fallbackAt) {
237
189
  return step;
238
190
  }
239
191
  /** Reads both the M1 journal and the 0.13.4 install-script receipt without discarding completed steps. */
240
- export function readOnboardJournal(path, cliVersion = "0.14.0", now = new Date()) {
192
+ export function readOnboardJournal(path, cliVersion = "0.13.1", now = new Date()) {
241
193
  const raw = safeRead(path);
242
- if (raw === undefined)
194
+ if (raw === null)
243
195
  return null;
244
196
  const value = object(raw);
245
197
  if (!value || !Array.isArray(value.steps))
@@ -274,22 +226,13 @@ export function readOnboardJournal(path, cliVersion = "0.14.0", now = new Date()
274
226
  : exit,
275
227
  steps: [...deduped.values()],
276
228
  ...(typeof value.account === "string" ? { account: value.account } : {}),
277
- ...(typeof value.membershipId === "string"
278
- ? { membershipId: value.membershipId }
279
- : {}),
229
+ ...(typeof value.membershipId === "string" ? { membershipId: value.membershipId } : {}),
280
230
  ...(typeof value.baseUrl === "string" ? { baseUrl: value.baseUrl } : {}),
281
- ...(value.scope === "step" || value.scope === "onboarding"
282
- ? { scope: value.scope }
283
- : {}),
284
- ...(value.mode === "plan" || value.mode === "run"
285
- ? { mode: value.mode }
286
- : {}),
287
- ...(typeof value.complete === "boolean"
288
- ? { complete: value.complete }
289
- : {}),
290
- operations: Object.fromEntries(Object.entries(object(value.operations) ?? {}).filter(([key, v]) => ONBOARD_STEPS.includes(key) &&
291
- typeof v === "string" &&
292
- /^[a-zA-Z0-9_.:-]{1,160}$/.test(v))),
231
+ ...(value.scope === "step" || value.scope === "onboarding" ? { scope: value.scope } : {}),
232
+ ...(value.mode === "plan" || value.mode === "run" ? { mode: value.mode } : {}),
233
+ ...(typeof value.complete === "boolean" ? { complete: value.complete } : {}),
234
+ ...(typeof value.localSync === "boolean" ? { localSync: value.localSync } : {}),
235
+ operations: Object.fromEntries(Object.entries(object(value.operations) ?? {}).filter(([key, v]) => ONBOARD_STEPS.includes(key) && typeof v === "string" && /^[a-zA-Z0-9_.:-]{1,160}$/.test(v))),
293
236
  changes: Array.isArray(value.changes)
294
237
  ? value.changes.flatMap((change) => {
295
238
  const row = object(change);
@@ -335,20 +278,11 @@ export function writeOnboardJournal(path, journal) {
335
278
  function defaultAlive(pid) {
336
279
  try {
337
280
  process.kill(pid, 0);
281
+ return true;
338
282
  }
339
- catch (err) {
340
- return object(err)?.code !== "ESRCH";
283
+ catch (error) {
284
+ return object(error)?.code !== "ESRCH";
341
285
  }
342
- if (pid === process.pid)
343
- return true;
344
- const probe = spawnSync("ps", ["-p", String(pid), "-o", "args="], {
345
- encoding: "utf8",
346
- timeout: 2_000,
347
- });
348
- if (probe.status !== 0)
349
- return true;
350
- const command = probe.stdout.trim();
351
- return command.includes("catalyst") && command.includes("onboard");
352
286
  }
353
287
  function readLockOwner(path) {
354
288
  try {
@@ -375,12 +309,14 @@ function removeStaleLock(lockPath, owner) {
375
309
  if (!before.isDirectory() || before.isSymbolicLink())
376
310
  return false;
377
311
  const current = readLockOwner(lockPath);
378
- if (current?.pid !== owner.pid || current?.token !== owner.token)
312
+ if ((current?.pid ?? null) !== (owner?.pid ?? null) ||
313
+ (current?.token ?? "") !== (owner?.token ?? ""))
379
314
  return false;
380
- const stalePath = `${lockPath}.stale-${owner.pid}-${randomBytes(4).toString("hex")}`;
315
+ const stalePath = `${lockPath}.stale-${owner?.pid ?? "unknown"}-${randomBytes(4).toString("hex")}`;
381
316
  renameSync(lockPath, stalePath);
382
317
  const moved = readLockOwner(stalePath);
383
- if (moved?.pid !== owner.pid || moved?.token !== owner.token) {
318
+ if ((moved?.pid ?? null) !== (owner?.pid ?? null) ||
319
+ (moved?.token ?? "") !== (owner?.token ?? "")) {
384
320
  try {
385
321
  renameSync(stalePath, lockPath);
386
322
  }
@@ -448,11 +384,7 @@ function acquireLock(ctx, deps) {
448
384
  const handoff = ctx.env.CATALYST_INSTALL_LOCK_TOKEN;
449
385
  if (current?.pid === pid && current.token !== "" && current.token === handoff)
450
386
  return { path, owner: current };
451
- const ownerIsAlive = current?.pid === pid
452
- ? false
453
- : current
454
- ? (deps.isProcessAlive ?? defaultAlive)(current.pid)
455
- : false;
387
+ const ownerIsAlive = current ? (deps.isProcessAlive ?? defaultAlive)(current.pid) : false;
456
388
  if (current && ownerIsAlive)
457
389
  throw new CliError(`setup is already running (pid ${current.pid}); wait for it to finish, then run the same command`, "onboard-locked", EXIT_FAILED);
458
390
  if (!current)
@@ -487,6 +419,10 @@ function freshJournal(ctx, cliVersion, deps) {
487
419
  changes: [],
488
420
  };
489
421
  }
422
+ /** A refusal before loading state must not reuse or copy an unreadable receipt. */
423
+ export function onboardErrorJournal(ctx, cliVersion, exit) {
424
+ return { ...freshJournal(ctx, cliVersion, {}), scope: "onboarding", mode: "run", exit, complete: false };
425
+ }
490
426
  function planJournal(journal) {
491
427
  return {
492
428
  ...journal,
@@ -536,18 +472,14 @@ async function confirmContinue(ctx, args, deps) {
536
472
  }
537
473
  }
538
474
  function sanitizedResult(id, result, at) {
539
- const row = normalizeStep({
540
- id,
541
- ...result,
542
- state: result.state === "refused" ? "failed" : result.state,
543
- }, at);
475
+ const row = normalizeStep({ id, ...result, state: result.state === "refused" ? "failed" : result.state }, at);
544
476
  row.at = at;
545
477
  return row;
546
478
  }
547
479
  function stepSatisfied(step) {
548
- return (step?.state === "done" ||
549
- (step?.state === "skipped" &&
550
- (step.id === "legacy" || step.reason === "member_scope")));
480
+ return step?.state === "done" || (step?.state === "skipped" &&
481
+ (step.id === "legacy" || step.reason === "member_scope" ||
482
+ (step.id === "daemon" && step.reason === "local_sync_not_selected")));
551
483
  }
552
484
  function requireMatchingIdentity(journal, identity) {
553
485
  const account = journal.account ?? journal.tenant;
@@ -563,22 +495,21 @@ function requireMatchingIdentity(journal, identity) {
563
495
  }
564
496
  }
565
497
  /** The receipt records progress; adapters verify the current state before any resumed action. */
566
- export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.14.0") {
498
+ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
567
499
  if (args.subcommand !== null || args.rest.length > 0)
568
500
  throw new UsageError("onboard takes no positional arguments");
569
- const only = typeof args.flags.only === "string"
570
- ? args.flags.only
571
- : undefined;
572
- const resume = typeof args.flags["resume-from"] === "string"
573
- ? args.flags["resume-from"]
574
- : undefined;
501
+ if (args.json || args.flags.yes === true || args.flags["dry-run"] === true)
502
+ deps = { ...deps, ui: undefined };
503
+ const only = typeof args.flags.only === "string" ? args.flags.only : undefined;
504
+ const memberScopeSkips = (id) => ADMIN_STEPS.has(id) ||
505
+ (id === "linear.team" && only !== "linear.team" && typeof args.flags.team !== "string");
506
+ const resume = typeof args.flags["resume-from"] === "string" ? args.flags["resume-from"] : undefined;
575
507
  const resumeFrom = resume === "install" ? "signin" : resume;
576
508
  for (const id of [only, resumeFrom])
577
509
  if (id && !ONBOARD_STEPS.includes(id))
578
510
  throw new UsageError(`unknown onboarding step: ${id}`);
579
511
  const statePath = onboardStatePath(ctx.home, ctx.env);
580
- let journal = readOnboardJournal(statePath, cliVersion, (deps.now ?? ctx.now)()) ??
581
- freshJournal(ctx, cliVersion, deps);
512
+ let journal = readOnboardJournal(statePath, cliVersion, (deps.now ?? ctx.now)()) ?? freshJournal(ctx, cliVersion, deps);
582
513
  if (args.flags["dry-run"] === true) {
583
514
  if (args.json)
584
515
  ctx.stdout(JSON.stringify(planJournal(journal)));
@@ -590,26 +521,31 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.14.0") {
590
521
  let identity = null;
591
522
  try {
592
523
  if (deps.identity) {
593
- identity = await deps.identity();
524
+ identity = await deps.identity(journal);
594
525
  requireMatchingIdentity(journal, identity);
595
526
  }
596
527
  }
597
528
  catch (error) {
598
- if (error instanceof CliError) {
599
- ctx.stderr(error.message);
600
- return error.exitCode;
601
- }
602
- ctx.stderr("Could not verify your Catalyst membership. Setup was not changed.");
603
- return EXIT_FAILED;
529
+ const code = error instanceof CliError ? error.exitCode : EXIT_FAILED;
530
+ ctx.stderr("Could not verify your Catalyst membership. Setup was not changed. Sign in with the original account to resume.");
531
+ if (args.json)
532
+ ctx.stdout(JSON.stringify({ ...journal, mode: "run", exit: code, complete: false }));
533
+ return code;
604
534
  }
605
- if (!args.json)
535
+ if (deps.ui)
536
+ deps.ui.plan(journal);
537
+ else if (!args.json)
606
538
  printPlan(ctx, journal);
607
539
  const inheritedConsent = resume === "install" && Boolean(ctx.env.CATALYST_INSTALL_LOCK_TOKEN);
608
- if (!inheritedConsent && !(await confirmContinue(ctx, args, deps))) {
540
+ let localSync = args.flags["local-sync"] === true || journal.localSync === true;
541
+ const consent = inheritedConsent ? true : deps.ui ? await deps.ui.confirmPlan(localSync) : await confirmContinue(ctx, args, deps);
542
+ if (typeof consent !== "boolean")
543
+ localSync = consent.localSync;
544
+ if (!(typeof consent === "boolean" ? consent : consent.proceed)) {
609
545
  ctx.stderr("Nothing was changed. Run the same command when ready.");
610
546
  if (args.json)
611
547
  ctx.stdout(JSON.stringify({ ...journal, complete: false }));
612
- return 0;
548
+ return deps.ui?.signal.aborted ? EXIT_WAITING : 0;
613
549
  }
614
550
  let lock;
615
551
  try {
@@ -624,35 +560,23 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.14.0") {
624
560
  return error.exitCode;
625
561
  }
626
562
  const stop = new AbortController();
627
- const signal = deps.signal
628
- ? AbortSignal.any([deps.signal, stop.signal])
629
- : stop.signal;
630
- const stepCtx = {
631
- ...ctx,
632
- stdout: args.json ? ctx.stderr : ctx.stdout,
563
+ const signal = AbortSignal.any([stop.signal, ...(deps.signal ? [deps.signal] : []), ...(deps.ui ? [deps.ui.signal] : [])]);
564
+ const stepCtx = { ...ctx, stdout: deps.ui ? (line) => deps.ui.message(line) : args.json ? ctx.stderr : ctx.stdout,
565
+ stderr: deps.ui ? (line) => deps.ui.message(line) : ctx.stderr,
633
566
  fetch: ((input, init) => ctx.fetch(input, {
634
- ...init,
635
- signal: init?.signal ? AbortSignal.any([signal, init.signal]) : signal,
636
- })),
637
- };
567
+ ...init, signal: init?.signal ? AbortSignal.any([signal, init.signal]) : signal,
568
+ })) };
638
569
  let current = null;
639
570
  const interrupted = () => {
640
571
  stop.abort();
641
572
  if (current)
642
- setStep(journal, {
643
- id: current,
644
- state: "failed",
645
- reason: "interrupted",
646
- at: isoNow(ctx, deps),
647
- });
573
+ setStep(journal, { id: current, state: "failed", reason: "interrupted", at: isoNow(ctx, deps) });
648
574
  journal.exit = EXIT_WAITING;
649
575
  journal.complete = false;
650
576
  try {
651
577
  writeOnboardJournal(statePath, journal);
652
578
  }
653
- catch {
654
- /* keep the last atomic receipt */
655
- }
579
+ catch { /* keep the last atomic receipt */ }
656
580
  };
657
581
  const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
658
582
  if (deps.bindSignals !== false)
@@ -664,26 +588,27 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.14.0") {
664
588
  writeOnboardJournal(statePath, journal);
665
589
  if (args.json)
666
590
  ctx.stdout(JSON.stringify(journal));
591
+ else if (deps.ui)
592
+ deps.ui.finish(journal, only);
667
593
  else {
668
594
  if (journal.complete)
669
595
  ctx.stdout("Onboarding complete.");
670
596
  else if (only && code === 0)
671
597
  ctx.stdout(`${ONBOARD_TITLES[only]} finished. Onboarding still has other steps.`);
672
598
  else
673
- ctx.stdout(`Setup still needs ${journal.steps.filter((step) => !stepSatisfied(step)).length} checks.`);
599
+ ctx.stdout(`Setup still needs ${journal.steps.filter(step => !stepSatisfied(step)).length} checks.`);
674
600
  ctx.stdout("resume: catalyst onboard");
675
601
  }
676
602
  return code;
677
603
  };
678
604
  try {
679
- journal =
680
- readOnboardJournal(statePath, cliVersion, (deps.now ?? ctx.now)()) ??
681
- journal;
605
+ journal = readOnboardJournal(statePath, cliVersion, (deps.now ?? ctx.now)()) ?? journal;
682
606
  if (deps.identity)
683
607
  requireMatchingIdentity(journal, identity);
684
608
  journal.cli = cliVersion;
685
609
  journal.scope = only ? "step" : "onboarding";
686
610
  journal.mode = "run";
611
+ journal.localSync = localSync;
687
612
  for (const id of ONBOARD_STEPS)
688
613
  if (!journalStep(journal, id))
689
614
  setStep(journal, { id, state: "pending" });
@@ -704,119 +629,104 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.14.0") {
704
629
  for (const id of ONBOARD_STEPS)
705
630
  needed.add(id);
706
631
  const fromIndex = resumeFrom ? ONBOARD_STEPS.indexOf(resumeFrom) : 0;
707
- for (const id of ONBOARD_STEPS.filter((id) => needed.has(id))) {
632
+ for (const id of ONBOARD_STEPS.filter(id => needed.has(id))) {
708
633
  if (signal.aborted)
709
634
  return finish(EXIT_WAITING);
710
635
  current = id;
636
+ deps.ui?.stepStart(id);
711
637
  // Member onboarding keeps administration outside its scope, with an explicit recorded reason.
712
- if (identity?.role === "member" && ADMIN_STEPS.has(id)) {
713
- setStep(journal, {
714
- id,
715
- state: "skipped",
716
- reason: "member_scope",
717
- at: isoNow(ctx, deps),
718
- });
638
+ if (identity?.role === "member" && memberScopeSkips(id)) {
639
+ setStep(journal, { id, state: "skipped", reason: "member_scope", at: isoNow(ctx, deps) });
640
+ deps.ui?.stepEnd(journalStep(journal, id));
719
641
  continue;
720
642
  }
721
- const missing = (ONBOARD_DEPENDENCIES[id] ?? []).find((parent) => !stepSatisfied(journalStep(journal, parent)));
643
+ const missing = (ONBOARD_DEPENDENCIES[id] ?? []).find(parent => !stepSatisfied(journalStep(journal, parent)));
722
644
  if (missing) {
723
- setStep(journal, {
724
- id,
725
- state: "waiting",
726
- reason: "prerequisite_not_ready",
727
- at: isoNow(ctx, deps),
728
- });
645
+ setStep(journal, { id, state: "waiting", reason: "prerequisite_not_ready", at: isoNow(ctx, deps) });
729
646
  writeOnboardJournal(statePath, journal);
647
+ deps.ui?.stepEnd(journalStep(journal, id));
730
648
  continue;
731
649
  }
732
650
  let adapter = deps.adapters?.[id];
733
651
  // Compatibility for the initial legacy adapter, which performs its own before/after checks.
734
652
  if (!adapter && id === "legacy") {
735
- let result = deps.runStep
736
- ? { state: "pending" }
737
- : { state: "skipped", reason: "legacy_not_selected" };
738
- adapter = {
739
- check: async () => result,
740
- act: deps.runStep
741
- ? async () => (result = await deps.runStep(id, stepCtx))
742
- : undefined,
743
- };
653
+ let result = deps.runStep ? { state: "pending" } : { state: "skipped", reason: "legacy_not_selected" };
654
+ adapter = { check: async () => result, act: deps.runStep ? async () => result = await deps.runStep(id, stepCtx) : undefined };
744
655
  }
745
656
  if (!adapter) {
746
- setStep(journal, {
747
- id,
748
- state: "waiting",
749
- reason: "step_not_available_in_this_release",
750
- at: isoNow(ctx, deps),
751
- });
657
+ setStep(journal, { id, state: "waiting", reason: "step_not_available_in_this_release", at: isoNow(ctx, deps) });
752
658
  writeOnboardJournal(statePath, journal);
659
+ deps.ui?.stepEnd(journalStep(journal, id));
753
660
  continue;
754
661
  }
755
662
  try {
756
- let result = await adapter.check(stepCtx, journal);
757
- if (result.state === "pending" &&
758
- adapter.act &&
759
- (!only || id === only) &&
760
- ONBOARD_STEPS.indexOf(id) >= fromIndex) {
663
+ let result = await adapter.check(stepCtx, journal, signal);
664
+ if (result.state === "pending" && adapter.act && (!only || id === only) && ONBOARD_STEPS.indexOf(id) >= fromIndex) {
761
665
  if (deps.identity) {
762
- identity = await deps.identity();
666
+ identity = await deps.identity(journal);
763
667
  requireMatchingIdentity(journal, identity);
668
+ if (identity?.role === "member" && memberScopeSkips(id)) {
669
+ setStep(journal, { id, state: "skipped", reason: "member_scope", at: isoNow(ctx, deps) });
670
+ writeOnboardJournal(statePath, journal);
671
+ deps.ui?.stepEnd(journalStep(journal, id));
672
+ continue;
673
+ }
764
674
  }
765
675
  journal.operations[id] ??= `${journal.runId}:${id}`;
766
676
  setStep(journal, { id, state: "running", at: isoNow(ctx, deps) });
767
677
  journal.exit = null;
768
678
  writeOnboardJournal(statePath, journal);
769
- const action = await adapter.act(stepCtx, journal);
679
+ const action = await adapter.act(stepCtx, journal, signal);
770
680
  if (action.state === "done") {
771
- result = await adapter.check(stepCtx, journal);
681
+ result = await adapter.check(stepCtx, journal, signal);
772
682
  if (result.state === "pending")
773
683
  result = { state: "waiting", reason: "verification_pending" };
774
684
  }
775
685
  else
776
686
  result = action;
777
687
  if (id === "signin" && result.state === "done" && deps.identity) {
778
- identity = await deps.identity();
688
+ identity = await deps.identity(journal);
779
689
  requireMatchingIdentity(journal, identity);
690
+ if (identity?.role === "member" && memberScopeSkips(id)) {
691
+ setStep(journal, { id, state: "skipped", reason: "member_scope", at: isoNow(ctx, deps) });
692
+ writeOnboardJournal(statePath, journal);
693
+ deps.ui?.stepEnd(journalStep(journal, id));
694
+ continue;
695
+ }
780
696
  }
781
697
  }
698
+ if (id === "signin" && result.state === "done" && deps.identity) {
699
+ identity = await deps.identity(journal);
700
+ requireMatchingIdentity(journal, identity);
701
+ if (!identity)
702
+ result = { state: "failed", reason: "membership_not_verified" };
703
+ }
782
704
  if (result.state === "refused")
783
705
  refused = true;
784
706
  if (result.state === "pending")
785
- result = {
786
- ...result,
787
- state: "waiting",
788
- reason: result.reason ?? "action_required",
789
- };
707
+ result = { ...result, state: "waiting", reason: result.reason ?? "action_required" };
790
708
  setStep(journal, sanitizedResult(id, result, isoNow(ctx, deps)));
791
709
  }
792
710
  catch (error) {
793
711
  if (error instanceof CliError && error.exitCode === EXIT_REFUSED)
794
712
  refused = true;
795
- const reason = error instanceof CliError &&
796
- /^[a-z][a-z0-9_-]{1,63}$/.test(error.code)
797
- ? error.code.replaceAll("-", "_")
798
- : "step_failed";
799
- setStep(journal, {
800
- id,
801
- state: "failed",
802
- reason,
803
- at: isoNow(ctx, deps),
804
- });
713
+ const reason = signal.aborted ? "interrupted" : error instanceof CliError && /^[a-z][a-z0-9_-]{1,63}$/.test(error.code) ? error.code.replaceAll("-", "_") : "step_failed";
714
+ setStep(journal, { id, state: "failed", reason, at: isoNow(ctx, deps) });
805
715
  stepCtx.stderr(`✗ ${ONBOARD_TITLES[id]}: ${reason}. The safe reason is saved; run the same command to resume.`);
806
716
  }
717
+ if (signal.aborted) {
718
+ setStep(journal, { id, state: "failed", reason: "interrupted", at: isoNow(ctx, deps) });
719
+ return finish(EXIT_WAITING);
720
+ }
721
+ deps.ui?.stepEnd(journalStep(journal, id));
807
722
  writeOnboardJournal(statePath, journal);
808
723
  if (refused)
809
724
  break;
810
725
  }
811
726
  current = null;
812
727
  const scope = only ? [only] : [...ONBOARD_STEPS];
813
- const code = refused
814
- ? EXIT_REFUSED
815
- : scope.some((id) => journalStep(journal, id)?.state === "failed")
816
- ? EXIT_FAILED
817
- : scope.every((id) => stepSatisfied(journalStep(journal, id)))
818
- ? 0
819
- : EXIT_WAITING;
728
+ const code = refused ? EXIT_REFUSED : scope.some(id => journalStep(journal, id)?.state === "failed") ? EXIT_FAILED
729
+ : scope.every(id => stepSatisfied(journalStep(journal, id))) ? 0 : EXIT_WAITING;
820
730
  return finish(code);
821
731
  }
822
732
  catch (error) {
@@ -832,5 +742,6 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.14.0") {
832
742
  for (const name of signals)
833
743
  process.off(name, interrupted);
834
744
  releaseLock(lock);
745
+ deps.ui?.dispose();
835
746
  }
836
747
  }