@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,100 @@
1
+ const MARKS = [
2
+ "done",
3
+ "act",
4
+ "fail",
5
+ "skip",
6
+ "later",
7
+ "run",
8
+ "ask",
9
+ ];
10
+ const isStepMark = (value) => MARKS.some((mark) => mark === value);
11
+ const WHOLE = /^(0|[1-9][0-9]{0,8})$/;
12
+ function parse(kind, f) {
13
+ const text = (n) => (f.length === n ? f : null);
14
+ switch (kind) {
15
+ case "line":
16
+ case "heading":
17
+ case "more":
18
+ case "detail":
19
+ case "change":
20
+ case "note":
21
+ return text(1) && { kind, text: f[0] ?? "" };
22
+ case "row":
23
+ return text(2) && { kind, label: f[0] ?? "", value: f[1] ?? "" };
24
+ case "plan":
25
+ return text(3) && WHOLE.test(f[0] ?? "")
26
+ ? { kind, number: Number(f[0]), title: f[1] ?? "", text: f[2] ?? "" }
27
+ : null;
28
+ case "begin":
29
+ return text(2) && WHOLE.test(f[0] ?? "")
30
+ ? { kind, number: Number(f[0]), title: f[1] ?? "" }
31
+ : null;
32
+ case "step": {
33
+ const mark = f[0] === "ok" ? "done" : f[0] === "warn" ? "act" : f[0];
34
+ return text(4) && isStepMark(mark) && WHOLE.test(f[1] ?? "")
35
+ ? {
36
+ kind,
37
+ mark,
38
+ number: Number(f[1]),
39
+ title: f[2] ?? "",
40
+ outcome: f[3] ?? "",
41
+ }
42
+ : null;
43
+ }
44
+ case "changes-end":
45
+ return text(0) && { kind };
46
+ case "verdict":
47
+ return text(2) && (f[0] === "ok" || f[0] === "fail")
48
+ ? { kind, ok: f[0] === "ok", text: f[1] ?? "" }
49
+ : null;
50
+ case "stop": {
51
+ if (!text(6))
52
+ return null;
53
+ const [header = "", what = "", done = "", fix = "", log = "", resume = "",] = f;
54
+ return { kind, header, what, done, fix, log, resume };
55
+ }
56
+ case "ask":
57
+ return text(3) && /^[a-z][a-z0-9-]{0,40}$/.test(f[0] ?? "")
58
+ ? { kind, id: f[0] ?? "", question: f[1] ?? "", fallback: f[2] ?? "" }
59
+ : null;
60
+ case "signin":
61
+ return text(1) && WHOLE.test(f[0] ?? "")
62
+ ? { kind, timeoutSeconds: Number(f[0]) }
63
+ : null;
64
+ default:
65
+ return null;
66
+ }
67
+ }
68
+ export function parseSetupEvent(raw) {
69
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
70
+ const [kind = "", ...fields] = line.split("\t");
71
+ return (parse(kind, fields) ?? { kind: "line", text: line.replaceAll("\t", " ") });
72
+ }
73
+ /** Turns a stream's chunks into events, one per completed line, the moment each line completes. */
74
+ export class SetupEventStream {
75
+ onEvent;
76
+ parse;
77
+ #pending = "";
78
+ constructor(onEvent, parse = parseSetupEvent) {
79
+ this.onEvent = onEvent;
80
+ this.parse = parse;
81
+ }
82
+ push(chunk) {
83
+ const lines = (this.#pending + chunk).split("\n");
84
+ this.#pending = lines.pop() ?? "";
85
+ for (const line of lines)
86
+ this.onEvent(this.parse(line));
87
+ }
88
+ end() {
89
+ if (this.#pending !== "")
90
+ this.onEvent(this.parse(this.#pending));
91
+ this.#pending = "";
92
+ }
93
+ }
94
+ /** A line outside the protocol (the engine's own stdout or stderr), shown as it is. */
95
+ export function plainSetupLine(line) {
96
+ return {
97
+ kind: "line",
98
+ text: (line.endsWith("\r") ? line.slice(0, -1) : line).replaceAll("\t", " "),
99
+ };
100
+ }
@@ -0,0 +1,438 @@
1
+ import { ONBOARD_DEPENDENCIES, ONBOARD_STEPS, ONBOARD_TITLES, } from "./onboard.js";
2
+ import { onboardReasonText } from "./onboard-next.js";
3
+ export const SETUP_NUMBERS = {
4
+ machine: 1,
5
+ cli: 1,
6
+ skills: 1,
7
+ legacy: 1,
8
+ signin: 4,
9
+ housekeeping: 5,
10
+ "linear.workspace": 6,
11
+ "linear.personal": 7,
12
+ "linear.team": 8,
13
+ "linear.adopt": 9,
14
+ "linear.automations": 10,
15
+ "github.install": 11,
16
+ "github.personal": 12,
17
+ "github.repos": 13,
18
+ projects: 13,
19
+ accounts: 14,
20
+ capacity: 15,
21
+ runner: 15,
22
+ settings: 16,
23
+ values: 16,
24
+ "first-ticket": 17,
25
+ };
26
+ const TITLES = {
27
+ "linear.adopt": "Set up the team's workflow",
28
+ "linear.automations": "Check Linear's PR automations",
29
+ projects: "Choose repositories",
30
+ accounts: "Add a coding account",
31
+ capacity: "Check runners",
32
+ values: "Review repository settings",
33
+ };
34
+ const OPTIONAL = new Set([
35
+ "local_sync_not_selected",
36
+ "housekeeping_service_unverified",
37
+ "member_scope",
38
+ "automation_management_unavailable",
39
+ "runner_not_selected",
40
+ "legacy_not_selected",
41
+ "settings_checkout_unverified",
42
+ "settings_approval_unverified",
43
+ ]);
44
+ export const CONNECTION_REPAIR_REASONS = new Set([
45
+ "github_app_permissions_outdated", "github_app_repository_missing", "github_app_repository_not_installed",
46
+ "linear_workspace_scope_outdated", "linear_personal_scope_outdated",
47
+ "github_app_permissions_unverified", "github_app_repository_access_unverified",
48
+ "linear_workspace_permissions_unverified", "personal_permissions_unverified",
49
+ ]);
50
+ const clean = (s) => s.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 300);
51
+ function value(step, key) {
52
+ const v = step.evidence?.[key];
53
+ return typeof v === "string" && v.length ? clean(v) : undefined;
54
+ }
55
+ function succeeded(step) {
56
+ switch (step.id) {
57
+ case "linear.workspace":
58
+ case "linear.personal":
59
+ case "github.personal":
60
+ return value(step, "username")
61
+ ? `connected as ${value(step, "username")}`
62
+ : "connected";
63
+ case "github.install":
64
+ return "installed";
65
+ case "linear.team":
66
+ return value(step, "teamKey") ?? "team chosen";
67
+ case "linear.adopt":
68
+ return "workflow ready";
69
+ case "linear.automations":
70
+ return "nothing to change";
71
+ case "github.repos":
72
+ return `${typeof step.evidence?.count === "number" ? step.evidence.count : "selected"} repositories chosen`;
73
+ case "projects":
74
+ return "repositories are now Catalyst projects";
75
+ case "accounts":
76
+ return `${value(step, "provider") === "claude" ? "Claude" : value(step, "provider") === "codex" ? "Codex" : "a coding account"} can take work`;
77
+ case "capacity":
78
+ return typeof step.evidence?.remainingUnits === "number"
79
+ ? `${step.evidence.remainingUnits} runner slots available`
80
+ : "runners can take work";
81
+ case "settings":
82
+ case "values":
83
+ return "settings reviewed";
84
+ case "first-ticket":
85
+ return value(step, "ticketKey")
86
+ ? `${value(step, "ticketKey")} started`
87
+ : "first ticket started";
88
+ case "daemon":
89
+ return "local sync is running";
90
+ case "runner":
91
+ return "runner is running on this computer";
92
+ default:
93
+ return "ready";
94
+ }
95
+ }
96
+ function unfinished(step) {
97
+ if (step.reason?.endsWith("_scope_outdated") || step.reason === "github_app_permissions_outdated")
98
+ return "needs updated permissions";
99
+ if (step.reason === "github_app_repository_missing")
100
+ return "cannot reach a project repository";
101
+ if (step.reason === "github_app_repository_not_installed")
102
+ return "no installation reaches the project repository";
103
+ if (CONNECTION_REPAIR_REASONS.has(step.reason ?? ""))
104
+ return "could not verify the connection's permissions";
105
+ if (step.reason === "housekeeping_service_unverified")
106
+ return "no scheduler here. catalyst says when an update is out";
107
+ if (step.reason === "local_sync_not_selected")
108
+ return "off, reads come from the cloud";
109
+ if (step.reason === "runner_docker_missing")
110
+ return "Docker is not running";
111
+ if (step.reason === "member_scope")
112
+ return "an admin handles this step";
113
+ if (step.reason === "automation_management_unavailable")
114
+ return "could not read them; checked again before work starts";
115
+ if (step.reason === "interrupted")
116
+ return "skipped for now";
117
+ if (step.state === "failed")
118
+ return "could not finish this step";
119
+ switch (step.id) {
120
+ case "github.install":
121
+ return "not installed yet";
122
+ case "accounts":
123
+ return "no coding account ready yet";
124
+ case "linear.workspace":
125
+ case "linear.personal":
126
+ case "github.personal":
127
+ return "not connected yet";
128
+ case "linear.adopt":
129
+ return "can't be set up from here yet";
130
+ case "capacity":
131
+ return "no runner can take work yet";
132
+ case "settings":
133
+ case "values":
134
+ return "optional; not reviewed yet";
135
+ default:
136
+ return step.state === "skipped" ? "not chosen" : "not done yet";
137
+ }
138
+ }
139
+ export function setupStepView(step, journal, required = false) {
140
+ required ||= step.id === "runner" && step.evidence?.selected === true;
141
+ let mark = step.state === "done"
142
+ ? "done"
143
+ : step.state === "failed"
144
+ ? "fail"
145
+ : step.reason === "prerequisite_not_ready" ||
146
+ (step.state === "pending" && !step.reason)
147
+ ? "later"
148
+ : !required &&
149
+ (step.state === "skipped" || OPTIONAL.has(step.reason ?? ""))
150
+ ? "skip"
151
+ : "act";
152
+ let outcome = step.state === "done" ? succeeded(step) : unfinished(step);
153
+ if (mark === "later") {
154
+ const numbers = [
155
+ ...new Set((ONBOARD_DEPENDENCIES[step.id] ?? [])
156
+ .filter((id) => {
157
+ const s = journal?.steps.find((s) => s.id === id);
158
+ return s?.state !== "done" && s?.state !== "skipped";
159
+ })
160
+ .map((id) => SETUP_NUMBERS[id])
161
+ .filter((n) => n !== undefined)),
162
+ ];
163
+ outcome =
164
+ step.state === "pending" && !step.reason
165
+ ? "not checked yet"
166
+ : numbers.length
167
+ ? `after ${numbers.length === 1 ? "step" : "steps"} ${numbers.join(" and ")}`
168
+ : "after an earlier step";
169
+ }
170
+ return {
171
+ number: SETUP_NUMBERS[step.id] ?? 0,
172
+ title: TITLES[step.id] ?? ONBOARD_TITLES[step.id],
173
+ mark,
174
+ outcome,
175
+ };
176
+ }
177
+ function page(base, path) {
178
+ try {
179
+ const url = new URL(path, base);
180
+ return ["https:", "http:"].includes(url.protocol) &&
181
+ !url.username &&
182
+ !url.password
183
+ ? url.href
184
+ : undefined;
185
+ }
186
+ catch {
187
+ return undefined;
188
+ }
189
+ }
190
+ function action(step, base, journal) {
191
+ if (CONNECTION_REPAIR_REASONS.has(step.reason ?? ""))
192
+ return onboardReasonText(step, { baseUrl: base, journal });
193
+ const link = (path, verb) => {
194
+ const url = page(base, path);
195
+ return url
196
+ ? `Open ${url} and ${verb}.`
197
+ : `Open Catalyst settings and ${verb}.`;
198
+ };
199
+ switch (step.id) {
200
+ case "linear.workspace":
201
+ return link("/settings/connections?connect=linear", "connect Linear");
202
+ case "linear.personal":
203
+ return link("/settings/connected-accounts?connect=linear", "connect your Linear account");
204
+ case "github.install":
205
+ return link("/settings/connections?install=github", "install Catalyst on your GitHub organization");
206
+ case "github.personal":
207
+ return link("/settings/connected-accounts?connect=github", "connect your GitHub account");
208
+ case "accounts":
209
+ return link("/settings/coding-accounts", "add a coding account");
210
+ case "linear.adopt": {
211
+ const team = journal?.steps.find((row) => row.id === "linear.team" && row.state === "done");
212
+ const key = team && value(team, "teamKey");
213
+ return link(key
214
+ ? `/settings/linear-teams/${encodeURIComponent(key)}/adopt`
215
+ : "/settings/linear-teams", "set up the team's Catalyst workflow");
216
+ }
217
+ case "linear.automations":
218
+ return "Open your Linear team settings and set each pull request automation to No action.";
219
+ case "capacity":
220
+ case "runner":
221
+ return step.reason
222
+ ? onboardReasonText(step, { baseUrl: base, journal })
223
+ : "Run catalyst onboard --runner to check whether this computer can take work.";
224
+ case "settings":
225
+ case "values":
226
+ return "Open your repository checkout and review its settings.";
227
+ case "linear.team":
228
+ return "Choose a Linear team when setup checks again.";
229
+ case "github.repos":
230
+ case "projects":
231
+ return "Choose repositories when setup checks again.";
232
+ case "first-ticket":
233
+ return "Choose a first ticket when setup checks again.";
234
+ default:
235
+ return "Try this step again when setup checks again.";
236
+ }
237
+ }
238
+ export function setupFinalScreen(journal, base, paused = false, required = new Set()) {
239
+ const roots = journal.steps
240
+ .filter((step) => {
241
+ const m = setupStepView(step, journal, required.has(step.id)).mark;
242
+ return ((m === "act" || m === "fail") &&
243
+ (ONBOARD_DEPENDENCIES[step.id] ?? []).every((id) => {
244
+ const parent = journal.steps.find((s) => s.id === id);
245
+ return parent?.state === "done" || parent?.state === "skipped";
246
+ }));
247
+ })
248
+ .filter((step) => step.id !== "ready")
249
+ .sort((a, b) => ONBOARD_STEPS.indexOf(a.id) - ONBOARD_STEPS.indexOf(b.id));
250
+ const folded = (id) => id === "projects" ? "github.repos" : id === "values" ? "settings" : id;
251
+ const unique = roots.filter((step, i) => roots.findIndex((s) => folded(s.id) === folded(step.id)) === i);
252
+ const actions = unique
253
+ .slice(0, 5)
254
+ .map((step) => ({ id: step.id, text: action(step, base, journal) }));
255
+ const readiness = journal.steps.find((step) => step.id === "ready");
256
+ const readinessCause = !paused &&
257
+ !actions.length &&
258
+ readiness &&
259
+ (readiness.state === "failed" || readiness.state === "waiting")
260
+ ? readiness.reason === "onboarding_checks_pending"
261
+ ? readiness.state === "failed"
262
+ ? "A required check failed. Run catalyst onboard again to retry."
263
+ : undefined
264
+ : clean(onboardReasonText(readiness, { baseUrl: base, journal }))
265
+ : undefined;
266
+ const heading = paused
267
+ ? "Setup paused"
268
+ : journal.steps.some((step) => step.id === "runner" &&
269
+ (step.evidence?.selected === true || required.has(step.id)) &&
270
+ step.state !== "done")
271
+ ? "Not ready for work yet"
272
+ : journal.exit === 0 && journal.complete
273
+ ? "Setup complete"
274
+ : journal.exit === 0
275
+ ? "Ready for work"
276
+ : "Not ready for work yet";
277
+ const ticket = journal.steps.find((s) => s.id === "first-ticket" && s.state === "done");
278
+ const key = ticket && value(ticket, "ticketKey");
279
+ const savedTicketUrl = ticket && (value(ticket, "url") ?? value(ticket, "ticketUrl"));
280
+ const ticketUrl = savedTicketUrl && page(base, savedTicketUrl);
281
+ const next = paused
282
+ ? "Next: run catalyst onboard to continue."
283
+ : key && ticketUrl
284
+ ? `Next: open ${ticketUrl} and follow ${key}.`
285
+ : heading === "Setup complete"
286
+ ? "Next: move a ticket to Todo in Linear."
287
+ : journal.exit === 0 && actions[0]
288
+ ? `Next: ${actions[0].text[0].toLowerCase() + actions[0].text.slice(1)}`
289
+ : actions.length === 1
290
+ ? "Next: run catalyst onboard after you finish 1."
291
+ : actions.length === 2
292
+ ? "Next: run catalyst onboard after you finish 1 and 2."
293
+ : actions.length
294
+ ? "Next: run catalyst onboard after you finish 1 to " +
295
+ actions.length +
296
+ "."
297
+ : "Next: run catalyst onboard to check again.";
298
+ return {
299
+ heading,
300
+ readinessCause,
301
+ actions,
302
+ more: unique.length - actions.length,
303
+ next,
304
+ paused,
305
+ };
306
+ }
307
+ export function setupBrowserInstruction(id, base, opened = false) {
308
+ const copy = {
309
+ "linear.workspace": [
310
+ "/settings/connections?connect=linear",
311
+ "connect Linear",
312
+ "Connect Linear",
313
+ ],
314
+ "linear.personal": [
315
+ "/settings/connected-accounts?connect=linear",
316
+ "connect your Linear account",
317
+ "Connect your Linear account",
318
+ ],
319
+ "github.install": [
320
+ "/settings/connections?install=github",
321
+ "install Catalyst on your GitHub organization",
322
+ "Install Catalyst on your GitHub organization",
323
+ ],
324
+ "github.personal": [
325
+ "/settings/connected-accounts?connect=github",
326
+ "connect your GitHub account",
327
+ "Connect your GitHub account",
328
+ ],
329
+ accounts: [
330
+ "/settings/coding-accounts",
331
+ "add the token with your Claude email",
332
+ "Add a coding account",
333
+ ],
334
+ };
335
+ const words = copy[id];
336
+ if (!words)
337
+ return undefined;
338
+ return {
339
+ instruction: opened
340
+ ? `Opened your browser. ${words[2]} there.`
341
+ : `Open this link and ${words[1]}:`,
342
+ url: page(base, words[0]),
343
+ preparation: id === "accounts"
344
+ ? "In another terminal, run claude setup-token."
345
+ : undefined,
346
+ };
347
+ }
348
+ /** JSON facts use the same reachable actions as the terminal, while retaining the existing journal. */
349
+ export function onboardJsonView(journal, base, paused = false, options = {}) {
350
+ const needed = new Set();
351
+ const include = (id) => {
352
+ if (needed.has(id))
353
+ return;
354
+ needed.add(id);
355
+ for (const parent of ONBOARD_DEPENDENCIES[id] ?? [])
356
+ include(parent);
357
+ };
358
+ if (options.only)
359
+ include(options.only);
360
+ const explicitRequired = new Set(options.requiredSteps ?? []);
361
+ const required = new Set([
362
+ ...explicitRequired,
363
+ ...journal.steps
364
+ .filter((step) => needed.has(step.id) && step.state === "waiting")
365
+ .map((step) => step.id),
366
+ ]);
367
+ const view = {
368
+ ...journal,
369
+ steps: journal.steps
370
+ .filter((step) => !options.only || needed.has(step.id))
371
+ .map((step) => explicitRequired.has(step.id) && step.state === "skipped"
372
+ ? { ...step, state: "waiting" }
373
+ : step),
374
+ };
375
+ const screen = setupFinalScreen(view, base, paused, required);
376
+ const verdict = paused
377
+ ? "paused"
378
+ : journal.exit === 10
379
+ ? "failed"
380
+ : screen.heading === "Setup complete"
381
+ ? "complete"
382
+ : screen.heading === "Ready for work"
383
+ ? "ready"
384
+ : "not-ready";
385
+ const actions = screen.actions.map((action) => {
386
+ const step = view.steps.find((step) => step.id === action.id);
387
+ const role = view.steps.find((step) => step.id === "signin" && step.state === "done")?.evidence?.role;
388
+ const admin = step.reason === "member_scope" ||
389
+ step.reason?.endsWith("_identity_unverified") ||
390
+ (action.id === "accounts" && role !== "owner" && role !== "admin");
391
+ const url = action.text
392
+ .match(/https?:\/\/[^\s<>]+/)?.[0]
393
+ ?.replace(/[.,;:!?]+$/, "");
394
+ const text = url
395
+ ? action.text.replace(`Open ${url} and `, "")
396
+ : action.text;
397
+ return {
398
+ step: action.id,
399
+ number: SETUP_NUMBERS[action.id] ?? 0,
400
+ text: text.charAt(0).toUpperCase() + text.slice(1),
401
+ ...(url ? { url } : {}),
402
+ who: admin
403
+ ? "admin"
404
+ : action.id === "github.install"
405
+ ? "github-org-admin"
406
+ : [
407
+ "linear.personal",
408
+ "github.personal",
409
+ "signin",
410
+ "linear.team",
411
+ "github.repos",
412
+ "projects",
413
+ "first-ticket",
414
+ "settings",
415
+ "values",
416
+ "accounts",
417
+ "runner",
418
+ "machine",
419
+ "cli",
420
+ "skills",
421
+ "legacy",
422
+ "daemon",
423
+ "housekeeping",
424
+ ].includes(action.id)
425
+ ? "you"
426
+ : "admin",
427
+ };
428
+ });
429
+ return {
430
+ ...journal,
431
+ verdict,
432
+ actions,
433
+ next: journal.exit === 0 &&
434
+ !screen.next.startsWith("Next: run catalyst onboard")
435
+ ? screen.next.replace(/^Next: /, "")
436
+ : "catalyst onboard",
437
+ };
438
+ }
@@ -0,0 +1,94 @@
1
+ import { Writable } from "node:stream";
2
+ import stringWidth from "fast-string-width";
3
+ /** Follow only cursor commands used by Clack. An unknown position keeps the submitted form. */
4
+ export function promptCursor(columns) {
5
+ let row = 0, col = 0, known = true;
6
+ return {
7
+ feed(text) {
8
+ const tokens = text.match(/\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|[^\x1b]+|\x1b/g) ?? [];
9
+ for (const token of tokens) {
10
+ if (token.startsWith("\x1b[")) {
11
+ const command = token.at(-1);
12
+ const amount = Number.parseInt(token.slice(2), 10) || 1;
13
+ switch (command) {
14
+ case "A":
15
+ row -= amount;
16
+ break;
17
+ case "B":
18
+ row += amount;
19
+ break;
20
+ case "C":
21
+ col = Math.min(columns - 1, col + amount);
22
+ break;
23
+ case "D":
24
+ col = Math.max(0, col - amount);
25
+ break;
26
+ case "G":
27
+ col = amount - 1;
28
+ break;
29
+ case "J":
30
+ case "K":
31
+ case "m":
32
+ case "h":
33
+ case "l":
34
+ break;
35
+ default:
36
+ known = false;
37
+ }
38
+ if (row < 0)
39
+ known = false;
40
+ }
41
+ else if (token.startsWith("\x1b]"))
42
+ continue;
43
+ else if (token === "\x1b")
44
+ known = false;
45
+ else
46
+ for (const part of token.split(/([\r\n])/)) {
47
+ if (part === "\r")
48
+ col = 0;
49
+ else if (part === "\n") {
50
+ row++;
51
+ col = 0;
52
+ }
53
+ else if (part) {
54
+ const cells = stringWidth(part);
55
+ if (cells) {
56
+ row += Math.floor((col + cells - 1) / columns);
57
+ col = ((col + cells - 1) % columns) + 1;
58
+ }
59
+ }
60
+ }
61
+ }
62
+ },
63
+ rows() {
64
+ return known ? row : null;
65
+ },
66
+ };
67
+ }
68
+ /** Keep Clack's writes on the real output while measuring the submitted form's cursor height. */
69
+ export async function trackedPrompt(output, run) {
70
+ const reported = Reflect.get(output, "columns");
71
+ const columns = typeof reported === "number" && reported > 0 ? reported : 80;
72
+ const cursor = promptCursor(columns);
73
+ let resized = false;
74
+ const proxy = new Writable({
75
+ write(chunk, encoding, callback) {
76
+ cursor.feed(String(chunk));
77
+ output.write(chunk, encoding, callback);
78
+ },
79
+ });
80
+ for (const key of ["columns", "rows", "isTTY"])
81
+ Object.defineProperty(proxy, key, { get: () => Reflect.get(output, key) });
82
+ const resize = () => {
83
+ resized = true;
84
+ proxy.emit("resize");
85
+ };
86
+ output.on("resize", resize);
87
+ try {
88
+ return { answer: await run(proxy), rows: resized ? null : cursor.rows() };
89
+ }
90
+ finally {
91
+ output.off("resize", resize);
92
+ proxy.destroy();
93
+ }
94
+ }