@catalyst-cloud/cli 0.15.2 → 0.15.3

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 (36) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +2 -2
  3. package/dist/cli.js +5 -17
  4. package/dist/oauth.js +1 -1
  5. package/dist/onboard-accounts.js +67 -171
  6. package/dist/onboard-checklist.js +309 -0
  7. package/dist/onboard-existing.js +1 -1
  8. package/dist/onboard-first-ticket.js +199 -0
  9. package/dist/onboard-headless.js +2 -2
  10. package/dist/onboard-housekeeping.js +93 -0
  11. package/dist/onboard-next.js +108 -9
  12. package/dist/onboard-ready.js +61 -0
  13. package/dist/onboard-runtime.js +60 -27
  14. package/dist/onboard-standalone-copy.js +73 -66
  15. package/dist/onboard-ui.js +162 -89
  16. package/dist/onboard-values.js +47 -0
  17. package/dist/onboard.js +49 -13
  18. package/dist/ready-copy.js +87 -0
  19. package/dist/ready.js +1 -57
  20. package/dist/setup-onboard-copy.js +237 -41
  21. package/dist/setup-render.js +73 -16
  22. package/dist/setup.js +6 -3
  23. package/package.json +1 -1
  24. package/skills/catalyst-github/SKILL.md +1 -1
  25. package/skills/catalyst-linear/SKILL.md +1 -1
  26. package/skills/catalyst-onboard/SKILL.md +3 -3
  27. package/skills/catalyst-onboard/references/replacing-a-credential.md +9 -15
  28. package/skills/catalyst-onboard/references/what-a-phase-needs.md +4 -11
  29. package/skills/catalyst-onboard/scripts/where-am-i.mjs +1 -1
  30. package/skills/run-this-project/SKILL.md +2 -2
  31. package/skills/unstick/SKILL.md +1 -1
  32. package/skills/what-needs-me/SKILL.md +1 -1
  33. package/skills/what-this-repo-needs/SKILL.md +1 -1
  34. package/skills/whats-happening/SKILL.md +2 -2
  35. package/skills/whats-happening/references/coding-accounts.md +1 -1
  36. package/skills/whats-happening/scripts/snapshot.mjs +1 -1
@@ -0,0 +1,309 @@
1
+ import { loadConfig, normalizeBaseUrl } from "./config.js";
2
+ import { verifyOnboardRoutes } from "./onboard-capabilities.js";
3
+ import { pollConsent } from "./onboard-consent.js";
4
+ import { readExistingOnboardJson } from "./onboard-existing.js";
5
+ /** The one read behind the web app's Connect accounts page. Each read runs live provider probes. */
6
+ export const CHECKLIST_PATH = "/api/v1/me/connections/checklist";
7
+ export const CONNECT_PAGE_PATH = "/connect-accounts";
8
+ /** Never poll the checklist faster than this: every read probes Linear and GitHub. */
9
+ export const CHECKLIST_POLL_MS = 10_000;
10
+ /** Exactly the page's own labels, so the terminal and the browser name each row the same way. */
11
+ export const CHECKLIST_LABELS = {
12
+ "linear-workspace": "Linear workspace",
13
+ "linear-personal": "Your Linear account",
14
+ "github-app": "Catalyst on GitHub",
15
+ "github-personal": "Your GitHub account",
16
+ "ai-account": "AI account",
17
+ };
18
+ /** The checklist's reason codes in words. Codes stay in receipts; only these reach the screen. */
19
+ const REASON_WORDS = {
20
+ not_connected: "not connected yet",
21
+ not_installed: "not installed yet",
22
+ lapsed: "the connection lapsed and needs connecting again",
23
+ expired: "the connection expired and needs connecting again",
24
+ workspace_first: "the Linear workspace needs connecting first",
25
+ missing_permissions: "needs updated permissions",
26
+ approval_pending: "waiting for an owner of your GitHub organization to approve it",
27
+ repository_not_covered: "does not reach every project repository yet",
28
+ none_usable: "no AI account can take work yet",
29
+ check_unavailable: "could not be checked just now. Setup keeps checking",
30
+ };
31
+ const ITEM_IDS = new Set(Object.keys(CHECKLIST_LABELS));
32
+ const STATES = new Set([
33
+ "done",
34
+ "needed",
35
+ "outdated",
36
+ "waiting",
37
+ "unknown",
38
+ ]);
39
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
40
+ ? value
41
+ : null;
42
+ /** "Catalyst on GitHub: waiting for an owner of your GitHub organization to approve it." */
43
+ export function checklistItemText(item) {
44
+ return `${CHECKLIST_LABELS[item.id]}: ${(item.reason && REASON_WORDS[item.reason]) || "not done yet"}.`;
45
+ }
46
+ function parse(body, origin) {
47
+ const row = object(body);
48
+ if (!row ||
49
+ row.schema !== 1 ||
50
+ typeof row.page !== "string" ||
51
+ typeof row.checkedAt !== "number" ||
52
+ !Number.isFinite(row.checkedAt) ||
53
+ !Array.isArray(row.items) ||
54
+ row.items.length > 20)
55
+ return null;
56
+ // The page is opened in a browser: it must be this cloud's own Connect page and nothing else.
57
+ let page;
58
+ try {
59
+ page = new URL(row.page);
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ if (page.origin !== origin ||
65
+ page.pathname !== CONNECT_PAGE_PATH ||
66
+ page.search ||
67
+ page.hash ||
68
+ page.username ||
69
+ page.password)
70
+ return null;
71
+ const items = [];
72
+ for (const value of row.items) {
73
+ const item = object(value);
74
+ if (!item || typeof item.id !== "string")
75
+ return null;
76
+ // A row, state or audience a newer cloud adds is not this CLI's to judge. Skipping it leaves the
77
+ // rows this CLI knows, so the checklist stays on.
78
+ if (!ITEM_IDS.has(item.id) ||
79
+ typeof item.state !== "string" ||
80
+ !STATES.has(item.state) ||
81
+ (item.who !== "admin" && item.who !== "you"))
82
+ continue;
83
+ if (typeof item.canAct !== "boolean" ||
84
+ !((item.state === "done" && item.reason === null) ||
85
+ (item.state !== "done" &&
86
+ typeof item.reason === "string" &&
87
+ /^[a-z][a-z0-9_]{1,63}$/.test(item.reason))) ||
88
+ items.some((seen) => seen.id === item.id))
89
+ return null;
90
+ items.push({
91
+ id: item.id,
92
+ state: item.state,
93
+ reason: item.reason,
94
+ who: item.who,
95
+ canAct: item.canAct,
96
+ });
97
+ }
98
+ const kinds = Array.isArray(row.aiAccountKinds) ? row.aiAccountKinds : [];
99
+ return {
100
+ page: page.toString(),
101
+ checkedAt: row.checkedAt,
102
+ items,
103
+ aiAccountKinds: kinds.includes("subscription")
104
+ ? "subscription,api-key"
105
+ : "api-key",
106
+ };
107
+ }
108
+ const LOGIN_EXPIRED = {
109
+ reason: "onboard_login_refresh_required",
110
+ fatal: true,
111
+ };
112
+ const IDENTITY_CHANGED = {
113
+ reason: "connect_checklist_identity_unverified",
114
+ fatal: true,
115
+ };
116
+ /** `unsupported` when the cloud does not advertise the checklist: the caller keeps its own routes.
117
+ * `verified`: the origin whose contract already advertised the route this wait, so a poll reads the
118
+ * checklist alone. A revoked login then answers 401 on the checklist itself. */
119
+ export async function readChecklist(ctx, journal, signal, verified) {
120
+ if (verified !== undefined) {
121
+ const cfg = loadConfig(ctx.home);
122
+ if (!cfg?.user ||
123
+ cfg.account !== (journal.account ?? journal.tenant) ||
124
+ cfg.user.id !== journal.membershipId ||
125
+ !journal.baseUrl ||
126
+ normalizeBaseUrl(cfg.baseUrl) !== normalizeBaseUrl(journal.baseUrl) ||
127
+ new URL(normalizeBaseUrl(cfg.baseUrl)).origin !== verified)
128
+ return IDENTITY_CHANGED;
129
+ }
130
+ const support = verified !== undefined
131
+ ? { origin: verified }
132
+ : await verifyOnboardRoutes(ctx, journal, [{ method: "GET", path: CHECKLIST_PATH }], signal);
133
+ if ("reason" in support) {
134
+ if (support.reason === "cloud_capability_unavailable")
135
+ return { unsupported: true };
136
+ if (support.reason.endsWith("_login_refresh_required"))
137
+ return LOGIN_EXPIRED;
138
+ if (support.reason.endsWith("_identity_unverified") ||
139
+ support.reason === "personal_login_required")
140
+ return IDENTITY_CHANGED;
141
+ return {
142
+ reason: support.reason,
143
+ fatal: support.reason === "interrupted",
144
+ };
145
+ }
146
+ const read = await readExistingOnboardJson(ctx, CHECKLIST_PATH, signal);
147
+ if ("reason" in read) {
148
+ if (read.reason.endsWith("_login_refresh_required"))
149
+ return LOGIN_EXPIRED;
150
+ if (read.reason.endsWith("_identity_unverified") ||
151
+ read.reason === "personal_login_required")
152
+ return IDENTITY_CHANGED;
153
+ if (read.status === 401 || read.status === 403)
154
+ return { reason: "connect_checklist_refused", fatal: true };
155
+ return { reason: "connect_checklist_unavailable", fatal: false };
156
+ }
157
+ const checklist = parse(read.body, support.origin);
158
+ return checklist
159
+ ? { checklist }
160
+ : { reason: "connect_checklist_unverified", fatal: false };
161
+ }
162
+ const done = (item, checklist) => ({
163
+ state: "done",
164
+ evidence: {
165
+ provider: item.id === "github-app" ? "github" : "ai-account",
166
+ scope: "checklist",
167
+ checkedAt: checklist.checkedAt,
168
+ },
169
+ });
170
+ /** CTC-4680: a step the Connect accounts page also lists. When the cloud advertises the checklist,
171
+ * its row is the step's answer and the wait sends the person to that page. Without it, the step's
172
+ * own status routes and links run unchanged. */
173
+ export function connectChecklistAdapter(options) {
174
+ const { fallback } = options;
175
+ const enabled = options.enabled !== false;
176
+ /** The row for this step; null when the cloud has none (the step keeps its own routes). */
177
+ const read = async (ctx, journal, signal, verified) => {
178
+ if (!enabled)
179
+ return null;
180
+ const cached = options.cache.last;
181
+ const age = cached ? ctx.now().getTime() - cached.at : Infinity;
182
+ let result = cached && age >= 0 && age < CHECKLIST_POLL_MS ? cached.read : undefined;
183
+ if (!result) {
184
+ // A recent read that never answered (cut off at a deadline) still spaces the next one.
185
+ if (age >= 0 && age < CHECKLIST_POLL_MS)
186
+ await (options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))))(CHECKLIST_POLL_MS - age);
187
+ const at = ctx.now().getTime();
188
+ // Recorded before the read starts, so one that is aborted still counts.
189
+ options.cache.last = { at };
190
+ result = await readChecklist(ctx, journal, signal, verified);
191
+ options.cache.last = { at, read: result };
192
+ }
193
+ if ("unsupported" in result)
194
+ return null;
195
+ if ("reason" in result)
196
+ return result;
197
+ kinds = result.checklist.aiAccountKinds;
198
+ const item = result.checklist.items.find((row) => row.id === options.item);
199
+ return item ? { checklist: result.checklist, item } : null;
200
+ };
201
+ // The AI account step's text follows what the workspace may add, so its results carry the kinds
202
+ // from the read that answered them. No read, no kinds: the text falls back to API keys.
203
+ let kinds;
204
+ const tagged = (run) => async (ctx, journal, signal) => {
205
+ kinds = undefined;
206
+ const result = await run(ctx, journal, signal);
207
+ return options.item === "ai-account" && kinds
208
+ ? { ...result, evidence: { ...result.evidence, aiAccountKinds: kinds } }
209
+ : result;
210
+ };
211
+ const actionable = (item) => item.state === "needed" && item.canAct && options.wait !== undefined;
212
+ const adapter = {
213
+ check: async (ctx, journal, signal) => {
214
+ const current = await read(ctx, journal, signal);
215
+ if (current && "item" in current) {
216
+ if (current.item.state === "done")
217
+ return done(current.item, current.checklist);
218
+ if (actionable(current.item))
219
+ return { state: "pending" };
220
+ }
221
+ return fallback.check(ctx, journal, signal);
222
+ },
223
+ act: async (ctx, journal, signal) => {
224
+ const current = await read(ctx, journal, signal);
225
+ if (current && "item" in current && current.item.state === "done")
226
+ return done(current.item, current.checklist);
227
+ // Anything the page cannot finish here (no browser wait, an admin's row, a row waiting on
228
+ // someone else, a checklist that could not be read) keeps the step's own action and reasons.
229
+ if (!current || !("item" in current) || !actionable(current.item) || !options.wait)
230
+ return fallback.act
231
+ ? fallback.act(ctx, journal, signal)
232
+ : fallback.check(ctx, journal, signal);
233
+ const label = CHECKLIST_LABELS[options.item];
234
+ const url = current.checklist.page;
235
+ // The contract advertised the route for this read; the wait's polls read the checklist alone.
236
+ const origin = new URL(url).origin;
237
+ let instruction;
238
+ if (options.page.openedBy === null ||
239
+ options.page.openedBy === options.step) {
240
+ try {
241
+ await options.openBrowser(url, signal);
242
+ options.page.openedBy = options.step;
243
+ instruction = `Opened your browser. Finish "${label}" on the page.`;
244
+ }
245
+ catch {
246
+ if (signal?.aborted)
247
+ return { state: "waiting", reason: "interrupted" };
248
+ instruction = `Open this link and finish "${label}":`;
249
+ // The page is not a credential, so it is printed on its own line for any browser.
250
+ ctx.stderr(`No browser opened on this computer. In a browser signed in to Catalyst, open this page and finish "${label}". Setup keeps waiting here.`);
251
+ ctx.stderr(url);
252
+ }
253
+ }
254
+ else
255
+ instruction = `Finish "${label}" on the page already open in your browser.`;
256
+ let last = current.item;
257
+ let finished = current.checklist;
258
+ let said = "";
259
+ let failures = 0;
260
+ const run = () => pollConsent({
261
+ timeoutMs: options.timeoutMs,
262
+ intervalMs: CHECKLIST_POLL_MS,
263
+ signal,
264
+ sleep: options.sleep,
265
+ readStatus: async (pollSignal) => {
266
+ const next = await read(ctx, journal, pollSignal, origin);
267
+ if (!next)
268
+ return { outcome: "unavailable" };
269
+ if (!("item" in next)) {
270
+ // A login that expired or changed, or a refusal, will not fix itself: stop and say so.
271
+ if (next.fatal)
272
+ return { outcome: "waiting", reason: next.reason };
273
+ if (++failures === 3)
274
+ options.note?.("Setup could not check the Connect accounts page just now. It keeps checking.");
275
+ return { outcome: "unavailable" };
276
+ }
277
+ failures = 0;
278
+ last = next.item;
279
+ finished = next.checklist;
280
+ if (last.state === "done")
281
+ return { outcome: "connected" };
282
+ const text = last.state === "needed" ? "" : checklistItemText(last);
283
+ if (text && text !== said)
284
+ options.note?.(text);
285
+ said = text;
286
+ return { outcome: "absent" };
287
+ },
288
+ });
289
+ const result = await options.wait(`Waiting for "${label}"`, run, {
290
+ url,
291
+ instruction,
292
+ });
293
+ if (result.state === "done")
294
+ return done(last, finished);
295
+ // A row that ended waiting on someone else, or needing a change elsewhere, gets the step's
296
+ // own detailed reason; a row still simply needed timed out and can be offered again.
297
+ const ended = {
298
+ state: result.state,
299
+ reason: result.reason ?? "consent_timeout",
300
+ };
301
+ if (result.reason === "consent_timeout" && last.state !== "needed") {
302
+ const detail = await fallback.check(ctx, journal, signal);
303
+ return detail.state === "pending" ? ended : detail;
304
+ }
305
+ return ended;
306
+ },
307
+ };
308
+ return { check: tagged(adapter.check), act: tagged(adapter.act) };
309
+ }
@@ -83,7 +83,7 @@ export async function readExistingOnboardJson(ctx, path, external) {
83
83
  signal,
84
84
  });
85
85
  if (response.status !== 200)
86
- return { reason: "team_read_unavailable" };
86
+ return { reason: "team_read_unavailable", status: response.status };
87
87
  const body = await response.json();
88
88
  const current = loadConfig(ctx.home);
89
89
  if (!current?.user ||
@@ -0,0 +1,199 @@
1
+ import { loadConfig } from "./config.js";
2
+ import { loadContract, readContractCache } from "./contract.js";
3
+ import { CliError } from "./errors.js";
4
+ import { selectedOnboardTeam } from "./onboard-existing.js";
5
+ import { dispatchStageName } from "./onboard-next.js";
6
+ import { onboardStatePath, writeOnboardJournal, } from "./onboard.js";
7
+ import { fetchPage } from "./pagination.js";
8
+ import { apiClient } from "./transport.js";
9
+ import { postAgent } from "./write.js";
10
+ /** The person's answer: a ticket identifier from the offered list, or one of these two. */
11
+ export const FIRST_TICKET_SAMPLE = "create-sample";
12
+ export const FIRST_TICKET_SKIP = "skip";
13
+ // A documentation-only starter: it changes one file and no code, so a first run is safe to watch.
14
+ const SAMPLE_TITLE = "Document how to run repository tests";
15
+ const SAMPLE_DESCRIPTION = "Update only CONTRIBUTING.md to document existing test commands from repository configuration. Do not change executable code, configuration, dependencies, or credentials. If the test commands cannot be established from source, stop and report the missing information.";
16
+ /** Linear's state types for a ticket nobody has started. */
17
+ const OPEN_TYPES = new Set(["triage", "backlog", "unstarted"]);
18
+ const IDENTIFIER = /^[A-Z][A-Z0-9]{0,7}-[0-9]{1,9}$/;
19
+ const waiting = (reason) => ({ state: "waiting", reason });
20
+ function linearUrl(value) {
21
+ if (typeof value !== "string")
22
+ return undefined;
23
+ try {
24
+ const url = new URL(value);
25
+ return url.protocol === "https:" && url.hostname === "linear.app" && !url.username && !url.password
26
+ ? url.href
27
+ : undefined;
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ }
33
+ /** A recorded outcome is kept: a moved ticket stays moved, and a skip stays the person's choice. */
34
+ function recorded(step) {
35
+ const ticket = step?.evidence?.ticket;
36
+ if (step?.state === "done" && typeof ticket === "string" && IDENTIFIER.test(ticket))
37
+ return { state: "done", evidence: { ...step.evidence } };
38
+ if (step?.state === "skipped" && step.reason === "first_ticket_skipped")
39
+ return {
40
+ state: "skipped",
41
+ reason: "first_ticket_skipped",
42
+ ...(typeof step.evidence?.stage === "string" ? { evidence: { stage: step.evidence.stage } } : {}),
43
+ };
44
+ return undefined;
45
+ }
46
+ /** What this setup already chose, saved in the receipt before any write: a ticket identifier or the
47
+ * sample. A run that stopped after the write, or lost its answer, finishes that choice instead of
48
+ * offering the list again, so it can never move a second ticket. */
49
+ const CHOSEN = /:first-ticket:([A-Z][A-Z0-9]{0,7}-[0-9]{1,9}|sample)$/;
50
+ /** Interactive only. The person picks one of the team's open tickets (smallest estimate first), a
51
+ * documentation-only sample, or skips. The chosen ticket moves to the team's dispatch stage through
52
+ * the same write route `catalyst write state` uses; a sample is created straight into it. Without
53
+ * `choose` (--yes, JSON, headless) the step only waits: it never lists, picks or creates a ticket.
54
+ * Done means the ticket is in the dispatch stage, not that a runner has started it. */
55
+ export function onboardFirstTicketAdapter(hooks) {
56
+ // The engine checks again right after an action, before the receipt holds its evidence.
57
+ let acted;
58
+ return {
59
+ check: async (ctx, journal) => {
60
+ const kept = acted ?? recorded(journal.steps.find((step) => step.id === "first-ticket"));
61
+ if (kept)
62
+ return kept;
63
+ if (hooks.choose)
64
+ return { state: "pending" };
65
+ // The contract this computer already has names the stage; no network read and no listing.
66
+ const teamId = selectedOnboardTeam(journal);
67
+ const stage = teamId
68
+ ? readContractCache(ctx.home)?.doc.teams.find((team) => team.id === teamId)?.stages.dispatch?.name
69
+ : undefined;
70
+ return {
71
+ ...waiting("first_ticket_choice_required"),
72
+ ...(typeof stage === "string" ? { evidence: { stage } } : {}),
73
+ };
74
+ },
75
+ act: async (ctx, journal, signal) => {
76
+ if (!hooks.choose)
77
+ return waiting("first_ticket_choice_required");
78
+ const cfg = loadConfig(ctx.home);
79
+ const teamId = selectedOnboardTeam(journal);
80
+ if (!cfg || !teamId)
81
+ return waiting("first_ticket_team_unverified");
82
+ let stage;
83
+ let writing = false;
84
+ const result = (state, reason, evidence = {}) => ({
85
+ state,
86
+ ...(reason ? { reason } : {}),
87
+ ...(stage || Object.keys(evidence).length
88
+ ? { evidence: { ...evidence, ...(stage ? { stage } : {}) } }
89
+ : {}),
90
+ });
91
+ try {
92
+ const { doc } = await loadContract(ctx, cfg, { refresh: true, signal });
93
+ const team = doc.teams.find((row) => row.id === teamId);
94
+ const dispatch = team?.stages.dispatch;
95
+ if (!team?.key || !dispatch?.stateStillExists)
96
+ return waiting("first_ticket_dispatch_unmapped");
97
+ stage = dispatch.name ?? undefined;
98
+ const api = apiClient(cfg, ctx);
99
+ const catalystStages = new Set(Object.values(team.stages).map((row) => row?.stateId));
100
+ const slug = doc.account.linearWorkspaceSlug;
101
+ const done = (identifier, url) => {
102
+ if (typeof identifier !== "string" || !IDENTIFIER.test(identifier))
103
+ return result("waiting", "first_ticket_unverified");
104
+ url ??=
105
+ slug && /^[A-Za-z0-9][A-Za-z0-9-]*$/.test(slug)
106
+ ? `https://linear.app/${slug}/issue/${identifier}`
107
+ : undefined;
108
+ return (acted = result("done", undefined, {
109
+ ticket: identifier,
110
+ teamKey: team.key,
111
+ ...(url ? { url } : {}),
112
+ }));
113
+ };
114
+ const createSample = async () => {
115
+ writing = true;
116
+ const created = await postAgent(api, doc, "issue-create", {
117
+ teamId: team.id,
118
+ title: SAMPLE_TITLE,
119
+ description: SAMPLE_DESCRIPTION,
120
+ stateId: dispatch.stateId,
121
+ // The same key on every run of this setup: a resent create answers with the first ticket.
122
+ idempotencyId: `${journal.runId}:first-ticket`,
123
+ });
124
+ return done(created?.identifier, linearUrl(created?.url));
125
+ };
126
+ const move = async (issueId, identifier, url) => {
127
+ writing = true;
128
+ await postAgent(api, doc, "issue-state", { issueId, stateId: dispatch.stateId });
129
+ return done(identifier, url);
130
+ };
131
+ const page = await fetchPage(api, "/api/v1/issues", {
132
+ team_key: team.key,
133
+ limit: 200,
134
+ });
135
+ const chosen = CHOSEN.exec(journal.operations?.["first-ticket"] ?? "")?.[1];
136
+ if (chosen === "sample")
137
+ return await createSample();
138
+ if (chosen) {
139
+ const detail = await api.getJson(`/api/v1/issues/${encodeURIComponent(chosen)}`, { accept: [404] });
140
+ const row = detail.status === 404 ? undefined : detail.body;
141
+ const url = linearUrl(row?.url);
142
+ // Already in a Catalyst stage: the move landed, or Catalyst has taken it further.
143
+ if (row && catalystStages.has(String(row.state_id)))
144
+ return done(chosen, url);
145
+ // Still open: the move never landed, so it is sent again for the same ticket only.
146
+ if (row && typeof row.id === "string" && !row.completed_at && !row.canceled_at)
147
+ return await move(row.id, chosen, url);
148
+ // Closed some other way: the choice no longer stands, so the person chooses again.
149
+ }
150
+ const open = page.rows
151
+ .filter((row) => row.team_id === team.id &&
152
+ typeof row.id === "string" &&
153
+ typeof row.identifier === "string" &&
154
+ IDENTIFIER.test(row.identifier) &&
155
+ OPEN_TYPES.has(String(row.state_type)) &&
156
+ !catalystStages.has(String(row.state_id)))
157
+ .map((row) => ({
158
+ id: row.id,
159
+ identifier: row.identifier,
160
+ title: String(row.title ?? "")
161
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
162
+ .slice(0, 120),
163
+ estimate: typeof row.estimate === "number" && Number.isFinite(row.estimate)
164
+ ? row.estimate
165
+ : null,
166
+ }))
167
+ .sort((a, b) => (a.estimate ?? Infinity) - (b.estimate ?? Infinity) ||
168
+ Number(a.identifier.split("-")[1]) - Number(b.identifier.split("-")[1]))
169
+ .slice(0, 5);
170
+ const choice = await hooks.choose(open.map(({ identifier, title, estimate }) => ({ identifier, title, estimate })), { teamKey: team.key, stage: dispatchStageName(stage) });
171
+ if (choice === null || signal?.aborted)
172
+ return waiting("interrupted");
173
+ if (choice === FIRST_TICKET_SKIP)
174
+ return (acted = result("skipped", "first_ticket_skipped"));
175
+ const ticket = open.find((row) => row.identifier === choice);
176
+ if (choice !== FIRST_TICKET_SAMPLE && !ticket)
177
+ return result("waiting", "first_ticket_choice_required");
178
+ journal.operations = {
179
+ ...journal.operations,
180
+ "first-ticket": `${journal.runId}:first-ticket:${ticket ? ticket.identifier : "sample"}`,
181
+ };
182
+ writeOnboardJournal(onboardStatePath(ctx.home, ctx.env), journal);
183
+ return ticket ? await move(ticket.id, ticket.identifier) : await createSample();
184
+ }
185
+ catch (error) {
186
+ if (signal?.aborted)
187
+ return waiting("interrupted");
188
+ const refused = error instanceof CliError && error.status === 403;
189
+ // A refused write changed nothing, so the choice is forgotten and the next run asks again.
190
+ if (refused && writing)
191
+ journal.operations = {
192
+ ...journal.operations,
193
+ "first-ticket": `${journal.runId}:first-ticket`,
194
+ };
195
+ return result("waiting", refused ? "first_ticket_move_refused" : "first_ticket_unavailable");
196
+ }
197
+ },
198
+ };
199
+ }
@@ -201,7 +201,7 @@ export function planOnboardHeadless(args, ctx) {
201
201
  id: "coding_account",
202
202
  kind: "input",
203
203
  reason: "coding_account_input_missing",
204
- text: `Name the coding account with --coding-account <slot> or CATALYST_ONBOARD_CODING_ACCOUNT. catalyst accounts lists the slots; enroll one at ${url.codingAccounts}.`,
204
+ text: `Name the AI account with --coding-account <slot> or CATALYST_ONBOARD_CODING_ACCOUNT. catalyst accounts lists them; add one at ${url.codingAccounts}.`,
205
205
  flag: "--coding-account",
206
206
  env: "CATALYST_ONBOARD_CODING_ACCOUNT",
207
207
  url: url.codingAccounts,
@@ -211,7 +211,7 @@ export function planOnboardHeadless(args, ctx) {
211
211
  id: "coding_account",
212
212
  kind: "input",
213
213
  reason: "coding_account_input_invalid",
214
- text: "The coding-account slot may hold only letters, digits, - and _. catalyst accounts lists the slots.",
214
+ text: "The AI account name given to --coding-account may hold only letters, digits, - and _. catalyst accounts lists them.",
215
215
  flag: "--coding-account",
216
216
  env: "CATALYST_ONBOARD_CODING_ACCOUNT",
217
217
  });
@@ -0,0 +1,93 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { accessSync, constants, existsSync } from "node:fs";
3
+ import { delimiter, join } from "node:path";
4
+ /** The names install.sh schedules the daily update under (catalyst-cloud's install-script.ts). */
5
+ export const HOUSEKEEPING_LABEL = "dev.catalystcloud.housekeeping";
6
+ export const HOUSEKEEPING_TIMER = "catalyst-housekeeping.timer";
7
+ const defaultRun = (cmd, args) => {
8
+ const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 5_000 });
9
+ return {
10
+ status: r.status ?? 1,
11
+ stdout: r.stdout ?? "",
12
+ stderr: r.error ? r.error.message : (r.stderr ?? ""),
13
+ };
14
+ };
15
+ function onPath(name, env) {
16
+ return (env.PATH ?? process.env.PATH ?? "")
17
+ .split(delimiter)
18
+ .filter(Boolean)
19
+ .some((dir) => {
20
+ try {
21
+ accessSync(join(dir, name), constants.X_OK);
22
+ return true;
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ });
28
+ }
29
+ /** The installer's own test for "systemctl is here but nothing manages user services". */
30
+ const NO_USER_MANAGER = /failed to connect to bus: (no medium found|no such file or directory)|system has not been booted with systemd/i;
31
+ /** Every answer but "scheduled" is a skip: the daily update is optional, so it never holds setup
32
+ * or becomes its next action. The reason keeps which case it is. */
33
+ const skipped = (reason, path) => ({
34
+ state: "skipped",
35
+ reason,
36
+ ...(path ? { evidence: { path } } : {}),
37
+ });
38
+ /** CTC-4680: the daily update step reports what this computer actually has. It only observes:
39
+ * scheduling stays the installer's job. */
40
+ export function onboardHousekeepingAdapter(options = {}) {
41
+ const run = options.run ?? defaultRun;
42
+ return {
43
+ check: async (ctx, journal) => {
44
+ const platform = options.platform ?? process.platform;
45
+ const choice = journal.dailyUpdate;
46
+ const notChosen = () => choice?.state === "off"
47
+ ? skipped("housekeeping_off_chosen")
48
+ : choice?.state === "skipped"
49
+ ? skipped("housekeeping_no_scheduler")
50
+ : undefined;
51
+ if (platform === "darwin") {
52
+ if (!onPath("launchctl", ctx.env))
53
+ return notChosen() ?? skipped("housekeeping_no_scheduler");
54
+ const plist = join(ctx.home, "Library", "LaunchAgents", `${HOUSEKEEPING_LABEL}.plist`);
55
+ if (!existsSync(plist))
56
+ return notChosen() ?? skipped("housekeeping_not_scheduled");
57
+ const uid = options.uid ?? process.getuid?.() ?? 0;
58
+ // `print` answers for a loaded job only; the plist alone schedules nothing.
59
+ if (run("launchctl", ["print", `gui/${uid}/${HOUSEKEEPING_LABEL}`]).status === 0)
60
+ return {
61
+ state: "done",
62
+ evidence: { scheduled: true, provider: "launchd", path: plist },
63
+ };
64
+ return notChosen() ?? skipped("housekeeping_not_loaded", plist);
65
+ }
66
+ if (platform === "linux") {
67
+ if (!onPath("systemctl", ctx.env))
68
+ return notChosen() ?? skipped("housekeeping_no_scheduler");
69
+ const timer = join(ctx.env.XDG_CONFIG_HOME ?? join(ctx.home, ".config"), "systemd", "user", HOUSEKEEPING_TIMER);
70
+ if (existsSync(timer)) {
71
+ const enabled = run("systemctl", ["--user", "is-enabled", HOUSEKEEPING_TIMER]);
72
+ if (enabled.status === 0 && enabled.stdout.trim() === "enabled")
73
+ return {
74
+ state: "done",
75
+ evidence: { scheduled: true, provider: "systemd", path: timer },
76
+ };
77
+ if (NO_USER_MANAGER.test(enabled.stderr))
78
+ return notChosen() ?? skipped("housekeeping_no_scheduler");
79
+ return notChosen() ?? skipped("housekeeping_not_enabled", timer);
80
+ }
81
+ const chosen = notChosen();
82
+ if (chosen)
83
+ return chosen;
84
+ // A systemctl binary is not a scheduler: a container or WSL has it with no user manager.
85
+ const manager = run("systemctl", ["--user", "show-environment"]);
86
+ return manager.status !== 0 && NO_USER_MANAGER.test(manager.stderr)
87
+ ? skipped("housekeeping_no_scheduler")
88
+ : skipped("housekeeping_not_scheduled");
89
+ }
90
+ return notChosen() ?? skipped("housekeeping_no_scheduler");
91
+ },
92
+ };
93
+ }