@catalyst-cloud/cli 0.14.9 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +41 -0
  3. package/dist/args.js +34 -2
  4. package/dist/capabilities.js +2 -1
  5. package/dist/cli-update-notice.js +76 -0
  6. package/dist/cli.js +267 -94
  7. package/dist/oauth.js +80 -18
  8. package/dist/onboard-accounts.js +34 -5
  9. package/dist/onboard-automation-management.js +27 -9
  10. package/dist/onboard-capacity.js +10 -1
  11. package/dist/onboard-existing.js +132 -31
  12. package/dist/onboard-github.js +171 -6
  13. package/dist/onboard-headless.js +647 -0
  14. package/dist/onboard-interrupts.js +41 -0
  15. package/dist/onboard-next.js +215 -0
  16. package/dist/onboard-permissions.js +100 -0
  17. package/dist/onboard-personal.js +46 -10
  18. package/dist/onboard-runner-cloud.js +213 -0
  19. package/dist/onboard-runner-egress.js +67 -0
  20. package/dist/onboard-runner.js +780 -0
  21. package/dist/onboard-runtime.js +48 -8
  22. package/dist/onboard-standalone-copy.js +110 -0
  23. package/dist/onboard-team-create.js +267 -0
  24. package/dist/onboard-ui.js +623 -29
  25. package/dist/onboard-workflow.js +311 -168
  26. package/dist/onboard-workspace.js +52 -5
  27. package/dist/onboard.js +359 -207
  28. package/dist/setup-events.js +100 -0
  29. package/dist/setup-onboard-copy.js +438 -0
  30. package/dist/setup-prompt-frame.js +94 -0
  31. package/dist/setup-prompts.js +108 -0
  32. package/dist/setup-render.js +339 -0
  33. package/dist/setup-signin.js +187 -0
  34. package/dist/setup.js +426 -0
  35. package/package.json +7 -3
  36. package/skills/catalyst-github/SKILL.md +1 -1
  37. package/skills/catalyst-linear/SKILL.md +1 -1
  38. package/skills/catalyst-onboard/SKILL.md +3 -2
  39. package/skills/run-this-project/SKILL.md +1 -1
  40. package/skills/unstick/SKILL.md +1 -1
  41. package/skills/what-needs-me/SKILL.md +1 -1
  42. package/skills/what-this-repo-needs/SKILL.md +1 -1
  43. package/skills/whats-happening/SKILL.md +1 -1
  44. package/vendor/README.md +4 -0
  45. package/vendor/self-host/compose.yaml +110 -0
  46. package/vendor/self-host/linux-session-egress/catalyst-session-egress-attest.service +14 -0
  47. package/vendor/self-host/linux-session-egress/catalyst-session-egress-attest.timer +10 -0
  48. package/vendor/self-host/linux-session-egress/catalyst-session-egress.service +19 -0
  49. package/vendor/self-host/linux-session-egress/session-egress.py +291 -0
  50. package/vendor/self-host/provenance.json +11 -0
@@ -0,0 +1,647 @@
1
+ // onboard-headless.ts — `catalyst onboard --headless` (CTC-4633): setup for CI, image builds and agent
2
+ // VMs, where every browser grant already exists. Inputs come only from flags, env or files; nothing
3
+ // prompts, nothing opens a browser, stdout carries one JSON document, and the exit code is 0/10/11/12
4
+ // with each missing item named. The key is read from env or a file and never printed.
5
+ import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
6
+ import { DEFAULT_BASE_URL, loadConfig, normalizeBaseUrl, } from "./config.js";
7
+ import { CliError, MeError, UsageError } from "./errors.js";
8
+ import { onboardReasonText } from "./onboard-next.js";
9
+ import { onboardJsonView } from "./setup-onboard-copy.js";
10
+ import { fetchMe } from "./transport.js";
11
+ import { ONBOARD_DEFERRED_STEPS, ONBOARD_DEPENDENCIES, ONBOARD_STEPS, onboardErrorJournal, onboardStatePath, readOnboardJournal, } from "./onboard.js";
12
+ export const ONBOARD_HEADLESS_SCHEMA = "catalyst-onboard-headless/1";
13
+ const EXIT_FAILED = 10;
14
+ const EXIT_WAITING = 11;
15
+ const EXIT_REFUSED = 12;
16
+ const KEY_FILE_MAX_BYTES = 16 * 1024;
17
+ const slotPattern = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
18
+ export function onboardHeadlessRequested(args, env) {
19
+ return args.flags.headless === true || env.CATALYST_ONBOARD_HEADLESS === "1";
20
+ }
21
+ /** True for an argv the parser refused that still asked for headless, so it answers in its contract. */
22
+ export function argvRequestsHeadless(argv, env) {
23
+ return (argv.includes("onboard") &&
24
+ (argv.includes("--headless") || env.CATALYST_ONBOARD_HEADLESS === "1"));
25
+ }
26
+ const pages = (baseUrl) => {
27
+ const web = normalizeBaseUrl(baseUrl);
28
+ return {
29
+ keys: `${web}/settings/api-keys`,
30
+ connections: `${web}/a/account/connections`,
31
+ personal: `${web}/settings/connected-accounts`,
32
+ teams: `${web}/settings/linear-teams`,
33
+ repos: `${web}/settings/repositories`,
34
+ codingAccounts: `${web}/settings/coding-accounts`,
35
+ };
36
+ };
37
+ const keyItem = (reason, text, url) => ({
38
+ id: "account_key",
39
+ kind: "input",
40
+ reason,
41
+ text,
42
+ env: "CATALYST_CLOUD_TOKEN",
43
+ flag: "--key-file",
44
+ url,
45
+ });
46
+ /** Never prints the path: a key pasted into CATALYST_CLOUD_TOKEN_FILE by mistake must not echo. */
47
+ function readKeyFile(path, url) {
48
+ const named = "the file named by --key-file or CATALYST_CLOUD_TOKEN_FILE";
49
+ const unreadable = () => ({
50
+ missing: keyItem("account_key_file_unreadable", `The key file could not be read: ${named}. Point it at a readable file holding a personal key from ${url}.`, url),
51
+ });
52
+ let fd;
53
+ try {
54
+ // Non-blocking, so a FIFO or device named as the key cannot hold the run open.
55
+ fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK);
56
+ }
57
+ catch {
58
+ return unreadable();
59
+ }
60
+ const read = () => {
61
+ const stat = fstatSync(fd);
62
+ if (!stat.isFile())
63
+ return {
64
+ missing: keyItem("account_key_file_not_regular", `The key path is not a regular file: ${named}. Point it at a file holding a personal key from ${url}.`, url),
65
+ };
66
+ const uid = process.getuid?.();
67
+ if (uid !== undefined && stat.uid !== uid && stat.uid !== 0)
68
+ return {
69
+ refused: keyItem("account_key_file_owner", `Another user owns the key file (${named}) and can change it. Use a file owned by this user or by root.`, url),
70
+ };
71
+ if (stat.mode & 0o022)
72
+ return {
73
+ refused: keyItem("account_key_file_permissions", `Other users can write the key file (${named}). Run chmod 600 on it, then run the same command.`, url),
74
+ };
75
+ if (stat.size > KEY_FILE_MAX_BYTES)
76
+ return {
77
+ refused: keyItem("account_key_malformed", `The key file (${named}) is too large to hold one key.`, url),
78
+ };
79
+ const buffer = Buffer.alloc(KEY_FILE_MAX_BYTES);
80
+ try {
81
+ const length = readSync(fd, buffer, 0, buffer.length, 0);
82
+ const key = buffer.subarray(0, length).toString("utf8").trim();
83
+ if (!key)
84
+ return {
85
+ missing: keyItem("account_key_missing", `The key file is empty: ${named}. Put a personal key from ${url} in it.`, url),
86
+ };
87
+ return stat.mode & 0o004
88
+ ? {
89
+ key,
90
+ warning: `The key file (${named}) is readable by other users. Run chmod 600 on it.`,
91
+ }
92
+ : { key };
93
+ }
94
+ finally {
95
+ buffer.fill(0);
96
+ }
97
+ };
98
+ let result;
99
+ try {
100
+ result = read();
101
+ }
102
+ catch {
103
+ result = unreadable();
104
+ }
105
+ try {
106
+ closeSync(fd);
107
+ }
108
+ catch {
109
+ // Discard a read key on a close fault; preserve an earlier explicit safety refusal.
110
+ if (!("refused" in result))
111
+ result = unreadable();
112
+ }
113
+ return result;
114
+ }
115
+ /** Resolves every headless input without a network call, a lock or a file write. */
116
+ export function planOnboardHeadless(args, ctx) {
117
+ const env = ctx.env;
118
+ let saved = null;
119
+ try {
120
+ saved = loadConfig(ctx.home);
121
+ }
122
+ catch {
123
+ saved = null;
124
+ }
125
+ const baseUrl = normalizeBaseUrl(args.baseUrl ??
126
+ env.CATALYST_CLOUD_BASE_URL ??
127
+ saved?.baseUrl ??
128
+ DEFAULT_BASE_URL);
129
+ const url = pages(baseUrl);
130
+ const missing = [];
131
+ const refused = [];
132
+ const warnings = [];
133
+ const text = (value) => typeof value === "string" && value.trim() ? value.trim() : null;
134
+ let key;
135
+ let accountKey = null;
136
+ const keyFile = text(args.flags["key-file"]) ?? text(env.CATALYST_CLOUD_TOKEN_FILE);
137
+ if (args.key !== undefined)
138
+ refused.push(keyItem("account_key_on_command_line", "A key on the command line is visible to every process on this machine. Pass it in CATALYST_CLOUD_TOKEN or in a file named by --key-file, then run the same command.", url.keys));
139
+ else if (keyFile) {
140
+ const read = readKeyFile(keyFile, url.keys);
141
+ if ("missing" in read)
142
+ missing.push(read.missing);
143
+ else if ("refused" in read)
144
+ refused.push(read.refused);
145
+ else {
146
+ key = read.key;
147
+ accountKey = "file";
148
+ if (read.warning)
149
+ warnings.push(read.warning);
150
+ }
151
+ }
152
+ else if (text(env.CATALYST_CLOUD_TOKEN)) {
153
+ key = text(env.CATALYST_CLOUD_TOKEN);
154
+ accountKey = "env";
155
+ }
156
+ else if (saved?.user)
157
+ accountKey = "saved";
158
+ else if (saved) {
159
+ accountKey = "saved";
160
+ refused.push(keyItem("account_key_not_personal", `This machine's saved login is the account's host key. Onboarding acts as a person: supply a personal key from ${url.keys} in CATALYST_CLOUD_TOKEN or --key-file.`, url.keys));
161
+ }
162
+ else
163
+ missing.push(keyItem("account_key_missing", `No account key. Set CATALYST_CLOUD_TOKEN, or name a key file with --key-file or CATALYST_CLOUD_TOKEN_FILE. Mint a personal key at ${url.keys}.`, url.keys));
164
+ if (key !== undefined && (key.length > 4096 || /[\s\u0000-\u001f\u007f]/.test(key))) {
165
+ refused.push(keyItem("account_key_malformed", "The supplied key is not a single-line key. Copy the key again from the API keys page.", url.keys));
166
+ key = undefined;
167
+ accountKey = null;
168
+ }
169
+ const team = text(args.flags.team) ?? text(env.CATALYST_ONBOARD_TEAM);
170
+ if (!team)
171
+ missing.push({
172
+ id: "linear_team",
173
+ kind: "input",
174
+ reason: "team_input_missing",
175
+ text: `Name the Linear team with --team <ID or key> or CATALYST_ONBOARD_TEAM. The workspace's teams are at ${url.teams}.`,
176
+ flag: "--team",
177
+ env: "CATALYST_ONBOARD_TEAM",
178
+ url: url.teams,
179
+ });
180
+ const flagRepos = args.flags.repo;
181
+ const repos = (Array.isArray(flagRepos)
182
+ ? flagRepos
183
+ : typeof flagRepos === "string"
184
+ ? [flagRepos]
185
+ : (env.CATALYST_ONBOARD_REPOS ?? "").split(/[\s,]+/))
186
+ .map((repo) => repo.trim())
187
+ .filter(Boolean);
188
+ if (!repos.length)
189
+ missing.push({
190
+ id: "repositories",
191
+ kind: "input",
192
+ reason: "repositories_input_missing",
193
+ text: `Name the repositories with --repo <owner/name> (repeatable) or CATALYST_ONBOARD_REPOS=owner/a,owner/b. Accessible repositories are at ${url.repos}.`,
194
+ flag: "--repo",
195
+ env: "CATALYST_ONBOARD_REPOS",
196
+ url: url.repos,
197
+ });
198
+ const codingAccount = text(args.flags["coding-account"]) ?? text(env.CATALYST_ONBOARD_CODING_ACCOUNT);
199
+ if (!codingAccount)
200
+ missing.push({
201
+ id: "coding_account",
202
+ kind: "input",
203
+ reason: "coding_account_input_missing",
204
+ text: `Name the coding account with --coding-account <slot> or CATALYST_ONBOARD_CODING_ACCOUNT. catalyst accounts lists the slots; enroll one at ${url.codingAccounts}.`,
205
+ flag: "--coding-account",
206
+ env: "CATALYST_ONBOARD_CODING_ACCOUNT",
207
+ url: url.codingAccounts,
208
+ });
209
+ else if (!slotPattern.test(codingAccount))
210
+ refused.push({
211
+ id: "coding_account",
212
+ kind: "input",
213
+ reason: "coding_account_input_invalid",
214
+ text: "The coding-account slot may hold only letters, digits, - and _. catalyst accounts lists the slots.",
215
+ flag: "--coding-account",
216
+ env: "CATALYST_ONBOARD_CODING_ACCOUNT",
217
+ });
218
+ const runnerText = args.flags.runner === true ? "true" : text(args.flags.runner) ?? (args.flags["no-runner"] === true ? "no" : text(env.CATALYST_ONBOARD_RUNNER));
219
+ const runnerValue = runnerText?.toLowerCase();
220
+ const runner = runnerValue === "yes" || runnerValue === "no" ? runnerValue : null;
221
+ const runnerItem = (reason, sentence) => ({
222
+ id: "runner",
223
+ kind: "input",
224
+ reason,
225
+ text: sentence,
226
+ flag: "--runner",
227
+ env: "CATALYST_ONBOARD_RUNNER",
228
+ });
229
+ if (!runnerText)
230
+ missing.push(runnerItem("runner_input_missing", "Say whether this machine runs agent work with --runner yes|no or CATALYST_ONBOARD_RUNNER."));
231
+ else if (!runner)
232
+ refused.push(runnerItem("runner_input_invalid", "--runner takes yes or no."));
233
+ if (args.flags.runner !== undefined && args.flags["no-runner"] === true)
234
+ refused.push(runnerItem("runner_input_invalid", "Choose one of --runner yes|no and --no-runner."));
235
+ const flags = { ...args.flags, yes: true };
236
+ if (team)
237
+ flags.team = team;
238
+ if (repos.length)
239
+ flags.repo = repos;
240
+ if (codingAccount)
241
+ flags["coding-account"] = codingAccount;
242
+ delete flags["key-file"];
243
+ delete flags.runner;
244
+ delete flags["no-runner"];
245
+ if (runner === "yes")
246
+ flags.runner = true;
247
+ if (runner === "no")
248
+ flags["no-runner"] = true;
249
+ return {
250
+ args: { ...args, key: undefined, baseUrl, flags },
251
+ key,
252
+ report: {
253
+ schema: ONBOARD_HEADLESS_SCHEMA,
254
+ inputs: {
255
+ accountKey,
256
+ baseUrl,
257
+ team,
258
+ repos,
259
+ codingAccount,
260
+ runner,
261
+ },
262
+ missing,
263
+ refused,
264
+ failed: [],
265
+ deferred: [],
266
+ warnings,
267
+ },
268
+ };
269
+ }
270
+ const GRANTS = {
271
+ "linear.workspace": {
272
+ reason: "linear_workspace_grant_missing",
273
+ page: "connections",
274
+ what: "Linear is not connected for this workspace. A workspace owner or administrator connects it",
275
+ },
276
+ "github.install": {
277
+ reason: "github_app_grant_missing",
278
+ page: "connections",
279
+ what: "The GitHub App is not installed for this workspace. A workspace owner or administrator installs it",
280
+ },
281
+ "linear.personal": {
282
+ reason: "linear_personal_grant_missing",
283
+ page: "personal",
284
+ what: "This person's Linear account is not connected. Connect it",
285
+ },
286
+ "github.personal": {
287
+ reason: "github_personal_grant_missing",
288
+ page: "personal",
289
+ what: "This person's GitHub account is not connected. Connect it",
290
+ },
291
+ };
292
+ const GRANT_BY_REASON = new Map(Object.entries(GRANTS).map(([id, grant]) => [grant.reason, id]));
293
+ /** Check-only browser steps: an absent grant waits with a named reason instead of opening a browser.
294
+ * Sign-in has no action either, so the device flow can never start. */
295
+ export function headlessOnboardAdapters(adapters) {
296
+ const out = { ...adapters };
297
+ const checkOnly = (id, reason) => {
298
+ const adapter = adapters[id];
299
+ if (!adapter)
300
+ return;
301
+ out[id] = {
302
+ check: async (ctx, journal, signal) => {
303
+ const result = await adapter.check(ctx, journal, signal);
304
+ return result.state === "pending" ? { state: "waiting", reason } : result;
305
+ },
306
+ };
307
+ };
308
+ for (const [id, grant] of Object.entries(GRANTS))
309
+ checkOnly(id, grant.reason);
310
+ checkOnly("signin", "account_key_missing");
311
+ return out;
312
+ }
313
+ function headlessLoginGuidance(reason, keys) {
314
+ if (reason.endsWith("_login_refresh_required"))
315
+ return `The saved login expired. Supply a personal key from ${keys} in CATALYST_CLOUD_TOKEN or --key-file, then run the same headless setup command.`;
316
+ if (reason === "runner_identity_unverified")
317
+ return `A workspace owner or administrator enrolls a runner. Supply a personal key from ${keys} for that owner or administrator in CATALYST_CLOUD_TOKEN or --key-file, then run the same headless setup command.`;
318
+ return undefined;
319
+ }
320
+ /** Shared by all rendered channels; the raw journal and internal stop classification stay intact. */
321
+ function headlessRetryText(text, keys) {
322
+ const key = `Supply a personal key from ${keys} in CATALYST_CLOUD_TOKEN or --key-file`;
323
+ return text
324
+ .replace(/\b(?:Renew your login with|Sign in as one with|Sign in with|[Rr]un) catalyst login\b/g, key)
325
+ .replace(/\bRenew your login with catalyst onboard\b/g, key)
326
+ .replace(/\bcatalyst onboard\b(?! --help\b)(?: --runner)?(?: again)?/g, "the same headless setup command");
327
+ }
328
+ /** The report's view of a finished journal: each unsatisfied step as one named item. */
329
+ export function headlessStepItems(journal, baseUrl, requireRunner = false, only) {
330
+ const url = pages(baseUrl);
331
+ const refused = [];
332
+ const scope = new Set();
333
+ const include = (id) => {
334
+ if (scope.has(id))
335
+ return;
336
+ scope.add(id);
337
+ for (const parent of ONBOARD_DEPENDENCIES[id] ?? [])
338
+ include(parent);
339
+ };
340
+ if (only)
341
+ include(only);
342
+ const missing = [];
343
+ const failed = [];
344
+ const deferred = [];
345
+ const steps = [...journal.steps].sort((a, b) => ONBOARD_STEPS.indexOf(a.id) - ONBOARD_STEPS.indexOf(b.id));
346
+ for (const step of steps) {
347
+ if (only && !scope.has(step.id))
348
+ continue;
349
+ if (step.state === "done" || (step.state === "skipped" && !(requireRunner && step.id === "runner")))
350
+ continue;
351
+ // A prerequisite wait repeats an earlier item; the earlier one is what to fix.
352
+ if (step.reason === "prerequisite_not_ready")
353
+ continue;
354
+ // Unreached rows have no action yet; retain them only in the raw journal.
355
+ if (step.state === "pending" && !step.reason)
356
+ continue;
357
+ const reason = step.reason ?? "not_checked";
358
+ const grantStep = GRANT_BY_REASON.get(reason);
359
+ const grant = grantStep ? GRANTS[grantStep] : undefined;
360
+ const item = grant
361
+ ? {
362
+ id: step.id,
363
+ kind: "grant",
364
+ reason,
365
+ text: `${grant.what} at ${url[grant.page]}, then run the same command.`,
366
+ url: url[grant.page],
367
+ }
368
+ : reason === "account_key_missing"
369
+ ? keyItem(reason, `No usable login. Supply a personal key from ${url.keys} in CATALYST_CLOUD_TOKEN or --key-file.`, url.keys)
370
+ : {
371
+ id: step.id,
372
+ kind: "step",
373
+ reason,
374
+ text: onboardReasonText(step, {
375
+ baseUrl,
376
+ journal: journal,
377
+ }),
378
+ };
379
+ const loginGuidance = headlessLoginGuidance(reason, url.keys);
380
+ item.text = loginGuidance ?? headlessRetryText(item.text, url.keys);
381
+ if (loginGuidance)
382
+ item.url = url.keys;
383
+ if (step.refused || (requireRunner && step.id === "runner" && reason === "member_scope"))
384
+ refused.push(item);
385
+ else if (step.state === "failed")
386
+ failed.push(item);
387
+ else if (!only && ONBOARD_DEFERRED_STEPS.has(step.id) && !(requireRunner && step.id === "runner") &&
388
+ !reason.endsWith("_login_refresh_required") && reason !== "interrupted")
389
+ deferred.push(item);
390
+ else
391
+ missing.push(item);
392
+ }
393
+ return { missing, failed, deferred, refused };
394
+ }
395
+ function contractCode(code) {
396
+ return [0, EXIT_FAILED, EXIT_WAITING, EXIT_REFUSED].includes(code)
397
+ ? code
398
+ : EXIT_FAILED;
399
+ }
400
+ const notPersonal = (keys) => keyItem("account_key_not_personal", `This is the account's host key. Onboarding acts as a person: mint a personal key at ${keys} and supply that instead.`, keys);
401
+ function loginItem(error, baseUrl) {
402
+ const url = pages(baseUrl);
403
+ if (error instanceof CliError && error.code === "onboard-person-required")
404
+ return { code: EXIT_REFUSED, item: notPersonal(url.keys) };
405
+ if (error instanceof MeError && error.kind === "http" && (error.status === 401 || error.status === 403))
406
+ return {
407
+ code: EXIT_WAITING,
408
+ item: keyItem("account_key_rejected", `Catalyst did not accept the supplied key. Mint a personal key at ${url.keys} and supply it again.`, url.keys),
409
+ };
410
+ return {
411
+ code: EXIT_FAILED,
412
+ item: {
413
+ id: "signin",
414
+ kind: "step",
415
+ reason: error instanceof MeError && error.kind === "network" ? "cloud_unreachable" : "key_login_failed",
416
+ text: `Signing in with the supplied key did not finish against ${baseUrl}. Check the address and the network, then run the same command.`,
417
+ },
418
+ };
419
+ }
420
+ /** Why onboarding stopped before its first step, from the one line it wrote and its exit code. */
421
+ function stopItem(code, said, baseUrl, errorCode) {
422
+ const url = pages(baseUrl);
423
+ if (errorCode === "onboard-login-refresh-required" || /Renew your login|login needs renewal/.test(said))
424
+ return keyItem("saved_login_expired", `The saved login expired. Supply a personal key from ${url.keys} in CATALYST_CLOUD_TOKEN or --key-file.`, url.keys);
425
+ if (errorCode === "onboard-person-required" || /sign in as yourself|personal login|personal key/i.test(said))
426
+ return notPersonal(url.keys);
427
+ if (errorCode === "onboard-identity-mismatch" || /belongs to another workspace or member/.test(said))
428
+ return keyItem("account_key_other_login", "This machine's setup record belongs to another workspace or person. Supply that person's key.", url.keys);
429
+ return {
430
+ id: "onboard",
431
+ kind: "step",
432
+ reason: errorCode ?? (code === EXIT_REFUSED ? "onboard_refused" : code === EXIT_FAILED ? "onboard_failed" : "onboard_waiting"),
433
+ text: headlessRetryText(said, url.keys) || "Onboarding stopped before its first step. Run the same command to retry.",
434
+ };
435
+ }
436
+ /** The whole headless run. Whatever happens, stdout gets exactly one document in --json mode. */
437
+ export async function runOnboardHeadless(args, ctx, hooks, cliVersion) {
438
+ const plan = planOnboardHeadless(args, ctx);
439
+ const report = plan.report;
440
+ const baseUrl = report.inputs.baseUrl;
441
+ const keys = pages(baseUrl).keys;
442
+ const emit = (journal, code) => {
443
+ for (const warning of report.warnings)
444
+ ctx.stderr(`! ${warning}`);
445
+ if (args.json) {
446
+ const only = typeof plan.args.flags.only === "string" &&
447
+ ONBOARD_STEPS.includes(plan.args.flags.only)
448
+ ? plan.args.flags.only : undefined;
449
+ const paused = code === EXIT_WAITING && (typeof journal.verdict === "string"
450
+ ? journal.verdict === "paused"
451
+ : [...report.missing, ...report.failed].some((item) => item.reason === "interrupted"));
452
+ const view = onboardJsonView(journal.mode === "plan" ? journal : { ...journal, exit: code }, baseUrl, paused, {
453
+ only,
454
+ requiredSteps: report.inputs.runner === "yes" ? ["runner"] : [],
455
+ });
456
+ const actions = view.actions.map((action) => {
457
+ const loginGuidance = headlessLoginGuidance(journal.steps.find((step) => step.id === action.step)?.reason ?? "", keys);
458
+ return { ...action, text: loginGuidance ?? headlessRetryText(action.text, keys), ...(loginGuidance ? { url: keys } : {}) };
459
+ });
460
+ const next = headlessRetryText(view.next.replace(/(?:run )?catalyst onboard(?: --runner)?(?: again)?/g, "run the same headless setup command"), keys);
461
+ ctx.stdout(JSON.stringify({ ...view, actions, next, headless: report }));
462
+ }
463
+ else
464
+ for (const [label, items] of [
465
+ ["Refused", report.refused],
466
+ ["Missing", report.missing],
467
+ ["Failed", report.failed],
468
+ ])
469
+ for (const item of items)
470
+ ctx.stderr(`${label}: ${item.text}`);
471
+ return code;
472
+ };
473
+ const early = (code, item) => {
474
+ if (item)
475
+ (code === EXIT_REFUSED ? report.refused : code === EXIT_WAITING ? report.missing : report.failed).push(item);
476
+ return emit(onboardErrorJournal(ctx, cliVersion, code), code);
477
+ };
478
+ if (report.refused.length)
479
+ return early(EXIT_REFUSED);
480
+ if (report.missing.length)
481
+ return early(EXIT_WAITING);
482
+ const dryRun = args.flags["dry-run"] === true;
483
+ // A supplied key replaces the saved login only for the same person, and a dry run saves nothing.
484
+ if (plan.key !== undefined && !dryRun) {
485
+ let saved = null;
486
+ try {
487
+ saved = loadConfig(ctx.home);
488
+ }
489
+ catch {
490
+ saved = null;
491
+ }
492
+ const current = saved?.key === plan.key && normalizeBaseUrl(saved.baseUrl) === baseUrl;
493
+ if (!current) {
494
+ let me;
495
+ try {
496
+ me = await fetchMe(baseUrl, plan.key, ctx.fetch);
497
+ }
498
+ catch (error) {
499
+ const { item, code } = loginItem(error, baseUrl);
500
+ return early(code, item);
501
+ }
502
+ if (!me.user)
503
+ return early(EXIT_REFUSED, notPersonal(pages(baseUrl).keys));
504
+ let receipt = null;
505
+ try {
506
+ receipt = readOnboardJournal(onboardStatePath(ctx.home, ctx.env), cliVersion, ctx.now());
507
+ }
508
+ catch {
509
+ receipt = null;
510
+ }
511
+ const receiptAccount = receipt?.account ?? receipt?.tenant;
512
+ if ((saved &&
513
+ (saved.account !== me.account ||
514
+ (saved.user?.id !== undefined && saved.user.id !== me.user.id) ||
515
+ normalizeBaseUrl(saved.baseUrl) !== baseUrl)) ||
516
+ (receiptAccount && receiptAccount !== me.account) ||
517
+ (receipt?.membershipId && receipt.membershipId !== me.user.id))
518
+ return early(EXIT_REFUSED, keyItem("account_key_other_login", "The supplied key belongs to another workspace or person than this machine's saved login or setup record. Supply that person's key, or set this machine up from a clean home.", pages(baseUrl).keys));
519
+ try {
520
+ // Login output is not the run's result; it never reaches stdout.
521
+ const loginOutput = (line) => ctx.stderr(headlessRetryText(line, keys));
522
+ const code = await hooks.login(plan.key, baseUrl, { ...ctx, stdout: loginOutput, stderr: loginOutput });
523
+ if (code !== 0)
524
+ throw new CliError("key login failed", "key-login-failed", EXIT_FAILED);
525
+ }
526
+ catch (error) {
527
+ const { item, code } = loginItem(error, baseUrl);
528
+ return early(code, item);
529
+ }
530
+ }
531
+ }
532
+ // The steps never need the key itself; keep it out of every child process they start.
533
+ const env = { ...ctx.env };
534
+ delete env.CATALYST_CLOUD_TOKEN;
535
+ delete env.CATALYST_CLOUD_TOKEN_FILE;
536
+ if (ctx.env === process.env) {
537
+ delete process.env.CATALYST_CLOUD_TOKEN;
538
+ delete process.env.CATALYST_CLOUD_TOKEN_FILE;
539
+ }
540
+ const captured = [];
541
+ const said = [];
542
+ const runCtx = {
543
+ ...ctx,
544
+ env,
545
+ stdout: args.json ? (line) => captured.push(line) : (line) => ctx.stdout(line === "resume: catalyst onboard" ? "resume: run the same headless setup command" :
546
+ headlessRetryText(line, keys)),
547
+ stderr: (line) => {
548
+ said.push(line);
549
+ ctx.stderr(headlessRetryText(line, keys));
550
+ },
551
+ };
552
+ const tracker = { stepsRan: false };
553
+ let code;
554
+ let errorCode;
555
+ try {
556
+ code = contractCode(await hooks.onboard(plan.args, runCtx, tracker));
557
+ }
558
+ catch (error) {
559
+ code = contractCode(error instanceof CliError ? error.exitCode : EXIT_FAILED);
560
+ if (error instanceof CliError)
561
+ errorCode = error.code;
562
+ if (error instanceof UsageError)
563
+ code = EXIT_REFUSED;
564
+ const line = error instanceof CliError || error instanceof UsageError
565
+ ? `catalyst: ${error.message}`
566
+ : "catalyst: onboarding stopped on an unexpected error. Run the same command to resume.";
567
+ said.push(line);
568
+ ctx.stderr(headlessRetryText(line, keys));
569
+ }
570
+ let journal = null;
571
+ for (const line of captured) {
572
+ try {
573
+ const value = JSON.parse(line);
574
+ if (value && value.schema === 1 && Array.isArray(value.steps)) {
575
+ journal = value;
576
+ continue;
577
+ }
578
+ }
579
+ catch {
580
+ /* not the result document */
581
+ }
582
+ ctx.stderr(headlessRetryText(line, keys));
583
+ }
584
+ if (!args.json && !journal) {
585
+ try {
586
+ journal = readOnboardJournal(onboardStatePath(ctx.home, ctx.env), cliVersion, ctx.now());
587
+ }
588
+ catch {
589
+ journal = null;
590
+ }
591
+ }
592
+ if (tracker.stepsRan && journal) {
593
+ const items = headlessStepItems(journal, baseUrl, report.inputs.runner === "yes", typeof plan.args.flags.only === "string" ? plan.args.flags.only : undefined);
594
+ report.refused.push(...items.refused);
595
+ report.missing.push(...items.missing);
596
+ report.failed.push(...items.failed);
597
+ report.deferred.push(...items.deferred);
598
+ }
599
+ else if (code !== 0) {
600
+ // Stopped before any step: an older receipt's steps are not this run's reasons.
601
+ const item = stopItem(code, said.filter(Boolean).at(-1) ?? "", baseUrl, errorCode ?? journal?.errorCode);
602
+ (code === EXIT_REFUSED ? report.refused : code === EXIT_WAITING ? report.missing : report.failed).push(item);
603
+ }
604
+ return emit(journal ?? onboardErrorJournal(ctx, cliVersion, code), code);
605
+ }
606
+ /** cmdOnboard's dependencies for a headless run: no terminal UI, no browser sign-in, check-only
607
+ * browser steps, and a confirmation that can only decline (--yes means it is never reached). */
608
+ export function headlessOnboardDeps(deps, tracker, requireRunner = false) {
609
+ const { stageSignin: _stageSignin, ui: _ui, ...rest } = deps;
610
+ return {
611
+ ...rest,
612
+ adapters: headlessOnboardAdapters(deps.adapters ?? {}),
613
+ ...(requireRunner ? { requiredSteps: [...(deps.requiredSteps ?? []), "runner"] } : {}),
614
+ beforeStep: async (id) => {
615
+ if (tracker)
616
+ tracker.stepsRan = true;
617
+ await deps.beforeStep?.(id);
618
+ },
619
+ isTty: () => false,
620
+ confirm: async () => false,
621
+ };
622
+ }
623
+ /** A command line the parser refused under --headless still gets the headless exit and document. */
624
+ export function headlessUsageRefusal(ctx, message, json, cliVersion) {
625
+ const item = {
626
+ id: "command_line",
627
+ kind: "input",
628
+ reason: "usage",
629
+ text: `${message}. Run catalyst onboard --help for the options.`,
630
+ };
631
+ ctx.stderr(`Refused: ${item.text}`);
632
+ if (json)
633
+ ctx.stdout(JSON.stringify({
634
+ ...onboardJsonView(onboardErrorJournal(ctx, cliVersion, EXIT_REFUSED)),
635
+ next: "run the same headless setup command",
636
+ headless: {
637
+ schema: ONBOARD_HEADLESS_SCHEMA,
638
+ inputs: null,
639
+ missing: [],
640
+ refused: [item],
641
+ failed: [],
642
+ deferred: [],
643
+ warnings: [],
644
+ },
645
+ }));
646
+ return EXIT_REFUSED;
647
+ }
@@ -0,0 +1,41 @@
1
+ const OPTIONAL_WAITS = new Set([
2
+ "linear.workspace",
3
+ "linear.personal",
4
+ "github.install",
5
+ "github.personal",
6
+ "accounts",
7
+ ]);
8
+ /** A browser wait can stop without cancelling the rest of setup. Sign-in cannot. */
9
+ export function createOnboardInterrupts(stop, now = () => Date.now()) {
10
+ let step = new AbortController();
11
+ let current;
12
+ let waiting = false;
13
+ let skippedAt;
14
+ return {
15
+ get signal() {
16
+ return step.signal;
17
+ },
18
+ begin(id) {
19
+ current = id;
20
+ waiting = false;
21
+ step = new AbortController();
22
+ return step.signal;
23
+ },
24
+ waiting(value) {
25
+ waiting = value;
26
+ },
27
+ interrupt(event) {
28
+ const withinStopWindow = skippedAt !== undefined && now() - skippedAt < 2000;
29
+ if (event === "SIGINT" &&
30
+ !withinStopWindow &&
31
+ waiting &&
32
+ current &&
33
+ OPTIONAL_WAITS.has(current)) {
34
+ skippedAt = now();
35
+ step.abort();
36
+ return;
37
+ }
38
+ stop.abort();
39
+ },
40
+ };
41
+ }