@catalyst-cloud/cli 0.14.7 → 0.14.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +2 -2
  3. package/dist/args.js +17 -5
  4. package/dist/browser.js +37 -3
  5. package/dist/cli.js +4 -6
  6. package/dist/config.js +1 -2
  7. package/dist/consent-browser.js +58 -0
  8. package/dist/env/onboard-draft-store.js +10 -3
  9. package/dist/env/settings-authority.js +3 -1
  10. package/dist/events-cloud-tail.js +25 -13
  11. package/dist/events-cloud.js +124 -5
  12. package/dist/events.js +175 -29
  13. package/dist/oauth.js +4 -1
  14. package/dist/onboard-github.js +14 -3
  15. package/dist/onboard-next.js +139 -0
  16. package/dist/onboard-personal.js +14 -3
  17. package/dist/onboard-repositories.js +3 -4
  18. package/dist/onboard-runtime.js +4 -4
  19. package/dist/onboard-ui.js +14 -76
  20. package/dist/onboard-workspace.js +14 -3
  21. package/dist/onboard.js +63 -12
  22. package/dist/pagination.js +13 -1
  23. package/dist/query.js +39 -16
  24. package/dist/repository-id.js +5 -0
  25. package/package.json +1 -1
  26. package/skills/catalyst-github/SKILL.md +3 -1
  27. package/skills/catalyst-github/references/local-replica.md +78 -0
  28. package/skills/catalyst-github/references/reading-from-the-cloud.md +24 -0
  29. package/skills/catalyst-linear/SKILL.md +3 -1
  30. package/skills/catalyst-linear/references/local-replica.md +91 -0
  31. package/skills/catalyst-linear/references/reading-from-the-cloud.md +38 -0
  32. package/skills/catalyst-onboard/SKILL.md +3 -2
  33. package/skills/catalyst-onboard/references/local-replica.md +40 -0
  34. package/skills/catalyst-onboard/references/local-sync.md +5 -3
  35. package/skills/catalyst-onboard/scripts/local-sync.mjs +2 -2
  36. package/skills/catalyst-onboard/scripts/where-am-i.mjs +1 -1
  37. package/skills/run-this-project/SKILL.md +1 -1
  38. package/skills/unstick/SKILL.md +1 -1
  39. package/skills/what-needs-me/SKILL.md +1 -1
  40. package/skills/what-this-repo-needs/SKILL.md +1 -1
  41. package/skills/whats-happening/SKILL.md +3 -1
  42. package/skills/whats-happening/references/local-replica.md +51 -0
  43. package/skills/whats-happening/references/reading-from-the-cloud.md +21 -0
package/dist/events.js CHANGED
@@ -3,8 +3,22 @@ import { normalizeBaseUrl, requireConfig } from "./config.js";
3
3
  import { CliError, UsageError } from "./errors.js";
4
4
  import { authStrategyFor } from "./oauth.js";
5
5
  import { eventCacheStatus } from "./event-status.js";
6
+ import { cloudEventHead, queryCloudEvents, unreachableError, unreachableKind } from "./events-cloud.js";
7
+ import { eventMatches, tailCloudEvents, waitForCloudEvent } from "./events-cloud-tail.js";
6
8
  const eventsModule = "@catalyst-cloud/sdk/events";
7
9
  export const loadEventsSdk = () => import(eventsModule);
10
+ /** Consecutive failed attempts after which `wait-for` reports an outage rather than waiting on. */
11
+ const OUTAGE_ATTEMPTS = 5;
12
+ /** How long the cloud may be unreachable before `tail` says so on stderr. */
13
+ const TAIL_WARN_AFTER_MS = 60_000;
14
+ function processInterrupts(handler) {
15
+ process.once("SIGINT", handler);
16
+ process.once("SIGTERM", handler);
17
+ return () => {
18
+ process.off("SIGINT", handler);
19
+ process.off("SIGTERM", handler);
20
+ };
21
+ }
8
22
  export async function createEventSync(ctx, deps = {}) {
9
23
  const cfg = requireConfig(ctx);
10
24
  const sdk = await (deps.loadSdk ?? loadEventsSdk)();
@@ -25,6 +39,12 @@ export async function cmdEvents(args, ctx, deps = {}) {
25
39
  throw new UsageError("events needs a subcommand: tail | wait-for | query | status");
26
40
  if (!["tail", "wait-for", "query", "status"].includes(sub))
27
41
  throw new UsageError(`unknown events subcommand: ${sub}`);
42
+ // CTC-4554 — the cloud is the default source; the local event file is the opt-in.
43
+ if (args.flags["from-cache"] !== true) {
44
+ if (flagString(args, "directory") !== undefined)
45
+ throw new UsageError("--directory names the local event cache; add --from-cache to read it");
46
+ return cmdCloudEvents(sub, args, ctx, deps);
47
+ }
28
48
  const cfg = requireConfig(ctx);
29
49
  const sdk = await (deps.loadSdk ?? loadEventsSdk)();
30
50
  const directory = flagString(args, "directory") ??
@@ -32,8 +52,8 @@ export async function cmdEvents(args, ctx, deps = {}) {
32
52
  if (sub === "status") {
33
53
  const status = await eventCacheStatus(ctx, directory, args.flags.probe === true);
34
54
  ctx.stdout(args.json
35
- ? JSON.stringify(status)
36
- : `events: ${status.verdict} at ${directory}${status.reasons.length ? ` (${status.reasons.join("; ")})` : ` (cursor ${status.cursor}, cloud head ${status.head})`}`);
55
+ ? JSON.stringify({ source: "cache", ...status })
56
+ : `events: local cache ${status.verdict} at ${directory}${status.reasons.length ? ` (${status.reasons.join("; ")})` : ` (cursor ${status.cursor}, cloud head ${status.head})`}`);
37
57
  return status.verdict === "current"
38
58
  ? 0
39
59
  : status.verdict === "stale"
@@ -42,8 +62,8 @@ export async function cmdEvents(args, ctx, deps = {}) {
42
62
  ? 3
43
63
  : 2;
44
64
  }
45
- const after = startingCursor(args, sub === "query");
46
- const matches = matcher(args);
65
+ const after = startingCursor(args);
66
+ const matches = eventMatches(eventFilter(args));
47
67
  if (sub === "query") {
48
68
  const events = (await sdk.readCachedEvents({ tenantId: cfg.account, directory, after }))
49
69
  .filter(matches)
@@ -80,7 +100,7 @@ export async function cmdEvents(args, ctx, deps = {}) {
80
100
  if (controller.signal.aborted)
81
101
  return sub === "wait-for" ? 1 : 0;
82
102
  if (error.code === "ENOENT")
83
- throw new CliError(`event cache is absent at ${directory} — start it with: catalyst replica start --detach`, "events-absent", 3);
103
+ throw new CliError(`event cache is absent at ${directory}: it is written only where local sync runs. Start the local writer with: catalyst replica start --detach, or drop --from-cache to read from the cloud`, "events-absent", 3);
84
104
  throw error;
85
105
  }
86
106
  finally {
@@ -89,33 +109,159 @@ export async function cmdEvents(args, ctx, deps = {}) {
89
109
  deps.signal?.removeEventListener("abort", abort);
90
110
  }
91
111
  }
92
- function startingCursor(args, history) {
93
- const requested = flagString(args, "after");
94
- if (requested !== undefined) {
95
- const parsed = Number(requested);
96
- if (!Number.isSafeInteger(parsed) || parsed < 0)
97
- throw new UsageError("--after must be a non-negative event sequence");
98
- return parsed;
99
- }
100
- if (history)
112
+ function startingCursor(args, name = "after") {
113
+ const requested = flagString(args, name);
114
+ if (requested === undefined)
101
115
  return undefined;
102
- return undefined;
116
+ const parsed = Number(requested);
117
+ if (!Number.isSafeInteger(parsed) || parsed < 0)
118
+ throw new UsageError(`--${name} must be a non-negative event sequence`);
119
+ return parsed;
103
120
  }
104
- function matcher(args) {
121
+ function eventFilter(args) {
105
122
  const type = flagString(args, "type");
106
123
  const ticket = flagString(args, "ticket")?.toUpperCase();
107
- return (event) => (!type || event.type === type) &&
108
- (!ticket || payloadReferences(event.payload, ticket));
124
+ return { ...(type ? { type } : {}), ...(ticket ? { ticket } : {}) };
125
+ }
126
+ /** A sink over `ctx.stdout` for contexts without a stream (tests): one call per line. */
127
+ function lineSink(ctx) {
128
+ return {
129
+ write(chunk, callback) {
130
+ try {
131
+ ctx.stdout(chunk.endsWith("\n") ? chunk.slice(0, -1) : chunk);
132
+ callback();
133
+ }
134
+ catch (error) {
135
+ callback(error);
136
+ }
137
+ return true;
138
+ },
139
+ on: () => undefined,
140
+ off: () => undefined,
141
+ };
109
142
  }
110
- function payloadReferences(value, ticket) {
111
- if (typeof value === "string")
112
- return value.toUpperCase() === ticket;
113
- if (Array.isArray(value))
114
- return value.some((item) => payloadReferences(item, ticket));
115
- if (!value || typeof value !== "object")
116
- return false;
117
- return Object.entries(value).some(([key, item]) => ["ticket", "identifier", "workItemKey", "issueIdentifier"].includes(key) &&
118
- typeof item === "string"
119
- ? item.toUpperCase() === ticket
120
- : payloadReferences(item, ticket));
143
+ const QUERY_MAX_LIMIT = 200;
144
+ const LOCAL_SYNC_HINT = "several agents on one machine are cheaper sharing one local cache: opt in to local sync (catalyst onboard --local-sync), then read it with --from-cache";
145
+ /** CTC-4554 — `events tail | wait-for | query | status` against the cloud, with no local file. */
146
+ async function cmdCloudEvents(sub, args, ctx, deps) {
147
+ requireConfig(ctx);
148
+ const filter = eventFilter(args);
149
+ if (sub === "status") {
150
+ let head;
151
+ try {
152
+ head = await cloudEventHead(ctx);
153
+ }
154
+ catch (error) {
155
+ if (error instanceof CliError)
156
+ throw error;
157
+ const failure = unreachableError(error);
158
+ if (args.json)
159
+ ctx.stdout(JSON.stringify({ source: "cloud", reachable: false, error: unreachableKind(error) }));
160
+ else
161
+ ctx.stderr(`catalyst: ${failure.message}`);
162
+ return failure.exitCode;
163
+ }
164
+ // A cloud stream has no local cursor to fall behind. Filtered reads come from the event index,
165
+ // which trails the head while it fills; that lag is measured, not assumed.
166
+ let indexedToSeq = null;
167
+ try {
168
+ indexedToSeq = (await queryCloudEvents(ctx, { limit: 1, order: "desc" })).coverage.indexedToSeq;
169
+ }
170
+ catch {
171
+ indexedToSeq = null;
172
+ }
173
+ const indexLag = indexedToSeq === null ? null : Math.max(0, head - indexedToSeq);
174
+ const status = { source: "cloud", reachable: true, head, behind: null, indexedToSeq, indexLag, note: LOCAL_SYNC_HINT };
175
+ ctx.stdout(args.json
176
+ ? JSON.stringify(status)
177
+ : `events: cloud stream at head ${head}; ${indexLag === null ? "the filtered index could not be read" : `filtered reads (--ticket, --type) trail it by ${indexLag}`} (every read goes to the cloud; ${LOCAL_SYNC_HINT})`);
178
+ return 0;
179
+ }
180
+ if (sub === "query") {
181
+ const limit = flagInt(args, "limit", 50);
182
+ if (!Number.isInteger(limit) || limit < 1 || limit > QUERY_MAX_LIMIT)
183
+ throw new UsageError(`--limit must be between 1 and ${QUERY_MAX_LIMIT} for a cloud query`);
184
+ const order = flagString(args, "order") ?? "desc";
185
+ if (order !== "asc" && order !== "desc")
186
+ throw new UsageError("--order must be asc or desc");
187
+ const page = await queryCloudEvents(ctx, {
188
+ ...filter,
189
+ limit,
190
+ order,
191
+ afterSeq: startingCursor(args, "after"),
192
+ beforeSeq: startingCursor(args, "before"),
193
+ });
194
+ for (const event of page.events)
195
+ ctx.stdout(JSON.stringify(event));
196
+ if (page.next)
197
+ ctx.stderr(`more: re-run with --${page.next.param === "beforeSeq" ? "before" : "after"} ${page.next.value}`);
198
+ if (page.events.length === 0) {
199
+ const { indexedFromSeq: from, indexedToSeq: to } = page.coverage;
200
+ ctx.stderr(from === null || to === null
201
+ ? "no match; the cloud index holds no events yet"
202
+ : `no match; the cloud index holds sequences ${from} to ${to}, so an earlier event may exist unindexed`);
203
+ }
204
+ return 0;
205
+ }
206
+ const out = deps.out ?? ctx.stdoutStream ?? lineSink(ctx);
207
+ const controller = new AbortController();
208
+ const abort = () => controller.abort(deps.signal?.reason);
209
+ if (deps.signal?.aborted)
210
+ abort();
211
+ else
212
+ deps.signal?.addEventListener("abort", abort, { once: true });
213
+ let interrupted = false;
214
+ const removeInterrupt = (deps.onInterrupt ?? processInterrupts)(() => {
215
+ interrupted = true;
216
+ controller.abort(new Error("interrupted"));
217
+ });
218
+ let outage;
219
+ let warned = false;
220
+ const seconds = (sinceMs) => Math.round((ctx.now().getTime() - sinceMs) / 1_000);
221
+ const cloud = {
222
+ ...deps.cloud,
223
+ onTrouble: (state) => {
224
+ deps.cloud?.onTrouble?.(state);
225
+ if (sub === "wait-for" && state.failures >= OUTAGE_ATTEMPTS && !outage) {
226
+ outage = state;
227
+ controller.abort(new Error("cloud unreachable"));
228
+ }
229
+ if (sub === "tail" && !warned && ctx.now().getTime() - state.sinceMs > TAIL_WARN_AFTER_MS) {
230
+ warned = true;
231
+ ctx.stderr(`events: the cloud has been unreachable for ${seconds(state.sinceMs)} s (${state.failures} attempts); still retrying`);
232
+ }
233
+ },
234
+ onHealthy: () => {
235
+ deps.cloud?.onHealthy?.();
236
+ if (warned)
237
+ ctx.stderr("events: the cloud is reachable again");
238
+ warned = false;
239
+ },
240
+ };
241
+ try {
242
+ const after = startingCursor(args);
243
+ if (sub === "wait-for") {
244
+ const timeout = flagInt(args, "timeout", 300);
245
+ const result = await waitForCloudEvent(ctx, { after, filter, timeoutMs: timeout * 1_000, out, signal: controller.signal }, cloud);
246
+ if (interrupted)
247
+ return 130;
248
+ if (outage) {
249
+ ctx.stderr(`events: the cloud event service was unreachable for ${outage.failures} attempts over ${seconds(outage.sinceMs)} s; this is an outage, not a timeout`);
250
+ return 4;
251
+ }
252
+ if (result.outcome === "matched" || result.outcome === "closed")
253
+ return 0;
254
+ if (result.outcome === "timeout")
255
+ ctx.stderr(`events: no matching event within ${timeout} s`);
256
+ return 1;
257
+ }
258
+ const result = await tailCloudEvents(ctx, { after, filter, out, signal: controller.signal }, cloud);
259
+ if (result.outcome !== "closed" && result.cursor !== undefined)
260
+ ctx.stderr(`events: stopped at sequence ${result.cursor}; resume with --after ${result.cursor}`);
261
+ return interrupted ? 130 : 0;
262
+ }
263
+ finally {
264
+ removeInterrupt();
265
+ deps.signal?.removeEventListener("abort", abort);
266
+ }
121
267
  }
package/dist/oauth.js CHANGED
@@ -135,12 +135,15 @@ export async function deviceFlowLogin(ctx, baseUrl, deps = {}) {
135
135
  ctx.stdout(`Or open this link, which fills the code in: ${auth.verification_uri_complete}`);
136
136
  if (round === 1 ? (deps.isTty ?? (() => false))() : browserOpened) {
137
137
  try {
138
- (deps.openBrowser ?? (() => { }))(auth.verification_uri_complete ?? auth.verification_uri);
138
+ if (!deps.openBrowser)
139
+ throw new Error("browser-unavailable");
140
+ await deps.openBrowser(auth.verification_uri_complete ?? auth.verification_uri);
139
141
  browserOpened = true;
140
142
  ctx.stdout("Opened your browser to that page — approve there, or use the code above.");
141
143
  }
142
144
  catch {
143
145
  // a browser that will not open is not a failure; the code and URL still work
146
+ ctx.stdout("Open the link above in a browser.");
144
147
  }
145
148
  }
146
149
  ctx.stdout("Waiting for you to approve… (Ctrl-C to cancel)");
@@ -1,3 +1,4 @@
1
+ import { finishOnTheWeb } from "./consent-browser.js";
1
2
  import { loadConfig, normalizeBaseUrl } from "./config.js";
2
3
  import { pollConsent } from "./onboard-consent.js";
3
4
  const STATUS = "/api/v1/me/connections/github/workspace";
@@ -292,11 +293,15 @@ export function githubInstallationAdapter(options) {
292
293
  };
293
294
  ctx.stderr("Approve the GitHub App installation in your browser. Your personal GitHub connection is checked separately.");
294
295
  // The signed URL is transient credential material: only the browser receives it.
296
+ let browserUnavailable = false;
295
297
  try {
296
- options.openBrowser(url);
298
+ await options.openBrowser(url, signal);
297
299
  }
298
300
  catch {
299
- return waiting("github_installation_browser_unavailable");
301
+ if (signal?.aborted)
302
+ return waiting("interrupted");
303
+ browserUnavailable = true;
304
+ ctx.stderr(finishOnTheWeb(current.baseUrl, "connections", "install the GitHub App"));
300
305
  }
301
306
  let latest = waiting("github_installation_status_unavailable");
302
307
  const run = () => pollConsent({
@@ -311,6 +316,8 @@ export function githubInstallationAdapter(options) {
311
316
  outcome: "waiting",
312
317
  reason: "cloud_capability_unavailable",
313
318
  };
319
+ if (latest.reason === "github_installation_login_refresh_required")
320
+ return { outcome: "waiting", reason: latest.reason };
314
321
  if (latest.state === "done")
315
322
  return { outcome: "connected" };
316
323
  if (latest.state === "refused" || latest.state === "failed")
@@ -323,7 +330,11 @@ export function githubInstallationAdapter(options) {
323
330
  const result = options.wait
324
331
  ? await options.wait("Waiting for GitHub App approval", run)
325
332
  : await run();
326
- return result.state === "done" ? latest : result;
333
+ if (result.state === "done")
334
+ return latest;
335
+ return browserUnavailable && result.reason === "consent_timeout"
336
+ ? waiting("github_installation_browser_unavailable")
337
+ : result;
327
338
  },
328
339
  };
329
340
  }
@@ -0,0 +1,139 @@
1
+ import { loadConfig, normalizeBaseUrl } from "./config.js";
2
+ /** What a person reads for a step that is not done. Each line ends in an action they can take. */
3
+ const REASONS = {
4
+ account_identity_unverified: "Your login changed while coding accounts were checked. Run catalyst onboard again to verify your person and workspace.",
5
+ account_inventory_unavailable: "Coding accounts could not be read. Run catalyst onboard again to retry.",
6
+ account_inventory_unverified: "The server did not return a fresh supported coding-account list. Run catalyst onboard after the server update.",
7
+ account_validation_admin_required: "An administrator must check the workspace's coding-account access.",
8
+ account_login_refresh_required: "Renew your login with catalyst login, then run catalyst onboard.",
9
+ account_validation_unavailable: "The provider check could not finish. Run catalyst onboard again to retry.",
10
+ account_validation_unverified: "The server did not return a fresh provider check. Run catalyst onboard after the server update.",
11
+ 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.",
12
+ codex_provider_access_unverified: "Codex credentials are stored, but provider access is not freshly verified. Setup did not refresh or rotate the Codex login.",
13
+ account_provider_walled: "Claude's usage limit is spent for now. Check the account's reset time, then run catalyst onboard again.",
14
+ account_provider_rejected: "Claude refused the stored login. Replace it in the workspace's coding-account settings, then resume setup.",
15
+ personal_consent_handoff: "The browser approval link could not be verified. Run catalyst onboard to try again.",
16
+ personal_identity_refused: "Your current login no longer matches this setup. Resume with the original workspace and person.",
17
+ personal_login_refresh_required: "Renew your login with catalyst login, then run catalyst onboard to resume.",
18
+ onboard_login_refresh_required: "Renew your login with catalyst login, then run catalyst onboard to resume.",
19
+ personal_status_unavailable: "Your personal connection could not be checked. Run catalyst onboard to try again.",
20
+ 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.",
21
+ 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.",
22
+ onboarding_capability_unavailable: "The server's setup capabilities could not be checked. Run catalyst onboard to try again.",
23
+ onboarding_capability_identity_unverified: "The server's setup capabilities belong to another login or workspace. Sign in to the original workspace to resume.",
24
+ onboarding_capability_login_refresh_required: "Your login needs a refresh before setup capabilities can be checked. Run catalyst login, then catalyst onboard.",
25
+ workspace_login_refresh_required: "Renew your login with catalyst login, then run catalyst onboard to resume.",
26
+ workspace_consent_refused: "The server did not accept this login for Linear workspace setup. Sign in again or ask your workspace administrator.",
27
+ github_installation_status_unavailable: "Your GitHub App installation could not be checked. Run catalyst onboard to try again.",
28
+ github_installation_status_shape: "The server returned a GitHub installation result that could not be verified.",
29
+ github_installation_consent_handoff: "The GitHub installation approval link could not be verified. Run catalyst onboard to try again.",
30
+ github_installation_consent_refused: "The server did not accept this login for GitHub App setup. Sign in again or ask your workspace administrator.",
31
+ github_installation_identity_refused: "GitHub setup belongs to another login or workspace. Sign in to the original workspace to resume.",
32
+ github_installation_admin_required: "A workspace owner or administrator approves the GitHub App installation.",
33
+ github_installation_login_refresh_required: "Renew your login with catalyst login, then run catalyst onboard to resume.",
34
+ 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.",
35
+ step_not_available_in_this_release: "This setup step is not available yet.",
36
+ prerequisite_not_ready: "Waiting for an earlier setup step.",
37
+ local_sync_not_selected: "Using cloud reads. Local sync was not selected.",
38
+ local_sync_capability_unavailable: "Local sync was selected but could not be verified.",
39
+ member_scope: "Your workspace administrator handles this step.",
40
+ onboarding_checks_pending: "Some required checks are still unverified.",
41
+ interrupted: "Setup paused. Run the same command to resume.",
42
+ signin_timeout: "Sign-in timed out. Run catalyst onboard to try again.",
43
+ linear_workspace_unverified: "Your Linear workspace connection could not be verified.",
44
+ team_inventory_empty: "No existing Linear teams could be verified.",
45
+ team_inventory_shape: "The cloud returned a team list that could not be verified.",
46
+ team_read_unavailable: "Your existing Linear teams could not be read. Run catalyst onboard to try again.",
47
+ team_read_identity_unverified: "Your login changed while setup was reading this workspace. Resume with the original workspace and person.",
48
+ project_identity_unverified: "Your login changed while setup was checking this project. Resume with the original workspace and person.",
49
+ project_options_unavailable: "Available teams and repositories could not be checked. Run catalyst onboard to try again.",
50
+ project_provider_inventory_unavailable: "Linear or GitHub access could not be verified. Check this workspace's connections, then resume.",
51
+ project_options_unverified: "The server returned a team or repository list that could not be verified.",
52
+ project_registration_visibility_pending: "Your project exists, but its repository binding is not visible yet. Run catalyst onboard to check again.",
53
+ project_binding_conflict: "This team or repository is already bound to a project. Ask your workspace administrator to check its binding, then resume.",
54
+ project_login_refresh_required: "Renew your login with catalyst onboard, then review the plan before creating this project.",
55
+ project_create_unverified: "Project creation could not be confirmed. Run catalyst onboard to check for the project before trying again.",
56
+ team_read_login_refresh_required: "Renew your login with catalyst login, then run catalyst onboard to resume.",
57
+ team_choice_required: "Choose an existing team with catalyst onboard --team <team ID or key>.",
58
+ team_selection_unverified: "Your selected Linear team could not be verified. Run catalyst onboard --team <team ID or key> to choose again.",
59
+ repository_choice_required: "Choose repositories with catalyst onboard --repo <owner/name>. Repeat --repo to choose more than one.",
60
+ repository_inventory_empty: "No accessible repositories are registered to this Linear team yet.",
61
+ repository_read_unavailable: "Your repository list could not be read. Run catalyst onboard to try again.",
62
+ repository_inventory_shape: "The cloud returned a repository list that could not be verified.",
63
+ repository_contract_unavailable: "Current repository IDs could not be checked. Run catalyst onboard to try again.",
64
+ repository_contract_unverified: "The cloud returned repository IDs that could not be verified for this workspace.",
65
+ repository_id_unverified: "A repository's current ID could not be verified. Keep your selection and try again.",
66
+ repository_binding_ambiguous: "A repository's Linear team binding is ambiguous. Your administrator needs to check it.",
67
+ repository_identity_unverified: "Sign in to the original workspace to choose repositories.",
68
+ settings_review_timeout: "The settings review timed out. Run catalyst onboard to resume.",
69
+ settings_checkout_unverified: "Setup could not verify the repository's local checkout or settings file. Settings are optional for a first ticket. To finish them later, run catalyst onboard from the repository's checkout.",
70
+ settings_draft_storage_unavailable: "A private settings draft could not be kept. Run catalyst onboard to retry.",
71
+ settings_draft_storage_timeout: "Keeping your settings draft took too long. Run catalyst onboard to retry.",
72
+ settings_approval_unverified: "Settings still need review and approval; kept drafts do not count. Settings are optional for a first ticket. To finish them later, run catalyst onboard from the repository's checkout.",
73
+ repository_selection_unverified: "Your selected repositories could not be verified. Run catalyst onboard --repo <owner/name> to choose again.",
74
+ workflow_identity_unverified: "Setup could not confirm the signed-in workspace, person or role for this team. Run catalyst onboard again as a workspace owner or administrator.",
75
+ workflow_login_refresh_required: "Renew your login with catalyst login, then run catalyst onboard.",
76
+ workflow_mapping_changed: "The team's workflow changed while setup checked it. Run catalyst onboard again.",
77
+ workflow_unavailable: "Setup could not read the team's workflow. Run catalyst onboard again to retry.",
78
+ capacity_admission_unverified: "No runner is allowed to take work for this workspace yet. Enroll a self-hosted runner host, or ask Catalyst to enable cloud runners for it. Then run catalyst onboard.",
79
+ capacity_currently_full: "Every runner slot for this repository is in use. Run catalyst onboard again after current work finishes.",
80
+ capacity_unavailable: "Setup could not read runner capacity. Run catalyst onboard again to retry.",
81
+ 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.",
82
+ };
83
+ /** The saved login's web address, or nothing when the config is missing, unreadable or not http(s). */
84
+ export function savedOnboardBaseUrl(home) {
85
+ try {
86
+ const cfg = loadConfig(home);
87
+ const url = cfg ? webUrl(cfg.baseUrl) : undefined;
88
+ return url ? normalizeBaseUrl(url) : undefined;
89
+ }
90
+ catch {
91
+ return undefined;
92
+ }
93
+ }
94
+ // Receipt and config text reaches the terminal only after URL parsing has escaped it.
95
+ function webUrl(value) {
96
+ if (typeof value !== "string")
97
+ return undefined;
98
+ try {
99
+ const url = new URL(value);
100
+ return ["https:", "http:"].includes(url.protocol) &&
101
+ !url.username &&
102
+ !url.password
103
+ ? url.href
104
+ : undefined;
105
+ }
106
+ catch {
107
+ return undefined;
108
+ }
109
+ }
110
+ /** The selected team's key from the receipt, only when it is plain enough to print in a command. */
111
+ export function onboardTeamKey(journal) {
112
+ const step = journal?.steps.find((row) => row.id === "linear.team");
113
+ const key = step?.state === "done" ? step.evidence?.teamKey : undefined;
114
+ return typeof key === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(key)
115
+ ? key
116
+ : undefined;
117
+ }
118
+ /** One step's next action. `waitsFor` names the unfinished step a prerequisite wait is blocked on. */
119
+ export function onboardReasonText(step, context = {}) {
120
+ const reason = step.reason ?? "";
121
+ if (reason === "workflow_mapping_unverified")
122
+ 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.`;
123
+ if (reason === "account_enrollment_required") {
124
+ const where = context.baseUrl
125
+ ? `at ${normalizeBaseUrl(context.baseUrl)}/settings/coding-accounts, the Settings → AI accounts page`
126
+ : "in the web app on the Settings → AI accounts page";
127
+ 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.`;
128
+ }
129
+ if (reason === "cloud_capability_unavailable") {
130
+ // The capability guard records the web page in evidence.path only when it printed it.
131
+ const page = webUrl(step.evidence?.path);
132
+ return page
133
+ ? `This setup step is not available on this server yet. Continue in the web app at ${page}, then run catalyst onboard.`
134
+ : "This setup step is not available on this server yet. Run catalyst onboard after the server update.";
135
+ }
136
+ if (reason === "prerequisite_not_ready" && context.waitsFor)
137
+ return `Runs after "${context.waitsFor}".`;
138
+ return REASONS[reason] ?? reason.replaceAll("_", " ");
139
+ }
@@ -1,3 +1,4 @@
1
+ import { finishOnTheWeb } from "./consent-browser.js";
1
2
  import { loadConfig, normalizeBaseUrl, } from "./config.js";
2
3
  import { pollConsent } from "./onboard-consent.js";
3
4
  const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
@@ -198,11 +199,15 @@ export function personalConsentAdapter(options) {
198
199
  const name = options.provider === "linear" ? "Linear" : "GitHub";
199
200
  ctx.stderr(`Approve your personal ${name} connection in the browser.`);
200
201
  // Signed continuation credentials belong only to the browser, never transcript or journal.
202
+ let browserUnavailable = false;
201
203
  try {
202
- options.openBrowser(url);
204
+ await options.openBrowser(url, signal);
203
205
  }
204
206
  catch {
205
- return waiting("personal_browser_unavailable");
207
+ if (signal?.aborted)
208
+ return waiting("interrupted");
209
+ browserUnavailable = true;
210
+ ctx.stderr(finishOnTheWeb(cfg.baseUrl, "connected-accounts", `connect your ${name} account`));
206
211
  }
207
212
  let latest = waiting("personal_status_unavailable");
208
213
  const run = () => pollConsent({
@@ -213,6 +218,8 @@ export function personalConsentAdapter(options) {
213
218
  latest = await inspect(ctx, journal, pollSignal);
214
219
  if (latest.reason === "cloud_capability_unavailable")
215
220
  return { outcome: "waiting", reason: latest.reason };
221
+ if (latest.reason === "personal_login_refresh_required")
222
+ return { outcome: "waiting", reason: latest.reason };
216
223
  if (latest.state === "done")
217
224
  return { outcome: "connected" };
218
225
  if (latest.state === "refused" || latest.state === "failed")
@@ -225,7 +232,11 @@ export function personalConsentAdapter(options) {
225
232
  const result = options.wait
226
233
  ? await options.wait(`Waiting for your ${name} approval`, run)
227
234
  : await run();
228
- return result.state === "done" ? latest : result;
235
+ if (result.state === "done")
236
+ return latest;
237
+ return browserUnavailable && result.reason === "consent_timeout"
238
+ ? waiting("personal_browser_unavailable")
239
+ : result;
229
240
  },
230
241
  };
231
242
  }
@@ -1,3 +1,4 @@
1
+ import { isRepositoryId } from "./repository-id.js";
1
2
  import { loadConfig, normalizeBaseUrl, readManifest, } from "./config.js";
2
3
  import { contractVersionInRange } from "./contract.js";
3
4
  import { readExistingOnboardJson, selectedOnboardTeam, } from "./onboard-existing.js";
@@ -16,8 +17,7 @@ const valid = (value) => {
16
17
  return (!!row &&
17
18
  typeof row.teamId === "string" &&
18
19
  id.test(row.teamId) &&
19
- typeof row.repoId === "string" &&
20
- id.test(row.repoId) &&
20
+ isRepositoryId(row.repoId) &&
21
21
  typeof row.owner === "string" &&
22
22
  part.test(row.owner) &&
23
23
  typeof row.name === "string" &&
@@ -154,8 +154,7 @@ export async function readOnboardRepositoryInventory(ctx, journal, signal) {
154
154
  !part.test(row.owner) ||
155
155
  typeof row.name !== "string" ||
156
156
  !part.test(row.name) ||
157
- typeof row.repoId !== "string" ||
158
- !id.test(row.repoId))
157
+ !isRepositoryId(row.repoId))
159
158
  return { reason: "repository_contract_unverified" };
160
159
  const name = key({ owner: row.owner, name: row.name });
161
160
  if (idNames.has(row.repoId) && idNames.get(row.repoId) !== name)
@@ -1,7 +1,7 @@
1
1
  import { existsSync, realpathSync } from "node:fs";
2
2
  import { userInfo } from "node:os";
3
3
  import { join } from "node:path";
4
- import { openBrowser } from "./browser.js";
4
+ import { openConsentBrowser } from "./consent-browser.js";
5
5
  import { defaultSkillsDirFor, loadConfig, normalizeBaseUrl, readManifest, } from "./config.js";
6
6
  import { CliError } from "./errors.js";
7
7
  import { cmdLegacy, findLegacy } from "./legacy.js";
@@ -130,7 +130,7 @@ export function createOnboardRuntime(args, ctx, hooks) {
130
130
  };
131
131
  const personalAdapter = (provider) => personalConsentAdapter({
132
132
  provider,
133
- openBrowser: hooks.openBrowser ?? openBrowser,
133
+ openBrowser: hooks.openBrowser ?? openConsentBrowser,
134
134
  sleep: hooks.sleep,
135
135
  wait: hooks.ui
136
136
  ? (message, run) => hooks.ui.wait(message, run)
@@ -206,14 +206,14 @@ export function createOnboardRuntime(args, ctx, hooks) {
206
206
  ...linear,
207
207
  "linear.workspace": linearWorkspaceAdapter({
208
208
  fallback: linear["linear.workspace"],
209
- openBrowser: hooks.openBrowser ?? openBrowser,
209
+ openBrowser: hooks.openBrowser ?? openConsentBrowser,
210
210
  wait: hooks.ui
211
211
  ? (message, work) => hooks.ui.wait(message, work)
212
212
  : undefined,
213
213
  sleep: hooks.sleep,
214
214
  }),
215
215
  "github.install": githubInstallationAdapter({
216
- openBrowser: hooks.openBrowser ?? openBrowser,
216
+ openBrowser: hooks.openBrowser ?? openConsentBrowser,
217
217
  wait: hooks.ui
218
218
  ? (message, work) => hooks.ui.wait(message, work)
219
219
  : undefined,