@catalyst-cloud/cli 0.15.1 → 0.15.3

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 (40) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +13 -4
  3. package/dist/args.js +1 -1
  4. package/dist/capabilities.js +3 -1
  5. package/dist/cli.js +25 -30
  6. package/dist/consent-browser.js +3 -1
  7. package/dist/oauth.js +1 -1
  8. package/dist/onboard-accounts.js +67 -171
  9. package/dist/onboard-checklist.js +309 -0
  10. package/dist/onboard-existing.js +1 -1
  11. package/dist/onboard-first-ticket.js +199 -0
  12. package/dist/onboard-github.js +13 -1
  13. package/dist/onboard-headless.js +6 -4
  14. package/dist/onboard-housekeeping.js +93 -0
  15. package/dist/onboard-next.js +124 -13
  16. package/dist/onboard-ready.js +61 -0
  17. package/dist/onboard-runtime.js +117 -26
  18. package/dist/onboard-standalone-copy.js +73 -66
  19. package/dist/onboard-ui.js +189 -81
  20. package/dist/onboard-values.js +47 -0
  21. package/dist/onboard.js +59 -18
  22. package/dist/ready-copy.js +325 -0
  23. package/dist/ready.js +35 -98
  24. package/dist/setup-onboard-copy.js +262 -41
  25. package/dist/setup-render.js +73 -14
  26. package/dist/setup.js +6 -3
  27. package/package.json +2 -2
  28. package/skills/catalyst-github/SKILL.md +1 -1
  29. package/skills/catalyst-linear/SKILL.md +1 -1
  30. package/skills/catalyst-onboard/SKILL.md +3 -3
  31. package/skills/catalyst-onboard/references/replacing-a-credential.md +9 -15
  32. package/skills/catalyst-onboard/references/what-a-phase-needs.md +4 -11
  33. package/skills/catalyst-onboard/scripts/where-am-i.mjs +3 -2
  34. package/skills/run-this-project/SKILL.md +2 -2
  35. package/skills/unstick/SKILL.md +1 -1
  36. package/skills/what-needs-me/SKILL.md +1 -1
  37. package/skills/what-this-repo-needs/SKILL.md +1 -1
  38. package/skills/whats-happening/SKILL.md +2 -2
  39. package/skills/whats-happening/references/coding-accounts.md +1 -1
  40. package/skills/whats-happening/scripts/snapshot.mjs +1 -1
@@ -0,0 +1,93 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { accessSync, constants, existsSync } from "node:fs";
3
+ import { delimiter, join } from "node:path";
4
+ /** The names install.sh schedules the daily update under (catalyst-cloud's install-script.ts). */
5
+ export const HOUSEKEEPING_LABEL = "dev.catalystcloud.housekeeping";
6
+ export const HOUSEKEEPING_TIMER = "catalyst-housekeeping.timer";
7
+ const defaultRun = (cmd, args) => {
8
+ const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 5_000 });
9
+ return {
10
+ status: r.status ?? 1,
11
+ stdout: r.stdout ?? "",
12
+ stderr: r.error ? r.error.message : (r.stderr ?? ""),
13
+ };
14
+ };
15
+ function onPath(name, env) {
16
+ return (env.PATH ?? process.env.PATH ?? "")
17
+ .split(delimiter)
18
+ .filter(Boolean)
19
+ .some((dir) => {
20
+ try {
21
+ accessSync(join(dir, name), constants.X_OK);
22
+ return true;
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ });
28
+ }
29
+ /** The installer's own test for "systemctl is here but nothing manages user services". */
30
+ const NO_USER_MANAGER = /failed to connect to bus: (no medium found|no such file or directory)|system has not been booted with systemd/i;
31
+ /** Every answer but "scheduled" is a skip: the daily update is optional, so it never holds setup
32
+ * or becomes its next action. The reason keeps which case it is. */
33
+ const skipped = (reason, path) => ({
34
+ state: "skipped",
35
+ reason,
36
+ ...(path ? { evidence: { path } } : {}),
37
+ });
38
+ /** CTC-4680: the daily update step reports what this computer actually has. It only observes:
39
+ * scheduling stays the installer's job. */
40
+ export function onboardHousekeepingAdapter(options = {}) {
41
+ const run = options.run ?? defaultRun;
42
+ return {
43
+ check: async (ctx, journal) => {
44
+ const platform = options.platform ?? process.platform;
45
+ const choice = journal.dailyUpdate;
46
+ const notChosen = () => choice?.state === "off"
47
+ ? skipped("housekeeping_off_chosen")
48
+ : choice?.state === "skipped"
49
+ ? skipped("housekeeping_no_scheduler")
50
+ : undefined;
51
+ if (platform === "darwin") {
52
+ if (!onPath("launchctl", ctx.env))
53
+ return notChosen() ?? skipped("housekeeping_no_scheduler");
54
+ const plist = join(ctx.home, "Library", "LaunchAgents", `${HOUSEKEEPING_LABEL}.plist`);
55
+ if (!existsSync(plist))
56
+ return notChosen() ?? skipped("housekeeping_not_scheduled");
57
+ const uid = options.uid ?? process.getuid?.() ?? 0;
58
+ // `print` answers for a loaded job only; the plist alone schedules nothing.
59
+ if (run("launchctl", ["print", `gui/${uid}/${HOUSEKEEPING_LABEL}`]).status === 0)
60
+ return {
61
+ state: "done",
62
+ evidence: { scheduled: true, provider: "launchd", path: plist },
63
+ };
64
+ return notChosen() ?? skipped("housekeeping_not_loaded", plist);
65
+ }
66
+ if (platform === "linux") {
67
+ if (!onPath("systemctl", ctx.env))
68
+ return notChosen() ?? skipped("housekeeping_no_scheduler");
69
+ const timer = join(ctx.env.XDG_CONFIG_HOME ?? join(ctx.home, ".config"), "systemd", "user", HOUSEKEEPING_TIMER);
70
+ if (existsSync(timer)) {
71
+ const enabled = run("systemctl", ["--user", "is-enabled", HOUSEKEEPING_TIMER]);
72
+ if (enabled.status === 0 && enabled.stdout.trim() === "enabled")
73
+ return {
74
+ state: "done",
75
+ evidence: { scheduled: true, provider: "systemd", path: timer },
76
+ };
77
+ if (NO_USER_MANAGER.test(enabled.stderr))
78
+ return notChosen() ?? skipped("housekeeping_no_scheduler");
79
+ return notChosen() ?? skipped("housekeeping_not_enabled", timer);
80
+ }
81
+ const chosen = notChosen();
82
+ if (chosen)
83
+ return chosen;
84
+ // A systemctl binary is not a scheduler: a container or WSL has it with no user manager.
85
+ const manager = run("systemctl", ["--user", "show-environment"]);
86
+ return manager.status !== 0 && NO_USER_MANAGER.test(manager.stderr)
87
+ ? skipped("housekeeping_no_scheduler")
88
+ : skipped("housekeeping_not_scheduled");
89
+ }
90
+ return notChosen() ?? skipped("housekeeping_no_scheduler");
91
+ },
92
+ };
93
+ }
@@ -1,27 +1,35 @@
1
1
  import { nativeEgressInstallCommands } from "./onboard-runner-egress.js";
2
- import { loadConfig, normalizeBaseUrl } from "./config.js";
2
+ import { INSTALL_COMMAND, loadConfig, normalizeBaseUrl } from "./config.js";
3
3
  import { githubInstallationPage } from "./onboard-permissions.js";
4
+ import { valuesFacts, valuesFix } from "./ready-copy.js";
4
5
  /** What a person reads for a step that is not done. Each line ends in an action they can take. */
5
6
  const REASONS = {
6
- account_identity_unverified: "Your login changed while coding accounts were checked. Run catalyst onboard again to verify your person and workspace.",
7
- account_inventory_unavailable: "Coding accounts could not be read. Run catalyst onboard again to retry.",
8
- account_inventory_unverified: "The server did not return a fresh supported coding-account list. Run catalyst onboard after the server update.",
9
- coding_account_not_found: "The coding account named by --coding-account is not enrolled in this workspace. Run catalyst accounts to list the slots, then run catalyst onboard with one of them.",
10
- account_validation_admin_required: "An administrator must check the workspace's coding-account access.",
7
+ account_identity_unverified: "Your login changed while AI accounts were checked. Run catalyst onboard again to verify your person and workspace.",
8
+ account_inventory_unavailable: "AI accounts could not be read. Run catalyst onboard again to retry.",
9
+ account_inventory_unverified: "The server did not return a fresh supported AI account list. Run catalyst onboard after the server update.",
10
+ connect_checklist_refused: "Catalyst refused to show this login the Connect accounts page's status. Sign in again with catalyst login, then run catalyst onboard.",
11
+ connect_checklist_identity_unverified: "Your login changed while setup was checking the Connect accounts page. Run catalyst onboard again to verify your person and workspace.",
12
+ coding_account_inactive: "The AI account named by --coding-account is turned off. Turn it on in the workspace's AI account settings, then run catalyst onboard.",
13
+ coding_account_ended: "The AI account named by --coding-account was removed or its access has ended. Name another one with --coding-account, or add one in the workspace's AI account settings.",
14
+ coding_account_needs_login: "The AI account named by --coding-account needs its key added again. Replace it in the workspace's AI account settings, then run catalyst onboard.",
15
+ coding_account_quarantined: "The AI account named by --coding-account is held after repeated failures. Check it in the workspace's AI account settings, then run catalyst onboard.",
16
+ coding_account_walled: "The AI account named by --coding-account has used its usage limit for now. Check when it resets, then run catalyst onboard again.",
17
+ coding_account_not_found: "The AI account named by --coding-account is not in this workspace. Run catalyst accounts to list the slots, then run catalyst onboard with one of them.",
18
+ account_validation_admin_required: "An administrator must check the workspace's AI account access.",
11
19
  account_login_refresh_required: "Renew your login with catalyst login, then run catalyst onboard.",
12
20
  account_validation_unavailable: "The provider check could not finish. Run catalyst onboard again to retry.",
13
21
  account_validation_unverified: "The server did not return a fresh provider check. Run catalyst onboard after the server update.",
14
- account_provider_access_unverified: "Coding-account provider access is not freshly verified. Check the account in the workspace's coding-account settings, then resume setup.",
22
+ account_provider_access_unverified: "AI account access is not freshly verified. Check the account in the workspace's AI account settings, then resume setup.",
15
23
  codex_provider_access_unverified: "Codex credentials are stored, but provider access is not freshly verified. Setup did not refresh or rotate the Codex login.",
16
24
  account_provider_walled: "Claude's usage limit is spent for now. Check the account's reset time, then run catalyst onboard again.",
17
- account_provider_rejected: "Claude refused the stored login. Replace it in the workspace's coding-account settings, then resume setup.",
25
+ account_provider_rejected: "Claude refused the stored login. Replace it in the workspace's AI account settings, then resume setup.",
18
26
  personal_consent_handoff: "The browser approval link could not be verified. Run catalyst onboard to try again.",
19
27
  personal_identity_refused: "Your current login no longer matches this setup. Resume with the original workspace and person.",
20
28
  personal_login_refresh_required: "Renew your login with catalyst login, then run catalyst onboard to resume.",
21
29
  onboard_login_refresh_required: "Renew your login with catalyst login, then run catalyst onboard to resume.",
22
30
  personal_status_unavailable: "Your personal connection could not be checked. Run catalyst onboard to try again.",
23
- workspace_browser_unavailable: "This computer could not open a browser. Connect Linear from this workspace’s Integrations page in your browser, then run catalyst onboard to resume.",
24
- personal_browser_unavailable: "This computer could not open a browser. Finish this login’s connection from Connected accounts in your browser, then run catalyst onboard to resume.",
31
+ workspace_browser_unavailable: "The link to connect Linear timed out. Run catalyst setup for a new one.",
32
+ personal_browser_unavailable: "The link to connect your own account timed out. Run catalyst setup for a new one.",
25
33
  onboarding_capability_unavailable: "The server's setup capabilities could not be checked. Run catalyst onboard to try again.",
26
34
  onboarding_capability_identity_unverified: "The server's setup capabilities belong to another login or workspace. Sign in to the original workspace to resume.",
27
35
  onboarding_capability_login_refresh_required: "Your login needs a refresh before setup capabilities can be checked. Run catalyst login, then catalyst onboard.",
@@ -34,13 +42,17 @@ const REASONS = {
34
42
  github_installation_identity_refused: "GitHub setup belongs to another login or workspace. Sign in to the original workspace to resume.",
35
43
  github_installation_admin_required: "A workspace owner or administrator approves the GitHub App installation.",
36
44
  github_installation_login_refresh_required: "Renew your login with catalyst login, then run catalyst onboard to resume.",
37
- github_installation_browser_unavailable: "This computer could not open a browser. Install the GitHub App from this workspace’s Integrations page in your browser, then run catalyst onboard to resume.",
45
+ github_installation_browser_unavailable: "The link to install Catalyst on GitHub timed out. Run catalyst setup for a new one.",
46
+ github_installation_approval_pending: "An install request is waiting on GitHub. An owner of your GitHub organization approves it there, then run catalyst setup.",
47
+ github_install_pending: "Runs after Catalyst is installed on GitHub.",
48
+ consent_timeout: "The link timed out. Run catalyst setup for a new one.",
38
49
  skills_install_unverified: "The Catalyst skills are not in this user's skills folder. Run catalyst install, then run catalyst onboard.",
39
50
  step_not_available_in_this_release: "This setup step is not available yet.",
40
51
  prerequisite_not_ready: "Waiting for an earlier setup step.",
41
52
  local_sync_not_selected: "Using cloud reads. Local sync was not selected.",
42
53
  local_sync_capability_unavailable: "Local sync was selected but could not be verified.",
43
54
  member_scope: "Your workspace administrator handles this step.",
55
+ member_team_required: "Setup needs your Linear team to check what Catalyst can work on. Run catalyst setup again with --team <KEY>, using your team's key from Linear.",
44
56
  onboarding_checks_pending: "Some required checks are still unverified.",
45
57
  interrupted: "Setup paused. Run the same command to resume.",
46
58
  signin_timeout: "Sign-in timed out. Run catalyst onboard to try again.",
@@ -135,7 +147,15 @@ const REASONS = {
135
147
  linear_workspace_permissions_unverified: "The workspace's Linear connection works, but its granted scopes could not be checked. Run catalyst onboard to try again.",
136
148
  linear_personal_scope_outdated: "Your personal Linear connection is missing scopes this version needs. Re-authorize it from Connected accounts, then run catalyst onboard.",
137
149
  personal_permissions_unverified: "Your personal connection's granted scopes could not be checked. Run catalyst onboard to try again.",
150
+ required_values_unverified: "Catalyst has not read the repository's settings yet. Run catalyst setup again in a few minutes.",
151
+ first_ticket_team_unverified: "Setup could not confirm the selected Linear team. Run catalyst setup again.",
152
+ first_ticket_dispatch_unmapped: "Catalyst does not know which of the team's stages starts its work yet. Set up the team's Catalyst workflow, then run catalyst setup again.",
138
153
  housekeeping_service_unverified: "The daily update needs a user service manager, launchd on macOS or systemd --user on Linux. Setup cannot schedule it on this computer. It is optional, and work does not depend on it.",
154
+ housekeeping_off_chosen: "The daily update is off, as you chose when you installed. It is optional. catalyst says when an update is out.",
155
+ housekeeping_no_scheduler: "The daily update can't run on this computer: it needs launchd on macOS or systemd --user on Linux. It is optional. catalyst says when an update is out.",
156
+ housekeeping_not_loaded: "The daily update is installed but not turned on. Run launchctl load ~/Library/LaunchAgents/dev.catalystcloud.housekeeping.plist to turn it on.",
157
+ housekeeping_not_enabled: "The daily update is installed but not turned on. Run systemctl --user enable --now catalyst-housekeeping.timer to turn it on.",
158
+ housekeeping_not_scheduled: `The daily update is not scheduled on this computer. Run the installer again to schedule it: ${INSTALL_COMMAND} -s -- --housekeeping yes`,
139
159
  };
140
160
  /** The saved login's web address, or nothing when the config is missing, unreadable or not http(s). */
141
161
  export function savedOnboardBaseUrl(home) {
@@ -258,6 +278,26 @@ export function onboardStepDetail(step) {
258
278
  : undefined;
259
279
  return granted ? `granted ${granted}` : undefined;
260
280
  }
281
+ /** The dispatch stage's name as the contract gave it, or what it does when unknown. */
282
+ export function dispatchStageName(value) {
283
+ return typeof value === "string" && /^[\p{L}\p{N}][\p{L}\p{N} ._&'()-]{0,59}$/u.test(value)
284
+ ? value
285
+ : "the stage that starts Catalyst's work";
286
+ }
287
+ function firstTicketText(reason, key, stage) {
288
+ const move = `move one of ${key ? `${key}'s` : "your team's"} tickets to ${dispatchStageName(stage)} in Linear`;
289
+ switch (reason) {
290
+ case "first_ticket_choice_required":
291
+ return `${move.charAt(0).toUpperCase()}${move.slice(1)}. Catalyst picks it up next.`;
292
+ case "first_ticket_move_refused":
293
+ return `Catalyst could not move the ticket for you. Ask a Catalyst owner or admin to check its Linear connection, or ${move} yourself.`;
294
+ case "first_ticket_unavailable":
295
+ return `Setup could not read or move the team's tickets. Run catalyst setup to try again, or ${move} yourself.`;
296
+ case "first_ticket_unverified":
297
+ return `Setup could not confirm which ticket moved. Check ${dispatchStageName(stage)} in Linear before choosing again.`;
298
+ }
299
+ return undefined;
300
+ }
261
301
  /** One step's next action. `waitsFor` names the unfinished step a prerequisite wait is blocked on. */
262
302
  export function onboardReasonText(step, context = {}) {
263
303
  const reason = step.reason ?? "";
@@ -269,13 +309,52 @@ export function onboardReasonText(step, context = {}) {
269
309
  const path = reason === "workspace_approval_required"
270
310
  ? "/settings/connections?connect=linear"
271
311
  : reason === "github_installation_approval_required"
272
- ? "/settings/connections?install=github"
312
+ ? "/connect/github/start"
273
313
  : `/settings/connected-accounts?connect=${step.id === "github.personal" ? "github" : "linear"}`;
274
314
  const where = context.baseUrl
275
315
  ? `${normalizeBaseUrl(context.baseUrl)}${path}`
276
316
  : "Catalyst settings";
277
317
  return `Open ${where} and approve this connection, then run catalyst onboard.`;
278
318
  }
319
+ // CTC-4680: only a Catalyst owner or admin can start the GitHub install (the start route refuses
320
+ // anyone else), so a member is told who to ask and where, on every channel.
321
+ if (step.id === "github.install" &&
322
+ (reason === "github_installation_admin_required" || reason === "member_scope"))
323
+ return `Only an owner or admin of your Catalyst workspace can install Catalyst on GitHub. Ask one to open ${context.baseUrl ? `${normalizeBaseUrl(context.baseUrl)}/settings/connections` : "Catalyst settings, Integrations,"} and install it there.`;
324
+ if (reason === "admin_setup_pending") {
325
+ const words = {
326
+ "linear.workspace": "connect Linear",
327
+ "github.install": "install Catalyst on GitHub",
328
+ "linear.adopt": "set up the team's Catalyst workflow",
329
+ };
330
+ const left = String(step.evidence?.admin ?? "")
331
+ .split(",")
332
+ .flatMap((id) => (words[id] ? [words[id]] : []));
333
+ const list = left.length > 1 ? `${left.slice(0, -1).join(", ")} and ${left.at(-1)}` : (left[0] ?? "finish setup");
334
+ return `A Catalyst owner or admin still has to ${list}. Ask one to open ${context.baseUrl ? `${normalizeBaseUrl(context.baseUrl)}/settings/connections` : "Catalyst settings"}, then run catalyst setup.`;
335
+ }
336
+ // A member is told which admin action is left and where an admin takes it.
337
+ if (reason === "member_scope") {
338
+ const key = onboardTeamKey(context.journal);
339
+ const adminOnly = {
340
+ "linear.workspace": ["connect Linear", "/settings/connections", " and connect it there"],
341
+ "linear.adopt": [
342
+ "set up the team's Catalyst workflow",
343
+ key ? `/settings/linear-teams/${encodeURIComponent(key)}/adopt` : "/settings/linear-teams",
344
+ "",
345
+ ],
346
+ };
347
+ const admin = adminOnly[step.id];
348
+ if (admin) {
349
+ const [what, path, rest] = admin;
350
+ const url = context.baseUrl ? `${normalizeBaseUrl(context.baseUrl)}${path}` : "Catalyst settings";
351
+ return `Only a Catalyst owner or admin can ${what}. Ask one to open ${url}${rest}.`;
352
+ }
353
+ }
354
+ // An install request can sit on GitHub for good (a wrong organization, or an owner who said no),
355
+ // so the step always keeps a way to install it directly.
356
+ if (reason === "github_installation_approval_pending" && context.baseUrl)
357
+ return `${REASONS[reason] ?? ""} If you own the organization, or want a different one, install it at ${normalizeBaseUrl(context.baseUrl)}/connect/github/start.`;
279
358
  if (reason === "workflow_mapping_unverified")
280
359
  return `This team's workflow mapping is not verified yet. Run catalyst team adopt ${onboardTeamKey(context.journal) ?? "<TEAM KEY>"} to preview the adoption plan. Run it again with the --yes --plan-hash value it prints. Then run catalyst onboard.`;
281
360
  if (reason === "team_created_not_adopted" ||
@@ -290,11 +369,22 @@ export function onboardReasonText(step, context = {}) {
290
369
  if (team)
291
370
  return `Setup could not confirm whether Linear created the team ${team}. Check Linear for it. If it is there: ${adopt(team)} If it is not, run catalyst onboard to create it again.`;
292
371
  }
372
+ if (reason === "ai_account_not_usable") {
373
+ const where = context.baseUrl
374
+ ? `at ${normalizeBaseUrl(context.baseUrl)}/settings/coding-accounts`
375
+ : "in the web app on the Settings → AI accounts page";
376
+ return `No AI account in this workspace can take work yet. A workspace owner or administrator checks them ${where}: one may need its key replaced, or may have reached its usage limit. Then run catalyst onboard.`;
377
+ }
293
378
  if (reason === "account_enrollment_required") {
294
379
  const where = context.baseUrl
295
380
  ? `at ${normalizeBaseUrl(context.baseUrl)}/settings/coding-accounts, the Settings → AI accounts page`
296
381
  : "in the web app on the Settings → AI accounts page";
297
- return `No coding account is enrolled. A workspace owner or administrator adds one ${where}. A Claude account needs its email and the token printed by \`claude setup-token\`. Then run catalyst onboard.`;
382
+ // CTC-4716: subscriptions are offered only to a workspace the cloud enables for them.
383
+ const kinds = step.evidence?.aiAccountKinds;
384
+ const what = typeof kinds === "string" && kinds.split(",").includes("subscription")
385
+ ? "an API key, billed per token, or a subscription, a fixed monthly plan"
386
+ : "an API key, billed per token";
387
+ return `No AI account is added yet. A workspace owner or administrator adds one ${where}: ${what}. Then run catalyst onboard.`;
298
388
  }
299
389
  if (reason === "automation_management_unavailable") {
300
390
  const conflicts = typeof step.evidence?.automations === "string" &&
@@ -345,6 +435,27 @@ export function onboardReasonText(step, context = {}) {
345
435
  }
346
436
  if (reason === "runner_admission_operator")
347
437
  return `The admission policy for team ${onboardTeamKey(context.journal) ?? "<TEAM KEY>"} needs a Catalyst operator to check it. Setup changes only restricted admission and preserves existing policy fields. Ask Catalyst support to confirm a compatible policy, then run the same setup command.`;
438
+ if (reason === "required_values_missing") {
439
+ let facts;
440
+ try {
441
+ facts = JSON.parse(String(step.evidence?.requiredValues ?? "null"));
442
+ }
443
+ catch {
444
+ facts = null;
445
+ }
446
+ const fix = valuesFix(context.baseUrl, valuesFacts(facts)) ??
447
+ valuesFix(context.baseUrl, { names: ["the missing values"] });
448
+ // A member cannot set a value; the line says who does, and still never prints one.
449
+ const member = context.journal?.steps.find((row) => row.id === "signin")?.evidence?.role === "member";
450
+ if (member)
451
+ return fix.startsWith("set ")
452
+ ? `Ask a Catalyst owner or admin to ${fix}. Then run catalyst setup.`
453
+ : `${fix.charAt(0).toUpperCase()}${fix.slice(1)}. Ask a Catalyst owner or admin to fix it, then run catalyst setup.`;
454
+ return `${fix.charAt(0).toUpperCase()}${fix.slice(1)}. Then run catalyst setup.`;
455
+ }
456
+ const firstTicket = firstTicketText(reason, onboardTeamKey(context.journal), step.evidence?.stage);
457
+ if (step.id === "first-ticket" && firstTicket)
458
+ return firstTicket;
348
459
  if (reason === "prerequisite_not_ready" && context.waitsFor)
349
460
  return `Runs after "${context.waitsFor}".`;
350
461
  const permission = permissionText(step, context.baseUrl);
@@ -1,5 +1,6 @@
1
1
  import { loadConfig } from "./config.js";
2
2
  import { loadContract } from "./contract.js";
3
+ import { ONBOARD_DEFERRED_STEPS, onboardRequiredStepsWaiting, } from "./onboard.js";
3
4
  const evidenceNames = new Set(["account", "membershipId", "project", "team", "repository", "count", "checkedAt", "expiresAt", "revision", "lag", "cursor", "heartbeatAgeMs", "supervised", "available", "validated", "phaseStarted", "linearComment", "fleetActivity"]);
4
5
  const slug = (value) => value && /^[a-z][a-z0-9_]{1,63}$/.test(value) ? value : undefined;
5
6
  function cleanCheck(check) {
@@ -32,6 +33,66 @@ export async function onboardingReadyReport(ctx, deps) {
32
33
  : required.length === 0 || required.some(check => check.state === "unknown") || work.state === "unknown" ? "unknown" : "complete";
33
34
  return { schema: 1, readMode: deps.localSync ? "local" : "cloud", state, checks, work };
34
35
  }
36
+ /** Team check outcomes that do not hold setup's readiness, as `check:reason`: the cloud's four
37
+ * WAITING_PAIRS (nothing has happened yet before a first ticket: a webhook delivery, a write, a host
38
+ * attaching, a reviewer's first answer), plus two of setup's own. A missing value is the values
39
+ * step's action; a reference with no value is not here, because the checkout refuses it before any
40
+ * work starts. A team with no reviewer still runs. */
41
+ const READY_EXEMPT = new Set([
42
+ "webhook_covers_team:delivery_window_empty",
43
+ "writes_land:no_write_observed",
44
+ "hosts_current:no_host_connected",
45
+ "reviewer_answering:reviewer_not_yet_answered",
46
+ "required_values:required_value_missing",
47
+ "reviewer_configured:no_reviewer_configured",
48
+ ]);
49
+ /** Admin-owned steps a member's run skips, and the team checks that show an admin finished them. A
50
+ * member's skip is not a finished setup: readiness holds until these checks pass. */
51
+ const ADMIN_OWNED = [
52
+ ["linear.workspace", ["oauth_scope", "token_live"]],
53
+ ["github.install", ["github_app_installed"]],
54
+ ["linear.adopt", ["mapping_total", "mapped_states_exist"]],
55
+ ];
56
+ /** The setup step's verdict. Every required step must be satisfied, and every required check must
57
+ * pass or be one of the exempt outcomes above. A failing check fails; anything else unread (a stale
58
+ * or unverified team, a check the cloud could not read, the cloud itself) waits. Work nobody can
59
+ * observe yet does not hold it. */
60
+ export function onboardReadyStep(report, journal) {
61
+ const evidence = {
62
+ checks: report.checks.length,
63
+ passed: report.checks.filter((check) => check.state === "pass").length,
64
+ };
65
+ const failedStep = (journal?.steps ?? []).some((step) => step.id !== "ready" &&
66
+ !ONBOARD_DEFERRED_STEPS.has(step.id) &&
67
+ step.state === "failed");
68
+ if (onboardRequiredStepsWaiting(journal) || failedStep)
69
+ return { state: "waiting", reason: "onboarding_checks_pending", evidence };
70
+ const skipped = (id) => journal?.steps.some((step) => step.id === id && step.state === "skipped" && step.reason === "member_scope");
71
+ // A member's plain run has no team, so nothing can show what an admin finished.
72
+ if (skipped("linear.team"))
73
+ return { state: "waiting", reason: "member_team_required", evidence };
74
+ // Only a check read from a fresh team (`team.<id>.<check>`) that fails counts. An absent, stale or
75
+ // unknown one (an outage, a workflow just adopted) says nothing about the admin step, so it falls
76
+ // through to the waits below.
77
+ const read = new Map(report.checks
78
+ .filter((check) => /^team\.[^.]+\.[a-z0-9_]+$/.test(check.id))
79
+ .map((check) => [check.id.split(".")[2], check.state]));
80
+ const adminLeft = ADMIN_OWNED.filter(([id, checks]) => skipped(id) && checks.some((check) => read.get(check) === "fail")).map(([id]) => id);
81
+ if (adminLeft.length)
82
+ return { state: "waiting", reason: "admin_setup_pending", evidence: { ...evidence, admin: adminLeft.join(",") } };
83
+ const open = report.checks.filter((check) => {
84
+ if (!check.required || check.state === "pass")
85
+ return false;
86
+ // `team.<id>.<check>` is one team check; `team.<id>` alone is the team's whole check list.
87
+ const parts = check.id.split(".");
88
+ return !(parts[0] === "team" && parts.length === 3 && READY_EXEMPT.has(`${parts[2]}:${check.reason}`));
89
+ });
90
+ if (open.some((check) => check.state === "fail"))
91
+ return { state: "failed", reason: "onboarding_checks_pending", evidence };
92
+ if (open.length)
93
+ return { state: "waiting", reason: "onboarding_checks_pending", evidence };
94
+ return { state: "done", evidence };
95
+ }
35
96
  /** The current contract exposes checked project setup, but not fresh starter-ticket proof. */
36
97
  export async function observeCloudOnboarding(ctx, scope = {}) {
37
98
  const cfg = loadConfig(ctx.home);
@@ -15,10 +15,22 @@ import { onboardRunnerAdapter } from "./onboard-runner.js";
15
15
  import { onboardAutomationManagementAdapter } from "./onboard-automation-management.js";
16
16
  import { onboardWorkflowVerificationAdapter } from "./onboard-workflow.js";
17
17
  import { onboardAccountsAdapter } from "./onboard-accounts.js";
18
+ import { onboardFirstTicketAdapter } from "./onboard-first-ticket.js";
19
+ import { onboardValuesAdapter } from "./onboard-values.js";
18
20
  import { githubInstallationAdapter } from "./onboard-github.js";
21
+ import { connectChecklistAdapter, } from "./onboard-checklist.js";
22
+ import { onboardHousekeepingAdapter, } from "./onboard-housekeeping.js";
19
23
  import { guardOnboardCapabilities, } from "./onboard-capabilities.js";
20
24
  import { onboardSettingsAdapter, } from "./onboard-settings.js";
25
+ import { TIMED_OUT_REASONS } from "./setup-onboard-copy.js";
21
26
  import { fetchMe } from "./transport.js";
27
+ const HOLD_ON_TIMEOUT = [
28
+ "linear.workspace",
29
+ "linear.personal",
30
+ "github.install",
31
+ "github.personal",
32
+ "accounts",
33
+ ];
22
34
  const waiting = (reason) => ({
23
35
  state: "waiting",
24
36
  reason,
@@ -136,12 +148,31 @@ export function createOnboardRuntime(args, ctx, hooks) {
136
148
  !args.json &&
137
149
  args.flags.yes !== true &&
138
150
  args.flags.headless !== true;
151
+ // CTC-4680 round 5: whether the step now acting ran a browser wait, so a timed-out wait can be
152
+ // re-offered with a fresh link instead of moving on to steps that need it.
153
+ let waited = false;
154
+ const waitFor = (message, run, page) => {
155
+ waited = true;
156
+ return hooks.ui.wait(message, run, page);
157
+ };
158
+ // CTC-4680: the Connect accounts page opens once per run, whichever step needs it first.
159
+ const connectPage = { openedBy: null };
160
+ const checklistOptions = {
161
+ openBrowser: hooks.openBrowser ?? openConsentBrowser,
162
+ wait: interactiveWait
163
+ ? (message, work, page) => waitFor(message, work, page)
164
+ : undefined,
165
+ note: (text) => hooks.ui?.note ? hooks.ui.note(text) : hooks.ui ? hooks.ui.message(text) : ctx.stderr(text),
166
+ sleep: hooks.sleep,
167
+ page: connectPage,
168
+ cache: { last: null },
169
+ };
139
170
  const personalAdapter = (provider) => personalConsentAdapter({
140
171
  provider,
141
172
  openBrowser: hooks.openBrowser ?? openConsentBrowser,
142
173
  sleep: hooks.sleep,
143
174
  wait: interactiveWait
144
- ? (message, run) => hooks.ui.wait(message, run)
175
+ ? (message, run) => waitFor(message, run)
145
176
  : undefined,
146
177
  });
147
178
  const linear = existingLinearAdapters(args, hooks.ui?.chooseTeam
@@ -218,16 +249,21 @@ export function createOnboardRuntime(args, ctx, hooks) {
218
249
  fallback: linear["linear.workspace"],
219
250
  openBrowser: hooks.openBrowser ?? openConsentBrowser,
220
251
  wait: interactiveWait
221
- ? (message, work) => hooks.ui.wait(message, work)
252
+ ? (message, work) => waitFor(message, work)
222
253
  : undefined,
223
254
  sleep: hooks.sleep,
224
255
  }),
225
- "github.install": githubInstallationAdapter({
226
- openBrowser: hooks.openBrowser ?? openConsentBrowser,
227
- wait: interactiveWait
228
- ? (message, work) => hooks.ui.wait(message, work)
229
- : undefined,
230
- sleep: hooks.sleep,
256
+ "github.install": connectChecklistAdapter({
257
+ ...checklistOptions,
258
+ step: "github.install",
259
+ item: "github-app",
260
+ fallback: githubInstallationAdapter({
261
+ openBrowser: hooks.openBrowser ?? openConsentBrowser,
262
+ wait: interactiveWait
263
+ ? (message, work) => waitFor(message, work)
264
+ : undefined,
265
+ sleep: hooks.sleep,
266
+ }),
231
267
  }),
232
268
  settings: onboardSettingsAdapter({
233
269
  ...hooks.settings,
@@ -247,21 +283,23 @@ export function createOnboardRuntime(args, ctx, hooks) {
247
283
  : undefined,
248
284
  message: (text) => (hooks.ui ? hooks.ui.message(text) : ctx.stderr(text)),
249
285
  }),
250
- accounts: onboardAccountsAdapter({
251
- ...(hooks.ui &&
252
- hooks.ui.interactive !== false &&
253
- !args.json &&
254
- args.flags.yes !== true &&
255
- args.flags.headless !== true
256
- ? {
257
- waitForAccount: (work) => hooks.ui.wait("Waiting for a coding account", work),
258
- sleep: hooks.sleep,
259
- }
260
- : {}),
261
- message: (text) => (hooks.ui ? hooks.ui.message(text) : ctx.stderr(text)),
262
- ...(typeof args.flags["coding-account"] === "string"
263
- ? { slot: args.flags["coding-account"] }
264
- : {}),
286
+ accounts: connectChecklistAdapter({
287
+ ...checklistOptions,
288
+ step: "accounts",
289
+ item: "ai-account",
290
+ // A named account is checked on its own: the checklist answers for the whole workspace.
291
+ enabled: typeof args.flags["coding-account"] !== "string",
292
+ fallback: onboardAccountsAdapter({
293
+ ...(interactiveWait
294
+ ? {
295
+ waitForAccount: (work) => waitFor("Waiting for an AI account", work),
296
+ sleep: hooks.sleep,
297
+ }
298
+ : {}),
299
+ ...(typeof args.flags["coding-account"] === "string"
300
+ ? { slot: args.flags["coding-account"] }
301
+ : {}),
302
+ }),
265
303
  }),
266
304
  capacity: onboardCapacityAdapter({
267
305
  message: (text) => (hooks.ui ? hooks.ui.message(text) : ctx.stderr(text)),
@@ -300,9 +338,14 @@ export function createOnboardRuntime(args, ctx, hooks) {
300
338
  evidence: { provider: "cloud" },
301
339
  },
302
340
  },
303
- housekeeping: {
304
- check: async () => waiting("housekeeping_service_unverified"),
305
- },
341
+ housekeeping: onboardHousekeepingAdapter(hooks.scheduler),
342
+ values: onboardValuesAdapter(),
343
+ // Only a person at a terminal picks a ticket; --yes, JSON and headless runs leave it waiting.
344
+ "first-ticket": onboardFirstTicketAdapter(interactiveWait && hooks.ui?.chooseFirstTicket
345
+ ? {
346
+ choose: (tickets, where) => hooks.ui.chooseFirstTicket(tickets.map((ticket) => ({ ...ticket })), { ...where }),
347
+ }
348
+ : {}),
306
349
  ready: { check: hooks.ready },
307
350
  };
308
351
  const get = (path) => ({ method: "GET", path });
@@ -362,6 +405,10 @@ export function createOnboardRuntime(args, ctx, hooks) {
362
405
  check: [get("/api/v1/repos"), get("/api/v1/agent/contract")],
363
406
  fallback: "connections",
364
407
  },
408
+ values: {
409
+ check: [get("/api/v1/agent/contract")],
410
+ fallback: "connections",
411
+ },
365
412
  ready: {
366
413
  check: [get("/api/v1/agent/contract")],
367
414
  fallback: "connections",
@@ -376,6 +423,50 @@ export function createOnboardRuntime(args, ctx, hooks) {
376
423
  message: (text) => hooks.ui ? hooks.ui.message(text) : ctx.stderr(text),
377
424
  });
378
425
  }
426
+ // CTC-4680 round 5: a browser link that ran out of time holds the step. The person gets a fresh
427
+ // link on yes; on stop the UI pauses setup, so nothing that depends on the step runs.
428
+ const retry = interactiveWait ? hooks.ui?.retryTimedOut?.bind(hooks.ui) : undefined;
429
+ if (retry)
430
+ for (const step of HOLD_ON_TIMEOUT) {
431
+ const adapter = adapters[step];
432
+ if (!adapter?.act)
433
+ continue;
434
+ const act = adapter.act;
435
+ adapters[step] = {
436
+ ...adapter,
437
+ act: async (stepCtx, journal, signal) => {
438
+ for (;;) {
439
+ waited = false;
440
+ const result = await act(stepCtx, journal, signal);
441
+ const timedOut = result.state === "waiting" &&
442
+ (TIMED_OUT_REASONS.has(result.reason ?? "") ||
443
+ (step === "accounts" &&
444
+ (result.reason === "account_enrollment_required" ||
445
+ result.reason === "ai_account_not_usable")));
446
+ if (!timedOut || !waited || signal?.aborted || hooks.ui?.signal.aborted)
447
+ return result;
448
+ if (!(await retry(step)))
449
+ return result;
450
+ }
451
+ },
452
+ };
453
+ }
454
+ // Round 5: the runner question waits until Catalyst is on GitHub; asking it earlier left people
455
+ // answering later steps while nothing could work.
456
+ const runner = adapters.runner;
457
+ if (runner?.act && hooks.ui?.chooseRunner && args.flags.runner !== true && args.flags["no-runner"] !== true) {
458
+ const { check, act } = runner;
459
+ const githubPending = (journal) => {
460
+ const github = journal.steps.find((step) => step.id === "github.install");
461
+ return Boolean(github && github.state !== "done" && github.state !== "skipped");
462
+ };
463
+ // Both: the engine runs check before act, and the runner's check is where the question is asked.
464
+ adapters.runner = {
465
+ ...runner,
466
+ check: async (stepCtx, journal, signal) => githubPending(journal) ? waiting("github_install_pending") : check(stepCtx, journal, signal),
467
+ act: async (stepCtx, journal, signal) => githubPending(journal) ? waiting("github_install_pending") : act(stepCtx, journal, signal),
468
+ };
469
+ }
379
470
  // Public auth discovery and the existing /me sign-in bootstrap establish identity before the
380
471
  // tenant-bound step guards can run. Unsupported/local-only steps make no endpoint requests.
381
472
  return {