@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,108 @@
1
+ // setup-prompts.ts — CTC-4625: the questions and the spinner `catalyst setup` shows on a terminal,
2
+ // drawn by @clack/prompts. Questions read the terminal itself (/dev/tty): under `curl | sh` the
3
+ // standard input is the download, never the person.
4
+ import { closeSync, openSync } from "node:fs";
5
+ import { ReadStream, WriteStream } from "node:tty";
6
+ /** What a prompt assumes when the terminal reports no width (a pty opened without a size). clack
7
+ * wraps each frame at the width it reads, and at 0 it printed one character per line. */
8
+ const FALLBACK_COLUMNS = 80;
9
+ /** Likewise for height: with 0 rows a select shows one option and "...". */
10
+ const FALLBACK_ROWS = 24;
11
+ /** The controlling terminal, or null when there is none (CI, a container with no TTY, a pipe). */
12
+ export function openSetupTerminal(stdout) {
13
+ // @clack/core sizes each prompt frame from process.stdout.columns, whatever output it was given.
14
+ if (stdout.isTTY && !stdout.columns)
15
+ stdout.columns = FALLBACK_COLUMNS;
16
+ if (stdout.isTTY && !stdout.rows)
17
+ stdout.rows = FALLBACK_ROWS;
18
+ let readFd;
19
+ let writeFd;
20
+ try {
21
+ readFd = openSync("/dev/tty", "r");
22
+ writeFd = openSync("/dev/tty", "w");
23
+ const input = new ReadStream(readFd);
24
+ const output = new WriteStream(writeFd);
25
+ if (!output.columns)
26
+ output.columns = FALLBACK_COLUMNS;
27
+ if (!output.rows)
28
+ output.rows = FALLBACK_ROWS;
29
+ return {
30
+ input,
31
+ output,
32
+ close() {
33
+ input.destroy();
34
+ output.destroy();
35
+ },
36
+ };
37
+ }
38
+ catch {
39
+ if (readFd !== undefined)
40
+ closeSync(readFd);
41
+ if (writeFd !== undefined)
42
+ closeSync(writeFd);
43
+ return null;
44
+ }
45
+ }
46
+ export const CONTINUE_CHOICES = [
47
+ { value: "y", label: "Yes, start" },
48
+ { value: "e", label: "Change options (folders, daily update, local sync)" },
49
+ { value: "n", label: "Not now" },
50
+ ];
51
+ /** Each question as a clack prompt on the terminal. A cancelled prompt (Ctrl-C, or the engine
52
+ * gone) answers null; the caller decides what that means. */
53
+ export function clackSetupAsk(clack, terminal) {
54
+ return async (event, r, gone) => {
55
+ const io = { input: terminal.input, output: terminal.output, signal: gone };
56
+ r.blank();
57
+ const question = event.question.replace(/\s*\[[^\]]*\]:?\s*$/, "");
58
+ if (event.id === "continue") {
59
+ const choice = await clack.select({
60
+ ...io,
61
+ message: "Start setup?",
62
+ options: CONTINUE_CHOICES,
63
+ initialValue: event.fallback || "y",
64
+ });
65
+ return clack.isCancel(choice) ? null : String(choice);
66
+ }
67
+ if (/^[yn]$/i.test(event.fallback)) {
68
+ const yes = await clack.confirm({
69
+ ...io,
70
+ message: question,
71
+ initialValue: /^y$/i.test(event.fallback),
72
+ });
73
+ return clack.isCancel(yes) ? null : yes ? "y" : "n";
74
+ }
75
+ const text = await clack.text({
76
+ ...io,
77
+ message: question,
78
+ placeholder: event.fallback,
79
+ defaultValue: event.fallback,
80
+ });
81
+ return clack.isCancel(text) ? null : String(text);
82
+ };
83
+ }
84
+ /**
85
+ * clack's spinner reads process.stdin for Ctrl-C and, on a terminal, puts it in raw mode, which
86
+ * would take Ctrl-C away from the engine. install.sh starts this command with stdin from
87
+ * /dev/null, so the spinner is used only then; a person running `catalyst setup` from a shell
88
+ * gets the plain waiting lines instead.
89
+ */
90
+ export function clackSetupSpinner(clack, stdinIsTty) {
91
+ return (r, stream) => {
92
+ if (!r.traits.color || stdinIsTty)
93
+ return null;
94
+ const spin = clack.spinner({ output: process[stream], withGuide: false });
95
+ return {
96
+ start: (msg) => spin.start(msg),
97
+ message: (msg) => spin.message(msg),
98
+ stop: () => spin.clear(),
99
+ };
100
+ };
101
+ }
102
+ /** Prompt input/output always share the controlling terminal, even when progress is redirected. */
103
+ export function setupOnboardStreams(terminal) {
104
+ return {
105
+ input: terminal?.input ?? process.stdin,
106
+ output: terminal?.output ?? process.stdout,
107
+ };
108
+ }
@@ -0,0 +1,339 @@
1
+ // setup-render.ts — CTC-4625: how `catalyst setup` looks. The terminal's traits come
2
+ // from the stream and the environment, and every line goes through here: a pipe, a log or a dumb
3
+ // terminal gets the same words in plain ASCII, never an escape sequence.
4
+ //
5
+ // ⛔ EVERY LINE IS WRITTEN WHEN IT IS PRINTED. Nothing here collects lines to wrap or align them
6
+ // later: the sign-in code must reach the screen before the wait for approval starts (the 0.13.9
7
+ // installer piped its login through fold, which held the code until the deadline discarded it).
8
+ import { styleText } from "node:util";
9
+ import { wrapAnsi } from "fast-wrap-ansi";
10
+ import stringWidth from "fast-string-width";
11
+ const DEFAULT_COLUMNS = 80;
12
+ const MIN_COLUMNS = 40;
13
+ function utf8Locale(env) {
14
+ return /utf-?8/i.test(env.LC_ALL || env.LC_CTYPE || env.LANG || "");
15
+ }
16
+ /**
17
+ * OSC 8 is shown only where a terminal is known to render it: an unknown one may print the escape
18
+ * as text. FORCE_HYPERLINK=1 or 0 overrides the list, as in the supports-hyperlinks convention.
19
+ */
20
+ function hyperlinkTerminal(env) {
21
+ if (env.FORCE_HYPERLINK === "0")
22
+ return false;
23
+ if (env.FORCE_HYPERLINK === "1")
24
+ return true;
25
+ const program = env.TERM_PROGRAM ?? "";
26
+ if (["iTerm.app", "WezTerm", "vscode", "ghostty", "Hyper"].includes(program))
27
+ return true;
28
+ if ((env.TERM ?? "").includes("kitty") || env.TERM === "alacritty")
29
+ return true;
30
+ if (env.WT_SESSION)
31
+ return true;
32
+ const vte = Number.parseInt(env.VTE_VERSION ?? "", 10);
33
+ return Number.isFinite(vte) && vte >= 5000;
34
+ }
35
+ export function terminalTraits(stream, env) {
36
+ const fancy = stream.isTTY === true &&
37
+ env.NO_COLOR === undefined &&
38
+ Boolean(env.TERM) &&
39
+ env.TERM !== "dumb" &&
40
+ utf8Locale(env);
41
+ let columns = DEFAULT_COLUMNS;
42
+ if (stream.isTTY === true &&
43
+ typeof stream.columns === "number" &&
44
+ stream.columns > 0)
45
+ columns = stream.columns;
46
+ else if (/^[1-9][0-9]{0,3}$/.test(env.COLUMNS ?? ""))
47
+ columns = Number(env.COLUMNS);
48
+ return {
49
+ color: fancy,
50
+ unicode: fancy,
51
+ links: fancy && hyperlinkTerminal(env),
52
+ columns: Math.max(MIN_COLUMNS, columns),
53
+ };
54
+ }
55
+ const ASCII_MARKS = {
56
+ done: "[done]",
57
+ fail: "[fail]",
58
+ act: "[!]",
59
+ skip: "[skip]",
60
+ later: "[later]",
61
+ run: "[..]",
62
+ ask: "[?]",
63
+ };
64
+ const UNICODE_MARKS = {
65
+ done: ["✓", "green"],
66
+ fail: ["✗", "red"],
67
+ act: ["▲", "yellow"],
68
+ skip: ["–", "gray"],
69
+ later: ["·", "gray"],
70
+ run: ["◐", "cyan"],
71
+ ask: ["◆", "cyan"],
72
+ };
73
+ export function createSetupRenderer(stream, env) {
74
+ let traits = terminalTraits(stream, env);
75
+ const style = (format, text) => traits.color ? styleText(format, text, { validateStream: false }) : text;
76
+ let width = Math.min(traits.columns, 100);
77
+ const refresh = () => {
78
+ const next = terminalTraits(stream, env);
79
+ if (live && next.columns !== traits.columns)
80
+ live.resized = true;
81
+ traits = next;
82
+ width = Math.min(traits.columns, 100);
83
+ };
84
+ let frame = 1;
85
+ const visible = (text) => text
86
+ .replace(/\u001b\]8;;[^\u001b]*\u001b\\/g, "")
87
+ .replace(/\u001b\[[0-9;]*m/g, "");
88
+ const rail = traits.unicode ? "│" : "";
89
+ const titleCol = traits.unicode ? 6 : 11;
90
+ const outcomeCol = traits.unicode ? 38 : 43;
91
+ const prefix = (col) => rail + " ".repeat(col - rail.length);
92
+ const mark = (kind) => {
93
+ if (!traits.unicode)
94
+ return ASCII_MARKS[kind];
95
+ const [glyph, colour] = UNICODE_MARKS[kind];
96
+ return style(colour, kind === "run" ? ["◒", "◐", "◓", "◑"][frame % 4] : glyph);
97
+ };
98
+ const rows = (text, col) => {
99
+ refresh();
100
+ const indent = /^ */.exec(text)?.[0].length ?? 0;
101
+ col += indent;
102
+ text = text.slice(indent);
103
+ return wrapAnsi(text, Math.max(1, width - col), {
104
+ hard: false,
105
+ wordWrap: true,
106
+ trim: true,
107
+ })
108
+ .split("\n")
109
+ .map((line) => {
110
+ const plain = visible(line);
111
+ const indent = /^https?:\/\/\S+$/.test(plain) && stringWidth(plain) > width - col
112
+ ? traits.unicode
113
+ ? 3
114
+ : 0
115
+ : col;
116
+ return prefix(indent) + line;
117
+ });
118
+ };
119
+ const stepRows = (kind, number, title, outcome, plan = false) => {
120
+ refresh();
121
+ const lead = plan
122
+ ? traits.unicode
123
+ ? "│"
124
+ : " "
125
+ : traits.unicode
126
+ ? mark(kind)
127
+ : mark(kind).padEnd(7);
128
+ const titleWidth = stringWidth(visible(title));
129
+ const label = `${lead}${traits.unicode ? " " : " "}${String(number || "").padStart(2)} ${title}`;
130
+ if (!outcome)
131
+ return [label];
132
+ if (width < 80 || titleWidth > 30)
133
+ return [label, ...rows(outcome, titleCol)];
134
+ const padding = " ".repeat(Math.max(2, 32 - titleWidth));
135
+ const wrapped = wrapAnsi(outcome, width - outcomeCol, {
136
+ hard: false,
137
+ wordWrap: true,
138
+ trim: true,
139
+ }).split("\n");
140
+ return [
141
+ `${label}${padding}${wrapped[0]}`,
142
+ ...wrapped.slice(1).map((l) => prefix(outcomeCol) + l),
143
+ ];
144
+ };
145
+ const write = (lines) => stream.write(lines.join("\n") + "\n");
146
+ let live;
147
+ const clear = () => {
148
+ if (live && traits.unicode && !live.resized && live.height)
149
+ stream.write(`\u001b[${live.height}A\r\u001b[J`);
150
+ };
151
+ let workingTimer;
152
+ const flushWorking = () => {
153
+ if (workingTimer) {
154
+ clearTimeout(workingTimer);
155
+ workingTimer = undefined;
156
+ paint();
157
+ }
158
+ };
159
+ let listening = false;
160
+ const releaseListener = () => {
161
+ if (listening) {
162
+ process.off("SIGWINCH", resize);
163
+ listening = false;
164
+ }
165
+ };
166
+ const abandon = () => {
167
+ if (workingTimer)
168
+ clearTimeout(workingTimer);
169
+ workingTimer = undefined;
170
+ live = undefined;
171
+ releaseListener();
172
+ };
173
+ const paint = () => {
174
+ if (!live || live.resized)
175
+ return;
176
+ const lines = [
177
+ ...stepRows(live.mark, live.number, style("bold", live.title), live.status),
178
+ ...live.details.flatMap((t) => rows(t, titleCol)),
179
+ ];
180
+ const physicalWidth = stream.columns && stream.columns > 0 ? stream.columns : traits.columns;
181
+ const physicalRows = lines.reduce((n, line) => n + Math.max(1, Math.ceil(stringWidth(visible(line)) / physicalWidth)), 0);
182
+ if (traits.unicode) {
183
+ clear();
184
+ write(lines);
185
+ live.height = physicalRows;
186
+ // A taller block cannot be safely erased in a short terminal. Keep its scrollback instead.
187
+ if (physicalRows > 8)
188
+ live.resized = true;
189
+ }
190
+ else if (!live.height) {
191
+ write(lines);
192
+ live.height = physicalRows;
193
+ }
194
+ };
195
+ const resize = () => {
196
+ if (live)
197
+ live.resized = true;
198
+ refresh();
199
+ };
200
+ const ordinary = (lines) => {
201
+ if (live && traits.unicode && !live.resized) {
202
+ clear();
203
+ live.height = 0;
204
+ write(lines);
205
+ if (!workingTimer)
206
+ paint();
207
+ }
208
+ else
209
+ write(lines);
210
+ };
211
+ const suspendLive = () => {
212
+ clear();
213
+ abandon();
214
+ };
215
+ const r = {
216
+ get traits() {
217
+ refresh();
218
+ return traits;
219
+ },
220
+ intro(title) {
221
+ refresh();
222
+ ordinary([traits.unicode ? `┌ ${style("bold", title)}` : title]);
223
+ },
224
+ outro(text) {
225
+ abandon();
226
+ write(traits.unicode ? [`│`, `└ ${text}`] : ["", text]);
227
+ },
228
+ plan(number, title, text) {
229
+ ordinary(stepRows("run", number, title, text, true));
230
+ if (width < 80)
231
+ r.blank();
232
+ },
233
+ begin(number, title, status = "checking…", kind = "run") {
234
+ if (live?.number === number)
235
+ suspendLive();
236
+ else
237
+ abandon();
238
+ if (traits.unicode && !listening) {
239
+ process.on("SIGWINCH", resize);
240
+ listening = true;
241
+ }
242
+ live = {
243
+ number,
244
+ title,
245
+ status,
246
+ mark: kind,
247
+ details: [],
248
+ height: 0,
249
+ resized: false,
250
+ };
251
+ if (status === "checking…" && kind === "run") {
252
+ workingTimer = setTimeout(() => {
253
+ workingTimer = undefined;
254
+ paint();
255
+ }, 300);
256
+ workingTimer.unref();
257
+ }
258
+ else
259
+ paint();
260
+ },
261
+ update(status) {
262
+ if (live) {
263
+ live.status = status;
264
+ frame++;
265
+ if (workingTimer && /waiting|new code/.test(status))
266
+ flushWorking();
267
+ else if (!workingTimer)
268
+ paint();
269
+ }
270
+ },
271
+ resolve(kind, outcome) {
272
+ if (!live)
273
+ return;
274
+ const { number, title } = live;
275
+ clear();
276
+ abandon();
277
+ write(stepRows(kind, number, title, outcome));
278
+ },
279
+ suspendLive,
280
+ promptRows(rows) {
281
+ if (live) {
282
+ if (rows === null)
283
+ live.resized = true;
284
+ else
285
+ live.height += rows;
286
+ }
287
+ },
288
+ dispose() {
289
+ abandon();
290
+ },
291
+ step(kind, number, title, outcome) {
292
+ if (live && live.number === number)
293
+ r.resolve(kind, outcome);
294
+ else
295
+ ordinary(stepRows(kind, number, title, outcome));
296
+ },
297
+ detail(text) {
298
+ flushWorking();
299
+ if (live && traits.unicode && !live.resized) {
300
+ live.details.push(text);
301
+ paint();
302
+ }
303
+ else
304
+ write(rows(text, titleCol));
305
+ },
306
+ replaceLastDetail(text) {
307
+ if (live && traits.unicode && !live.resized && live.details.length) {
308
+ live.details[live.details.length - 1] = text;
309
+ paint();
310
+ }
311
+ else
312
+ r.detail(text);
313
+ },
314
+ action(text) {
315
+ r.detail(style("bold", text));
316
+ },
317
+ heading(text) {
318
+ abandon();
319
+ write(traits.unicode ? ["│", `├─ ${style("bold", text)}`] : ["", text]);
320
+ },
321
+ line(text) {
322
+ ordinary(rows(text, traits.unicode ? 3 : 0));
323
+ },
324
+ blank() {
325
+ refresh();
326
+ ordinary([rail]);
327
+ },
328
+ link(url) {
329
+ return traits.links
330
+ ? `\u001b]8;;${url}\u001b\\${url}\u001b]8;;\u001b\\`
331
+ : url;
332
+ },
333
+ bold: (text) => style("bold", text),
334
+ dim: (text) => style("dim", text),
335
+ verdict: (ok, text) => style(["bold", ok ? "green" : "red"], text),
336
+ mark,
337
+ };
338
+ return r;
339
+ }
@@ -0,0 +1,187 @@
1
+ // setup-signin.ts — CTC-4625: step 4 of `catalyst setup`, the browser sign-in, drawn in this process.
2
+ //
3
+ // ⛔ THE CODE IS WRITTEN THE MOMENT THE SERVER SENDS IT. The device flow hands each code to
4
+ // `present`, which writes straight to the output stream before the wait for approval begins; no
5
+ // filter, wrap buffer or relay process sits between them (the 0.13.9 installer piped the login
6
+ // through fold, which held the code until the deadline discarded it).
7
+ import { realpathSync } from "node:fs";
8
+ import { join, relative } from "node:path";
9
+ import { loadConfig, saveConfig } from "./config.js";
10
+ import { CliError } from "./errors.js";
11
+ const PLAIN_REMINDER_MS = 60_000;
12
+ function clock(ms) {
13
+ const total = Math.max(0, Math.ceil(ms / 1000));
14
+ const minutes = Math.floor(total / 60);
15
+ return `${minutes}:${String(total % 60).padStart(2, "0")}`;
16
+ }
17
+ function limitWords(seconds) {
18
+ if (seconds <= 0)
19
+ return "";
20
+ if (seconds < 60)
21
+ return ` (up to ${seconds} seconds)`;
22
+ const minutes = Math.ceil(seconds / 60);
23
+ return ` (up to ${minutes} minute${minutes === 1 ? "" : "s"})`;
24
+ }
25
+ /** The host a link opens, named when it is not Catalyst Cloud's own, so the page is not a surprise. */
26
+ function vendorNote(code) {
27
+ let host;
28
+ try {
29
+ host = new URL(code.completeUri ?? code.verificationUri).host;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ if (host === "catalystcloud.dev" || host.endsWith(".catalystcloud.dev"))
35
+ return null;
36
+ return `That page is Catalyst's sign-in page, hosted by WorkOS.`;
37
+ }
38
+ export async function runSetupSignin(ctx, r, seconds, deps) {
39
+ const now = deps.now ?? Date.now;
40
+ const reason = "This connects this computer to your Catalyst account, so catalyst and your coding agent can act for you.";
41
+ r.detail(reason);
42
+ const deadline = new AbortController();
43
+ const timer = seconds > 0
44
+ ? setTimeout(() => deadline.abort(), seconds * 1000)
45
+ : undefined;
46
+ const signal = deps.interrupted
47
+ ? AbortSignal.any([deadline.signal, deps.interrupted])
48
+ : deadline.signal;
49
+ const started = now();
50
+ const progress = () => seconds > 0
51
+ ? `${clock(started + seconds * 1000 - now())} left`
52
+ : `${clock(now() - started)} so far`;
53
+ let hintInFrame = false;
54
+ let round = 1, rounds = 1;
55
+ const waitingWords = () => `${round > 1 ? `new code ${round} of ${rounds}` : "waiting for you"} · ${progress()}`;
56
+ const present = (code) => {
57
+ round = code.round;
58
+ rounds = code.rounds;
59
+ if (code.round > 1) {
60
+ r.begin(4, "Sign in to Catalyst", `new code ${code.round} of ${code.rounds} · ${progress()}`);
61
+ r.detail(reason);
62
+ hintInFrame = false;
63
+ }
64
+ r.action("Open this link and approve this computer:");
65
+ if (code.completeUri)
66
+ r.detail(r.link(code.completeUri));
67
+ r.detail(`Or open ${r.link(code.verificationUri)} and enter ${code.userCode}.`);
68
+ const note = vendorNote(code);
69
+ if (note)
70
+ r.detail(note);
71
+ if (code.round > 1) {
72
+ r.detail("Ctrl-C stops setup.");
73
+ hintInFrame = true;
74
+ }
75
+ };
76
+ const presentBrowser = (opened) => {
77
+ if (opened)
78
+ r.detail("Your browser opened that page.");
79
+ };
80
+ const waitForApproval = async (run) => {
81
+ let tick;
82
+ if (r.traits.unicode) {
83
+ r.update(waitingWords());
84
+ if (!hintInFrame)
85
+ r.detail("Ctrl-C stops setup.");
86
+ hintInFrame = true;
87
+ tick = setInterval(() => r.update(waitingWords()), 1000);
88
+ }
89
+ else {
90
+ r.detail(`waiting for you${limitWords(seconds > 0 ? Math.max(0, Math.ceil((started + seconds * 1000 - now()) / 1000)) : seconds)}.${hintInFrame ? "" : " Ctrl-C stops setup."}`);
91
+ hintInFrame = true;
92
+ tick = setInterval(() => {
93
+ const minutes = Math.max(0, Math.ceil((started + seconds * 1000 - now()) / 60000));
94
+ if (minutes > 0)
95
+ r.detail(`Still waiting, ${minutes} minute${minutes === 1 ? "" : "s"} left.`);
96
+ }, PLAIN_REMINDER_MS);
97
+ }
98
+ try {
99
+ return await run();
100
+ }
101
+ finally {
102
+ clearInterval(tick);
103
+ }
104
+ };
105
+ // The login's own lines repeat what this step already shows, so they are kept, not printed; the
106
+ // last one is the reason when the login ends without signing in.
107
+ const logged = [];
108
+ const log = (line) => {
109
+ if (line.trim())
110
+ logged.push(line.trim());
111
+ deps.log?.(line);
112
+ };
113
+ const quiet = { ...ctx, stdout: log, stderr: log };
114
+ try {
115
+ const code = await deps.login(quiet, {
116
+ signal,
117
+ present,
118
+ presentBrowser,
119
+ waitForApproval,
120
+ });
121
+ if (code === 0)
122
+ return "done";
123
+ r.detail(`The sign-in did not finish: ${logged.at(-1) ?? `catalyst login exited ${code}`}`);
124
+ return "failed";
125
+ }
126
+ catch (error) {
127
+ if (deps.interrupted?.aborted)
128
+ return "cancelled";
129
+ if (deadline.signal.aborted)
130
+ return "timeout";
131
+ if (error instanceof CliError && error.code === "login-expired")
132
+ return "timeout";
133
+ deps.log?.(error instanceof Error ? error.message : String(error));
134
+ r.detail(`The sign-in did not finish: ${error instanceof Error ? error.message : String(error)}`);
135
+ return "failed";
136
+ }
137
+ finally {
138
+ clearTimeout(timer);
139
+ }
140
+ }
141
+ /**
142
+ * install.sh may run `catalyst setup` from a temporary install (CATALYST_INSTALL_TEMP_CLI) that is
143
+ * removed when setup ends. The sign-in records the running command's path for skill scripts to
144
+ * spawn, so once it is saved, point it at the installed `catalyst` on PATH (step 1 installed it).
145
+ * An installed command running setup keeps its own path.
146
+ */
147
+ export function keepInstalledCliPath(ctx, running) {
148
+ const given = ctx.env.CATALYST_INSTALL_TEMP_CLI;
149
+ if (!given)
150
+ return;
151
+ let temporary;
152
+ try {
153
+ temporary = realpathSync(given);
154
+ running = realpathSync(running);
155
+ }
156
+ catch {
157
+ return;
158
+ }
159
+ if (relative(temporary, running).startsWith(".."))
160
+ return;
161
+ const cfg = loadConfig(ctx.home);
162
+ if (!cfg?.cliPath)
163
+ return;
164
+ try {
165
+ if (realpathSync(cfg.cliPath) !== running)
166
+ return;
167
+ }
168
+ catch {
169
+ return;
170
+ }
171
+ for (const dir of (ctx.env.PATH ?? "").split(":")) {
172
+ if (!dir)
173
+ continue;
174
+ let installed;
175
+ try {
176
+ installed = realpathSync(join(dir, "catalyst"));
177
+ }
178
+ catch {
179
+ continue;
180
+ }
181
+ if (!relative(temporary, installed).startsWith(".."))
182
+ continue;
183
+ cfg.cliPath = installed;
184
+ saveConfig(ctx.home, cfg);
185
+ return;
186
+ }
187
+ }