@catalyst-cloud/cli 0.14.10 → 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.
- package/CHANGELOG.md +9 -0
- package/README.md +41 -0
- package/dist/args.js +34 -2
- package/dist/capabilities.js +2 -1
- package/dist/cli-update-notice.js +76 -0
- package/dist/cli.js +263 -101
- package/dist/oauth.js +48 -18
- package/dist/onboard-accounts.js +34 -5
- package/dist/onboard-automation-management.js +27 -9
- package/dist/onboard-capacity.js +10 -1
- package/dist/onboard-existing.js +132 -31
- package/dist/onboard-github.js +171 -6
- package/dist/onboard-headless.js +647 -0
- package/dist/onboard-interrupts.js +41 -0
- package/dist/onboard-next.js +215 -0
- package/dist/onboard-permissions.js +100 -0
- package/dist/onboard-personal.js +46 -10
- package/dist/onboard-runner-cloud.js +213 -0
- package/dist/onboard-runner-egress.js +67 -0
- package/dist/onboard-runner.js +780 -0
- package/dist/onboard-runtime.js +48 -8
- package/dist/onboard-standalone-copy.js +110 -0
- package/dist/onboard-team-create.js +267 -0
- package/dist/onboard-ui.js +623 -29
- package/dist/onboard-workflow.js +311 -168
- package/dist/onboard-workspace.js +52 -5
- package/dist/onboard.js +359 -212
- package/dist/setup-events.js +100 -0
- package/dist/setup-onboard-copy.js +438 -0
- package/dist/setup-prompt-frame.js +94 -0
- package/dist/setup-prompts.js +108 -0
- package/dist/setup-render.js +339 -0
- package/dist/setup-signin.js +187 -0
- package/dist/setup.js +426 -0
- package/package.json +7 -3
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +3 -2
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-this-repo-needs/SKILL.md +1 -1
- package/skills/whats-happening/SKILL.md +1 -1
- package/vendor/README.md +4 -0
- package/vendor/self-host/compose.yaml +110 -0
- package/vendor/self-host/linux-session-egress/catalyst-session-egress-attest.service +14 -0
- package/vendor/self-host/linux-session-egress/catalyst-session-egress-attest.timer +10 -0
- package/vendor/self-host/linux-session-egress/catalyst-session-egress.service +19 -0
- package/vendor/self-host/linux-session-egress/session-egress.py +291 -0
- 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
|
+
}
|