@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.
- package/CHANGELOG.md +13 -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 +267 -94
- package/dist/oauth.js +80 -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 -207
- 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,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
|
+
}
|