@catalyst-cloud/cli 0.9.3 → 0.9.5
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 +20 -0
- package/README.md +35 -31
- package/dist/args.js +13 -1
- package/dist/ask.js +2 -2
- package/dist/capabilities.js +70 -0
- package/dist/cli.js +37 -29
- package/dist/config.js +1 -1
- package/dist/connections.js +5 -5
- package/dist/environment.js +4 -4
- package/dist/events.js +1 -1
- package/dist/execution.js +5 -4
- package/dist/identity.js +3 -3
- package/dist/oauth.js +3 -3
- package/dist/query.js +27 -1
- package/dist/ready.js +3 -3
- package/dist/release.js +1 -1
- package/dist/replica.js +9 -7
- package/dist/runtime-store.js +2 -2
- package/dist/runtime.js +1 -1
- package/dist/secret.js +1 -1
- package/dist/skills.js +1 -1
- package/dist/team.js +329 -0
- package/package.json +1 -1
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +51 -35
- package/skills/catalyst-onboard/references/choosing-a-coding-account.md +52 -0
- package/skills/catalyst-onboard/references/declaring-a-repository.md +4 -2
- package/skills/catalyst-onboard/references/local-sync.md +3 -3
- package/skills/catalyst-onboard/references/replacing-a-credential.md +2 -2
- package/skills/catalyst-onboard/references/skill-sources.md +1 -1
- package/skills/catalyst-onboard/references/the-one-path.md +54 -86
- package/skills/catalyst-onboard/references/what-a-phase-needs.md +6 -6
- package/skills/catalyst-onboard/references/what-the-browser-owns.md +8 -10
- package/skills/catalyst-onboard/references/who-fixes-what.md +38 -18
- package/skills/catalyst-onboard/scripts/lib/cli.mjs +15 -1
- package/skills/catalyst-onboard/scripts/local-sync.mjs +6 -6
- package/skills/catalyst-onboard/scripts/where-am-i.mjs +294 -61
- package/skills/catalyst-setup/SKILL.md +1 -1
- package/skills/connect-me/SKILL.md +1 -1
- package/skills/how-catalyst-works/SKILL.md +2 -2
- package/skills/how-catalyst-works/references/what-runs-next.md +11 -0
- 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/whats-happening/SKILL.md +1 -1
- package/skills/whats-happening/references/reading-the-board.md +1 -1
- package/skills/whats-happening/references/why-is-it-stuck.md +1 -0
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// where-am-i.mjs — how far has setup got?
|
|
2
|
+
// where-am-i.mjs — how far has setup got? EIGHT PARTS, EACH READ BY THE INSTRUMENT THAT OWNS IT, and
|
|
3
|
+
// reported in the order a person can act on them (coding account first, then the integrations, the
|
|
4
|
+
// project, its repository, the person's own connected accounts, the repository's declaration, the host);
|
|
3
5
|
// each finding labelled with the part it belongs to. The whole point of this script is that no part
|
|
4
6
|
// answers for another: a project that is not ready is reported as a project finding with a tenant
|
|
5
7
|
// owner's name on it, never as something the person at this keyboard can fix by running anything.
|
|
@@ -18,8 +20,10 @@ const NOTES = [
|
|
|
18
20
|
"`replica status --probe --json` and `events status --probe --json` (optional local freshness),",
|
|
19
21
|
"`me --json` and personal connection statuses (person), `contract --path …` for the account, the projects and the repositories,",
|
|
20
22
|
"`contract --path codingAccounts` and `accounts --json` (coding accounts, and which one needs a new credential), and each",
|
|
21
|
-
"project's hosts_current check with its fixedWhere (host).",
|
|
23
|
+
"project's hosts_current check with its fixedWhere (host), and each project's environment_declared check with its per-repository notes (repository declarations). The teams read is refreshed, so a mapping just saved reads back mapped.",
|
|
24
|
+
"Reads `capabilities --json` once: when the installed CLI can run a step for this person's role (`team check`, `team map`), the next step is that command; otherwise the page, with who can.",
|
|
22
25
|
"Also runs `gh repo view <owner>/thoughts` for each registered repository's owner, as a note: it shows the repository exists, never that the GitHub App can reach it.",
|
|
26
|
+
"When the tenant serves a contract version this CLI refuses, runs `npm view @catalyst-cloud/cli version` once to say whether a newer CLI is published.",
|
|
23
27
|
"Writes nothing and changes nothing. Runs before this machine is connected — that is one of the states it reports.",
|
|
24
28
|
];
|
|
25
29
|
|
|
@@ -42,17 +46,46 @@ let personalGithubIncomplete = false;
|
|
|
42
46
|
let personalNext = null;
|
|
43
47
|
let workspaceResolved = false;
|
|
44
48
|
let repositoryRegistered = false;
|
|
49
|
+
// what the installed CLI can do, read once from `capabilities --json` (an older CLI has no
|
|
50
|
+
// such verb and reads as "nothing known"), and the person's role from `me`. Together they decide
|
|
51
|
+
// whether a next step is a command this person can run or a page for the person who can.
|
|
52
|
+
let capabilities = null;
|
|
53
|
+
let personRole = null;
|
|
54
|
+
const canManage = () => personRole === "admin" || personRole === "owner";
|
|
55
|
+
const capability = (verb) => (Array.isArray(capabilities) ? capabilities.find((c) => c && c.verb === verb) : undefined);
|
|
56
|
+
const verbAvailable = (verb) => { const c = capability(verb); return c?.availability === "available" && (c.needs === "member" || canManage()); };
|
|
57
|
+
const verbAdminOnly = (verb) => { const c = capability(verb); return c?.availability === "available" && c.needs === "admin" && !canManage(); };
|
|
45
58
|
// `blocking` is false for a finding that is real and reportable but does not stop the next step —
|
|
46
59
|
// an unmatched Linear identity is the one that matters: it must be said, and it must not become the
|
|
47
60
|
// thing the person is told to go and do before they can map a project.
|
|
48
|
-
const add = (part, instrument, verdict, lines, owner = null, where = null, blocking = true) =>
|
|
49
|
-
|
|
61
|
+
const add = (part, instrument, verdict, lines, owner = null, where = null, blocking = true) => {
|
|
62
|
+
const p = { part, instrument, verdict, lines, owner, where, blocking };
|
|
63
|
+
parts.push(p);
|
|
64
|
+
return p;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// A tenant contract outside this CLI's range makes every contract read fail the same way. When that
|
|
68
|
+
// happens, no part read from the contract can back a next step, and the only honest next step is the
|
|
69
|
+
// version itself. The CLI's own hint says to update, which is wrong when no newer CLI is published.
|
|
70
|
+
let contractMismatch = null;
|
|
71
|
+
const noteMismatch = (text) => {
|
|
72
|
+
const m =
|
|
73
|
+
(text ?? "").match(/serves contract version (\S+) but this bundle accepts (\S+)/) ??
|
|
74
|
+
(text ?? "").match(/contract: version (\S+) is outside this bundle's range (\S+)/);
|
|
75
|
+
if (m !== null) contractMismatch ??= { served: m[1], accepts: m[2] };
|
|
76
|
+
return m !== null;
|
|
77
|
+
};
|
|
78
|
+
const readContract = (args) => {
|
|
79
|
+
const r = runCli(args);
|
|
80
|
+
noteMismatch(r.stderr);
|
|
81
|
+
return r;
|
|
82
|
+
};
|
|
50
83
|
|
|
51
84
|
// ── machine ───────────────────────────────────────────────────────────────────────────────────────
|
|
52
85
|
const status = runCli(["status"]);
|
|
53
86
|
if (!status.ran) {
|
|
54
87
|
process.stderr.write(`${status.stderr}\n`);
|
|
55
|
-
process.stderr.write(`the catalyst
|
|
88
|
+
process.stderr.write(`the catalyst CLI could not be started. Install it, then run this again.\n`);
|
|
56
89
|
process.exit(2);
|
|
57
90
|
}
|
|
58
91
|
const statusLines = status.stdout.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
@@ -72,7 +105,7 @@ const machineLines = connected ? statusLines : [statusLines[0] ?? "status printe
|
|
|
72
105
|
let machineVerdict = connected ? "ok" : "unfinished";
|
|
73
106
|
|
|
74
107
|
// `ready` is ONE verdict over two parts; split it by check id before anything is reported. A check
|
|
75
|
-
// whose id begins with "team:" belongs to a project and
|
|
108
|
+
// whose id begins with "team:" belongs to a project and needs the member's admin or owner seat.
|
|
76
109
|
let projectChecks = [];
|
|
77
110
|
let machineFix = null;
|
|
78
111
|
let localSync;
|
|
@@ -87,10 +120,13 @@ if (connected) {
|
|
|
87
120
|
projectChecks = checks.filter((c) => typeof c.id === "string" && c.id.startsWith("team:"));
|
|
88
121
|
const machineChecks = checks.filter((c) => !(typeof c.id === "string" && c.id.startsWith("team:")));
|
|
89
122
|
const failed = machineChecks.filter((c) => !c.ok && !c.note);
|
|
90
|
-
|
|
123
|
+
// A refused contract version is printed without the CLI's "update" fix: whether an update exists
|
|
124
|
+
// is checked below, once, against what npm has published.
|
|
125
|
+
const refused = (c) => !c.ok && !c.note && noteMismatch(c.line);
|
|
126
|
+
for (const c of machineChecks) machineLines.push(`${c.note ? "note" : c.ok ? "ok " : "FAIL"} ${c.line}${!c.ok && !c.note && c.fix && !refused(c) ? ` — fix: ${c.fix}` : ""}`);
|
|
91
127
|
if (failed.length > 0) {
|
|
92
128
|
machineVerdict = "unfinished";
|
|
93
|
-
machineFix = failed.find((c) => typeof c.fix === "string")?.fix ?? null;
|
|
129
|
+
machineFix = failed.find((c) => typeof c.fix === "string" && !refused(c))?.fix ?? null;
|
|
94
130
|
}
|
|
95
131
|
}
|
|
96
132
|
// Supplemental only: local caches are optional and do not change setup completion or --next.
|
|
@@ -102,31 +138,38 @@ if (connected) {
|
|
|
102
138
|
localSync = {
|
|
103
139
|
assessment: { verdict: "unknown", current: false, reason: "connect this machine before local freshness can be checked" },
|
|
104
140
|
started: false,
|
|
105
|
-
recovery: "catalyst
|
|
141
|
+
recovery: "catalyst login",
|
|
106
142
|
};
|
|
107
143
|
}
|
|
108
144
|
add(
|
|
109
145
|
"machine",
|
|
110
|
-
"catalyst
|
|
146
|
+
"catalyst status, and the non-team checks of catalyst ready",
|
|
111
147
|
machineVerdict,
|
|
112
148
|
machineLines,
|
|
113
149
|
"you, on this machine",
|
|
114
|
-
|
|
150
|
+
// The keyless login alone: a person who holds a personal key already knows the key form. A machine
|
|
151
|
+
// that holds the CLI is told its own command; only one with no CLI at all gets the npx line.
|
|
152
|
+
connected ? null : cliTarget().recorded ? "catalyst login" : CONNECT_LINE.split(" (or")[0],
|
|
115
153
|
);
|
|
154
|
+
if (connected) {
|
|
155
|
+
const capDoc = tryJson(runCli(["capabilities", "--json"]).stdout);
|
|
156
|
+
capabilities = Array.isArray(capDoc?.capabilities) ? capDoc.capabilities : null;
|
|
157
|
+
}
|
|
116
158
|
|
|
117
159
|
// ── person ────────────────────────────────────────────────────────────────────────────────────────
|
|
118
160
|
if (!connected) {
|
|
119
|
-
add("person", "catalyst
|
|
161
|
+
add("person", "catalyst me", "unreadable", ["not readable until this machine is connected"], null, null);
|
|
120
162
|
} else {
|
|
121
163
|
const me = runCli(["me", "--json"]);
|
|
122
164
|
const doc = tryJson(me.stdout);
|
|
123
165
|
const user = doc && typeof doc.user === "object" && doc.user !== null ? doc.user : null;
|
|
124
166
|
if (doc === null) {
|
|
125
|
-
add("person", "catalyst
|
|
167
|
+
add("person", "catalyst me", "unreadable", [`me could not be read (${(me.stderr || me.stdout).trim().split("\n")[0] ?? "no output"})`], null, null).next =
|
|
168
|
+
"run this again; catalyst me could not be read, so no step after it can be named yet";
|
|
126
169
|
} else if (user === null) {
|
|
127
170
|
add(
|
|
128
171
|
"person",
|
|
129
|
-
"catalyst
|
|
172
|
+
"catalyst me",
|
|
130
173
|
"unfinished",
|
|
131
174
|
["this credential names no person — it is a host credential, so nothing an agent writes will carry a name"],
|
|
132
175
|
"you: connect again with your own login",
|
|
@@ -134,6 +177,7 @@ if (!connected) {
|
|
|
134
177
|
);
|
|
135
178
|
} else {
|
|
136
179
|
const matched = typeof user.linearUserId === "string" && user.linearUserId !== "";
|
|
180
|
+
personRole = typeof user.role === "string" ? user.role : null;
|
|
137
181
|
const grantLines = [];
|
|
138
182
|
for (const provider of ["linear", "github"]) {
|
|
139
183
|
const read = runCli(["connections", "personal", provider, "status", "--json"]);
|
|
@@ -152,17 +196,17 @@ if (!connected) {
|
|
|
152
196
|
personalGrantIncomplete = true;
|
|
153
197
|
if (provider === "linear") personalLinearIncomplete = true;
|
|
154
198
|
else personalGithubIncomplete = true;
|
|
155
|
-
grantLines.push(`personal ${provider}: ${outcome === "unavailable" ? "temporarily unavailable; grant state unknown" : "could not be checked; update the catalyst
|
|
199
|
+
grantLines.push(`personal ${provider}: ${outcome === "unavailable" ? "temporarily unavailable; grant state unknown" : "could not be checked; update the catalyst CLI or inspect its status output"}`);
|
|
156
200
|
if (provider === "linear") personalNext ??= "re-check your personal linear connection";
|
|
157
201
|
}
|
|
158
202
|
}
|
|
159
203
|
add(
|
|
160
204
|
"person",
|
|
161
|
-
"catalyst
|
|
205
|
+
"catalyst me, and catalyst connections personal <provider> status --json",
|
|
162
206
|
matched && !personalGrantIncomplete ? "ok" : "unfinished",
|
|
163
|
-
[`${user.label ?? "(unnamed)"} (${user.role ?? "role unknown"})`, matched ? "Linear identity matched" : "Linear identity NOT matched — asks assigned to you cannot be told apart from everyone else's. It blocks nothing below. Run catalyst
|
|
164
|
-
personalGrantIncomplete ? "you" : matched ? null : "a
|
|
165
|
-
personalGrantIncomplete ? "catalyst
|
|
207
|
+
[`${user.label ?? "(unnamed)"} (${user.role ?? "role unknown"})`, matched ? "Linear identity matched" : "Linear identity NOT matched — asks assigned to you cannot be told apart from everyone else's. It blocks nothing below. Run catalyst identity linear options for self-service recovery; personal Linear consent normally binds its viewer automatically.", ...grantLines],
|
|
208
|
+
personalGrantIncomplete ? "you" : matched ? null : "a workspace owner or admin",
|
|
209
|
+
personalGrantIncomplete ? "catalyst connections personal <provider> start or status" : matched ? null : link("/settings/account"),
|
|
166
210
|
false,
|
|
167
211
|
);
|
|
168
212
|
}
|
|
@@ -170,12 +214,13 @@ if (!connected) {
|
|
|
170
214
|
|
|
171
215
|
// ── account ───────────────────────────────────────────────────────────────────────────────────────
|
|
172
216
|
if (!connected) {
|
|
173
|
-
add("account", "catalyst
|
|
217
|
+
add("account", "catalyst contract --path account", "unreadable", ["not readable until this machine is connected"], null, null);
|
|
174
218
|
} else {
|
|
175
|
-
const acct =
|
|
219
|
+
const acct = readContract(["contract", "--path", "account", "--json"]);
|
|
176
220
|
const doc = tryJson(acct.stdout);
|
|
177
221
|
if (doc === null) {
|
|
178
|
-
add("account", "catalyst
|
|
222
|
+
add("account", "catalyst contract --path account", "unreadable", ["the account block could not be read — try: catalyst contract --refresh"], null, null).next =
|
|
223
|
+
"refresh the contract (catalyst contract --refresh), then run this again; the account could not be read, so no step after it can be named yet";
|
|
179
224
|
} else {
|
|
180
225
|
const workspace = typeof doc.linearWorkspaceSlug === "string" && doc.linearWorkspaceSlug !== "" ? doc.linearWorkspaceSlug : typeof doc.linearWorkspaceId === "string" && doc.linearWorkspaceId !== "" ? doc.linearWorkspaceId : null;
|
|
181
226
|
workspaceResolved = workspace !== null;
|
|
@@ -187,7 +232,7 @@ if (!connected) {
|
|
|
187
232
|
if (envDoc === null) {
|
|
188
233
|
envLines.push(`environment: not readable (${(env.stderr || env.stdout).trim().split("\n")[0] ?? "no output"})`);
|
|
189
234
|
} else if (envDoc.current === null || envDoc.current === undefined) {
|
|
190
|
-
envLines.push("environment: nothing declared yet — declare it with `catalyst
|
|
235
|
+
envLines.push("environment: nothing declared yet — declare it with `catalyst environment propose --file <path> --approve`");
|
|
191
236
|
} else {
|
|
192
237
|
envLines.push(`environment: revision ${envDoc.current.revision} (${envDoc.current.canonicalHash}), ${envDoc.isApproved ? "approved" : "NOT approved — a proposal nobody approved changes nothing"}`);
|
|
193
238
|
envLines.push(envDoc.delivered ? `environment delivered to phases: revision ${envDoc.delivered.revision}` : "environment delivered to phases: nothing yet");
|
|
@@ -196,7 +241,7 @@ if (!connected) {
|
|
|
196
241
|
}
|
|
197
242
|
add(
|
|
198
243
|
"account",
|
|
199
|
-
"catalyst
|
|
244
|
+
"catalyst contract --path account, and catalyst environment read",
|
|
200
245
|
workspace === null ? "unfinished" : "ok",
|
|
201
246
|
[
|
|
202
247
|
`${doc.name ?? "(unnamed tenant)"} (${doc.slug ?? "?"})`,
|
|
@@ -204,7 +249,7 @@ if (!connected) {
|
|
|
204
249
|
"the GitHub App install is NOT carried here — the connections page is the only place that shows it",
|
|
205
250
|
...envLines,
|
|
206
251
|
],
|
|
207
|
-
"a
|
|
252
|
+
"a workspace owner or admin",
|
|
208
253
|
link("/settings/connections"),
|
|
209
254
|
);
|
|
210
255
|
}
|
|
@@ -212,16 +257,40 @@ if (!connected) {
|
|
|
212
257
|
|
|
213
258
|
// The host part reads the same rows, so they are kept rather than read twice.
|
|
214
259
|
let teamRows = null;
|
|
260
|
+
// The contract's readinessChecks metadata (severity and owner per check id), read once and shared by
|
|
261
|
+
// the projects, host and declarations parts. An older cloud that has none answers [].
|
|
262
|
+
let readinessMetaCache;
|
|
263
|
+
const readinessMeta = () => {
|
|
264
|
+
if (readinessMetaCache === undefined) {
|
|
265
|
+
const m = tryJson(readContract(["contract", "--path", "readinessChecks", "--json"]).stdout);
|
|
266
|
+
readinessMetaCache = Array.isArray(m) ? m : [];
|
|
267
|
+
}
|
|
268
|
+
return readinessMetaCache;
|
|
269
|
+
};
|
|
270
|
+
// Linear's own Git automation rules, by check id, as the Settings → Workflow → Git automation screen
|
|
271
|
+
// names them. A rule that moves a card is a conflict with the stage Catalyst manages, and no Catalyst
|
|
272
|
+
// key can change a Linear automation rule: the fix is in Linear, by a Linear admin.
|
|
273
|
+
const LINEAR_AUTOMATION_RULES = {
|
|
274
|
+
linear_automation_pr_open: "On PR open",
|
|
275
|
+
linear_automation_pr_review: "On PR review request or activity",
|
|
276
|
+
linear_automation_pr_ready: "On PR ready for merge",
|
|
277
|
+
linear_automation_pr_merge: "On PR merge",
|
|
278
|
+
};
|
|
279
|
+
let blockedProjects = [];
|
|
280
|
+
|
|
215
281
|
// ── projects (a project is one Linear team) ───────────────────────────────────────────────────────
|
|
216
282
|
if (!connected) {
|
|
217
|
-
add("projects", "catalyst
|
|
283
|
+
add("projects", "catalyst contract --path teams", "unreadable", ["not readable until this machine is connected"], null, null);
|
|
218
284
|
} else {
|
|
219
|
-
|
|
285
|
+
// The mapping write updates the cloud before the cached contract's dispatchGate projection.
|
|
286
|
+
// Revalidate this one read so a successful save does not appear unmapped on the next step.
|
|
287
|
+
const teams = readContract(["contract", "--refresh", "--path", "teams", "--json"]);
|
|
220
288
|
const doc = tryJson(teams.stdout);
|
|
221
289
|
const rows = Array.isArray(doc) ? doc : null;
|
|
222
290
|
teamRows = rows;
|
|
223
291
|
if (rows === null) {
|
|
224
|
-
add("projects", "catalyst
|
|
292
|
+
add("projects", "catalyst contract --path teams", "unreadable", ["the project list could not be read — try: catalyst contract --refresh"], null, null).next =
|
|
293
|
+
"refresh the contract (catalyst contract --refresh), then run this again; the project list could not be read, so no step after it can be named yet";
|
|
225
294
|
} else {
|
|
226
295
|
const lines = [`${rows.length} mapped`];
|
|
227
296
|
for (const t of rows) {
|
|
@@ -231,19 +300,42 @@ if (!connected) {
|
|
|
231
300
|
lines.push(`${key}: ${readiness.status ?? "unknown"}${bad.length ? ` — ${bad.map((c) => `${c.id} ${c.state}`).join(", ")}` : ""}`);
|
|
232
301
|
}
|
|
233
302
|
for (const c of projectChecks) lines.push(`${c.ok ? "note" : "FAIL"} ${c.line}${c.who ? ` — who: ${c.who}` : ""}`);
|
|
234
|
-
lines.push("
|
|
303
|
+
lines.push("MAPPED projects only. An empty list means nothing is mapped yet, NOT that there are no projects. Run catalyst team list to inspect the live list without checking readiness.");
|
|
235
304
|
// A project is set up when its dispatch gate is open (its stages are mapped), or when its
|
|
236
305
|
// readiness reads ready. Readiness alone kept `--next` on "map its stages" for a tenant whose
|
|
237
306
|
// gates were open: readiness stays "unchecked" until someone presses Re-check, and a new team
|
|
238
307
|
// stays "degraded" until a repository is attached, which is the step AFTER this one.
|
|
239
|
-
|
|
308
|
+
// ⛔ A team whose readiness reads `blocked` is NOT set up, whatever its gate says: a blocking
|
|
309
|
+
// check (a Linear automation that moves cards off Catalyst's stages, a missing OAuth scope) breaks
|
|
310
|
+
// every run in it. The step is that check's own fix, by its own owner, then a re-check.
|
|
311
|
+
const isBlocking = (c) => readinessMeta().length === 0 || readinessMeta().find((r) => r.id === c.id)?.severity === "blocking";
|
|
312
|
+
for (const t of rows.filter((r) => r.readiness?.status === "blocked")) {
|
|
313
|
+
const key = t.key ?? t.id ?? "(unkeyed)";
|
|
314
|
+
const fails = (Array.isArray(t.readiness?.checks) ? t.readiness.checks : []).filter((c) => c.state === "fail" && isBlocking(c));
|
|
315
|
+
const ids = fails.map((c) => c.id);
|
|
316
|
+
const rules = ids.filter((id) => id in LINEAR_AUTOMATION_RULES).map((id) => LINEAR_AUTOMATION_RULES[id]);
|
|
317
|
+
const others = ids.filter((id) => !(id in LINEAR_AUTOMATION_RULES));
|
|
318
|
+
const ownerRow = fails.map((c) => readinessMeta().find((r) => r.id === c.id)).find((r) => r && typeof r.fixedByLine === "string");
|
|
319
|
+
const owner = ownerRow ? ownerRow.fixedByLine : "a workspace owner or admin";
|
|
320
|
+
const how = [
|
|
321
|
+
rules.length > 0 ? `in Linear, open Settings → Teams → ${key} → Workflow → Git automation and set ${rules.join(", ")} to No action (no Catalyst key can change a Linear automation rule)` : null,
|
|
322
|
+
others.length > 0 ? `${others.join(", ")}: ${owner}` : null,
|
|
323
|
+
].filter(Boolean).join("; ");
|
|
324
|
+
lines.push(`${key}: BLOCKED — ${ids.length > 0 ? ids.join(", ") : "a blocking check failed"}; ${how || `owner: ${owner}`}`);
|
|
325
|
+
blockedProjects.push({ key, action: `fix ${key}'s blocking check${ids.length === 1 ? "" : "s"} (${ids.join(", ") || "see the projects lines"}): ${how || owner}; then run catalyst team check ${key} (or press Re-check) and run this again`, owner });
|
|
326
|
+
}
|
|
327
|
+
const setUp = (t) => t.readiness?.status !== "blocked" && (t.dispatchGate?.status === "open" || (t.readiness?.status ?? "unchecked") === "ready");
|
|
240
328
|
for (const t of rows) {
|
|
241
329
|
if (t.dispatchGate?.status === "open" && (t.readiness?.status ?? "unchecked") === "unchecked") {
|
|
242
|
-
lines.push(`${t.key ?? t.id ?? "(unkeyed)"}: stages mapped; readiness not checked yet:
|
|
330
|
+
lines.push(`${t.key ?? t.id ?? "(unkeyed)"}: stages mapped; readiness not checked yet: run catalyst team check ${t.key ?? t.id} to see the rest`);
|
|
243
331
|
}
|
|
244
332
|
}
|
|
245
333
|
const ready = rows.length > 0 && rows.every(setUp);
|
|
246
|
-
|
|
334
|
+
const mapOwner = verbAvailable("team map") ? "you, with your own login" : "a workspace owner or admin";
|
|
335
|
+
const mapWhere = verbAvailable("team map") ? "catalyst team list" : link("/settings/linear-teams");
|
|
336
|
+
const projectOwner = blockedProjects.length > 0 ? blockedProjects[0].owner : mapOwner;
|
|
337
|
+
const projectWhere = blockedProjects.length > 0 ? null : mapWhere;
|
|
338
|
+
add("projects", "catalyst contract --path teams, and the team: checks of ready", ready ? "ok" : "unfinished", lines, projectOwner, projectWhere);
|
|
247
339
|
}
|
|
248
340
|
}
|
|
249
341
|
|
|
@@ -260,19 +352,20 @@ function thoughtsNote(owner) {
|
|
|
260
352
|
return `note ${repo}: gh could not see it (${why}). It does not exist, or this GitHub login cannot see it. Create or confirm it (step 5a).`;
|
|
261
353
|
}
|
|
262
354
|
if (!connected) {
|
|
263
|
-
add("repositories", "catalyst
|
|
355
|
+
add("repositories", "catalyst contract --path merge.repositories", "unreadable", ["not readable until this machine is connected"], null, null);
|
|
264
356
|
} else {
|
|
265
|
-
const repos =
|
|
357
|
+
const repos = readContract(["contract", "--path", "merge.repositories", "--json"]);
|
|
266
358
|
const doc = tryJson(repos.stdout);
|
|
267
359
|
const rows = Array.isArray(doc) ? doc : null;
|
|
268
360
|
if (rows === null) {
|
|
269
|
-
add("repositories", "catalyst
|
|
361
|
+
add("repositories", "catalyst contract --path merge.repositories", "unreadable", ["the repository list could not be read — try: catalyst contract --refresh"], null, null).next =
|
|
362
|
+
"refresh the contract (catalyst contract --refresh), then run this again; the repository list could not be read, so no step after it can be named yet";
|
|
270
363
|
} else {
|
|
271
364
|
const lines = [`${rows.length} registered`, ...rows.map((r) => `${r.owner ?? "?"}/${r.name ?? "?"}`)];
|
|
272
365
|
repositoryRegistered = rows.length > 0;
|
|
273
366
|
lines.push("⛔ REGISTRATION only. This carries no status and no project attachment, so it never proves a repository can be dispatched into.");
|
|
274
367
|
for (const owner of [...new Set(rows.map((r) => r.owner).filter((o) => typeof o === "string" && o !== ""))]) lines.push(thoughtsNote(owner));
|
|
275
|
-
add("repositories", "catalyst
|
|
368
|
+
add("repositories", "catalyst contract --path merge.repositories", rows.length > 0 ? "ok" : "unfinished", lines, "a workspace owner or admin", link("/settings/repositories"));
|
|
276
369
|
}
|
|
277
370
|
}
|
|
278
371
|
|
|
@@ -282,17 +375,29 @@ if (!connected) {
|
|
|
282
375
|
// The contract says the state, its sentence, who enrolls one and on which page. An older cloud's
|
|
283
376
|
// contract has no `codingAccounts`, and only then is the owner assumed.
|
|
284
377
|
let accountsNext = "enrol a coding account a phase can run on";
|
|
378
|
+
// A note --next prints beside the next step: an ended or cancelled account is not unfinished, but the
|
|
379
|
+
// person must hear that it is kept for reporting and never re-tokened, before anyone reaches for a setup token.
|
|
380
|
+
let retireNote = null;
|
|
285
381
|
// The contract counts accounts by declared rotation, so one healthy account can hide another whose
|
|
286
382
|
// credential is dead. When it says `enrolled`, each account is read as well. An account needs a new
|
|
287
383
|
// credential when it is quarantined, expired or revoked, or when its last polls failed on the
|
|
288
|
-
// credential itself. The
|
|
289
|
-
|
|
384
|
+
// credential itself. The cloud's own verdict, `needsCredential`, wins when it is sent:
|
|
385
|
+
// it leaves out ended and revoked accounts and usage-endpoint refusals, which are not verdicts on the
|
|
386
|
+
// credential. Older clouds send only the poll fields, read with the same narrowed code list.
|
|
387
|
+
const CREDENTIAL_ERROR_CODES = new Set(["no_access_token", "no_credential"]);
|
|
290
388
|
const CREDENTIAL_FAILURE_STREAK = 3;
|
|
291
389
|
// A cancelled subscription or an ended account is not a credential problem: no token brings it
|
|
292
|
-
// back. It is
|
|
390
|
+
// back. It is kept for reporting, never re-credentialed, and it does not make setup unfinished.
|
|
293
391
|
const isRetirable = (a) => a?.renewalStatus === "canceled" || a?.status === "ended" || a?.declaredState === "ended";
|
|
294
392
|
const credentialProblem = (a) => {
|
|
295
393
|
if (isRetirable(a)) return null;
|
|
394
|
+
if (typeof a?.needsCredential === "boolean") {
|
|
395
|
+
if (!a.needsCredential) return null;
|
|
396
|
+
if (a.quarantined === true) return `quarantined${typeof a.quarantineReason === "string" && a.quarantineReason !== "" ? `: ${a.quarantineReason}` : ""}`;
|
|
397
|
+
if (a.status === "expired-or-revoked") return "expired or revoked";
|
|
398
|
+
return typeof a.lastPollErrorCode === "string" ? `its last ${a.pollFailureCount} polls failed with ${a.lastPollErrorCode}` : "the cloud says it needs a new credential";
|
|
399
|
+
}
|
|
400
|
+
if (a?.status === "ended" || (a?.revokedAtMs ?? null) !== null) return null;
|
|
296
401
|
if (a?.quarantined === true) return `quarantined${typeof a.quarantineReason === "string" && a.quarantineReason !== "" ? `: ${a.quarantineReason}` : ""}`;
|
|
297
402
|
if (a?.status === "expired-or-revoked") return "expired or revoked";
|
|
298
403
|
if (CREDENTIAL_ERROR_CODES.has(a?.lastPollErrorCode) && typeof a?.pollFailureCount === "number" && a.pollFailureCount >= CREDENTIAL_FAILURE_STREAK) {
|
|
@@ -307,24 +412,25 @@ const readAccountRows = () => {
|
|
|
307
412
|
return { rows, error: rows === null ? (acc.stderr || acc.stdout).trim().split("\n")[0] || "no output" : null };
|
|
308
413
|
};
|
|
309
414
|
if (!connected) {
|
|
310
|
-
add("coding accounts", "codingAccounts in catalyst
|
|
415
|
+
add("coding accounts", "codingAccounts in catalyst contract", "unreadable", ["not readable until this machine is connected"], null, null);
|
|
311
416
|
} else {
|
|
312
|
-
const res =
|
|
417
|
+
const res = readContract(["contract", "--path", "codingAccounts", "--json"]);
|
|
313
418
|
const ca = tryJson(res.stdout);
|
|
314
419
|
const older = ca === null && /has nothing at/.test(res.stderr ?? "");
|
|
315
|
-
const INSTRUMENT = "codingAccounts in catalyst
|
|
420
|
+
const INSTRUMENT = "codingAccounts in catalyst contract";
|
|
316
421
|
if (ca !== null && typeof ca === "object" && typeof ca.state === "string") {
|
|
317
422
|
const line = typeof ca.line === "string" ? ca.line : `state ${ca.state}`;
|
|
318
423
|
const owner = typeof ca.enrolledByLine === "string" ? ca.enrolledByLine : null;
|
|
319
424
|
const where = typeof ca.page === "string" ? link(ca.page) : null;
|
|
320
425
|
if (ca.state === "enrolled" || ca.state === "needs_credential") {
|
|
321
|
-
const DETAIL = `${INSTRUMENT}, and catalyst
|
|
426
|
+
const DETAIL = `${INSTRUMENT}, and catalyst accounts`;
|
|
322
427
|
const head = [line, `${ca.activeCount ?? "?"} active`];
|
|
323
428
|
const { rows, error } = readAccountRows();
|
|
324
429
|
const dead = rows === null ? [] : rows.filter((a) => credentialProblem(a) !== null);
|
|
325
430
|
const retirable = rows === null ? [] : rows.filter(isRetirable);
|
|
326
431
|
const who = (a) => [a.label, a.email].find((v) => typeof v === "string" && v !== "");
|
|
327
|
-
const retireLine = retirable.length === 0 ? [] : [`${retirable.length} account(s)
|
|
432
|
+
const retireLine = retirable.length === 0 ? [] : [`${retirable.length} account(s) cancelled or ended (${retirable.map((a) => `${who(a) ?? a.displayName ?? a.accountSlot ?? "?"} (${a.provider ?? "unknown provider"})`).join(", ")}): kept for reporting, not used, and not counted here. Never replace their credential; reactivate one on the AI accounts page only if its subscription is live again.`];
|
|
433
|
+
retireNote = retireLine[0] ?? null;
|
|
328
434
|
if (rows === null) {
|
|
329
435
|
accountsNext = "read the coding accounts again; each account's credential could not be checked";
|
|
330
436
|
add("coding accounts", DETAIL, "unreadable", [...head, `coding accounts could not be checked in detail (${error}). Whether each one still has a working credential is unknown. Do not enroll one on this reading.`], null, null);
|
|
@@ -360,31 +466,41 @@ if (!connected) {
|
|
|
360
466
|
const { rows, error } = readAccountRows();
|
|
361
467
|
const olderLine = "this cloud is older than the bundle: its contract does not say whether a coding account is enrolled, so the account list is read instead";
|
|
362
468
|
if (rows === null) {
|
|
363
|
-
|
|
469
|
+
accountsNext = "read the coding accounts again; the account list could not be read this time";
|
|
470
|
+
add("coding accounts", "catalyst accounts", "unreadable", [olderLine, `coding accounts could not be read (${error})`], null, null);
|
|
364
471
|
} else {
|
|
365
472
|
// An expired or revoked slot, or a quarantined one, cannot take work until an admin acts on it.
|
|
366
473
|
const usable = rows.filter((a) => a?.status !== "expired-or-revoked" && a?.quarantined !== true);
|
|
367
474
|
const lines = [olderLine, `${rows.length} enrolled, ${usable.length} able to take work`];
|
|
368
475
|
for (const a of rows) lines.push(`${a.accountSlot ?? "?"}: ${a.provider ?? "?"}, ${a.status ?? "status unknown"}${a.quarantined ? ", quarantined" : ""}`);
|
|
369
476
|
if (usable.length === 0) lines.push("no phase can start until one is enrolled and able to take work");
|
|
370
|
-
add("coding accounts", "catalyst
|
|
477
|
+
add("coding accounts", "catalyst accounts", usable.length > 0 ? "ok" : "unfinished", lines, "a workspace owner or admin", link("/settings/coding-accounts"));
|
|
371
478
|
}
|
|
372
479
|
} else {
|
|
373
480
|
accountsNext = "read the coding accounts again; the contract could not be read";
|
|
374
|
-
add("coding accounts", INSTRUMENT, "unreadable", [`the contract could not be read (${(res.stderr || res.stdout).trim().split("\n").pop() || "no output"}); try: catalyst
|
|
481
|
+
add("coding accounts", INSTRUMENT, "unreadable", [`the contract could not be read (${(res.stderr || res.stdout).trim().split("\n").pop() || "no output"}); try: catalyst contract --refresh`], null, null);
|
|
375
482
|
}
|
|
376
483
|
}
|
|
377
484
|
|
|
485
|
+
// the Re-check step, as a command when this person can run it, else as the page with the
|
|
486
|
+
// person who can. `team check` is the CLI twin of the page's Re-check button (admin or owner).
|
|
487
|
+
const mappedKeys = () => (teamRows ?? []).map((t) => t.key ?? t.id).filter((k) => typeof k === "string" && k !== "");
|
|
488
|
+
const recheckStep = () => {
|
|
489
|
+
const cmds = mappedKeys().map((k) => `catalyst team check ${k}`);
|
|
490
|
+
if (cmds.length > 0 && verbAvailable("team check")) return { action: `run ${cmds.join(" and ")}, then run this again`, owner: "you, the assistant: run it now with the person's login, without asking", where: cmds[0] };
|
|
491
|
+
if (cmds.length > 0 && verbAdminOnly("team check")) return { action: `a workspace owner or admin runs ${cmds.join(" and ")} (or presses Re-check on the projects page); then run this again`, owner: "a workspace owner or admin", where: link("/settings/linear-teams") };
|
|
492
|
+
return { action: "press Re-check on the projects page, then run this again", owner: "a workspace owner or admin", where: link("/settings/linear-teams") };
|
|
493
|
+
};
|
|
494
|
+
|
|
378
495
|
// ── host ──────────────────────────────────────────────────────────────────────────────────────────
|
|
379
496
|
// Read off the contract, never assumed: each checked project carries a hosts_current check, and the
|
|
380
497
|
// contract says who owns it. The check is account-wide, so any one project's reading is the answer.
|
|
381
498
|
// A tenant that runs no host of its own reads `pass` here, and then nothing is asked of anyone.
|
|
382
499
|
if (!connected) {
|
|
383
|
-
add("host", "hosts_current in catalyst
|
|
500
|
+
add("host", "hosts_current in catalyst contract --path teams", "unreadable", ["not readable until this machine is connected"], null, null);
|
|
384
501
|
} else {
|
|
385
502
|
const checks = (teamRows ?? []).flatMap((t) => (Array.isArray(t.readiness?.checks) ? t.readiness.checks : []).filter((c) => c.id === "hosts_current").map((c) => ({ ...c, team: t.key ?? t.id ?? "(unkeyed)" })));
|
|
386
|
-
const
|
|
387
|
-
const row = Array.isArray(meta) ? meta.find((r) => r.id === "hosts_current") : null;
|
|
503
|
+
const row = readinessMeta().find((r) => r.id === "hosts_current") ?? null;
|
|
388
504
|
const hc = checks.find((c) => c.state === "fail") ?? checks.find((c) => c.state === "unknown") ?? checks[0] ?? null;
|
|
389
505
|
// The contract's printed line for this owner is written for a host that is behind. When no host is
|
|
390
506
|
// connected there is nothing behind, so the owner is named by its id instead of by that sentence.
|
|
@@ -399,12 +515,81 @@ if (!connected) {
|
|
|
399
515
|
const how = fw === null ? null : link(fw.page);
|
|
400
516
|
const howLines = fw?.command ? [`the owner runs: ${fw.command}`] : [];
|
|
401
517
|
if (hc === null) {
|
|
402
|
-
|
|
518
|
+
const step = recheckStep();
|
|
519
|
+
add("host", "hosts_current in catalyst contract --path teams", "unreadable", [teamRows === null ? "the project list could not be read, so the host check cannot be either" : "no project has a readiness check yet, so whether a host is connected cannot be read."], teamRows === null ? "a workspace owner or admin" : step.owner, teamRows === null ? link("/settings/linear-teams") : step.where).next =
|
|
520
|
+
teamRows === null
|
|
521
|
+
? "refresh the contract (catalyst contract --refresh), then run this again; the project list could not be read, so the host check cannot be either"
|
|
522
|
+
: step.action;
|
|
403
523
|
} else if (hc.state === "pass") {
|
|
404
|
-
add("host", "hosts_current in catalyst
|
|
524
|
+
add("host", "hosts_current in catalyst contract --path teams", "ok", [`hosts_current pass (read on ${hc.team})`]);
|
|
405
525
|
} else {
|
|
406
526
|
const detail = hc.reason === "no_host_connected" ? "no Catalyst host is connected" : hc.reason === "hosts_behind" ? "a connected host runs an older mapping" : hc.reason === "hosts_unreported" ? "a host is connected but has not reported what it loaded" : `state ${hc.state}`;
|
|
407
|
-
add("host", "hosts_current in catalyst
|
|
527
|
+
add("host", "hosts_current in catalyst contract --path teams", "unfinished", [`hosts_current ${hc.state}${hc.reason ? ` (${hc.reason})` : ""} on ${hc.team}: ${detail}`, ...howLines], owner, how);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ── repository declarations ───────────────────────────────────────────────────────────────────────
|
|
532
|
+
// A repository's own settings live in its committed `.catalyst/catalyst.toml`. Whether the project's
|
|
533
|
+
// default repository has one in effect is the contract's `environment_declared` check; the project's
|
|
534
|
+
// other repositories appear under that check's `repos`. Names only: nothing here reads a value, and
|
|
535
|
+
// nothing here reads a file on this machine. A cloud that sends no such check has nothing to read.
|
|
536
|
+
const DECL_INSTRUMENT = "environment_declared in catalyst contract --path teams";
|
|
537
|
+
const DECL_REASONS = {
|
|
538
|
+
no_team_repo_default: { text: "no repository is the project's default yet: register one and make it the default", who: "a workspace owner or admin", page: "/settings/repositories" },
|
|
539
|
+
no_environment_declaration: { text: "no .catalyst/catalyst.toml on its default branch yet: write it with the person (names only, never a value), open a pull request, merge it", who: "the person, in the repository, with your help", page: null },
|
|
540
|
+
declaration_invalid: { text: "the committed .catalyst/catalyst.toml did not validate: fix the file (the ingest names the error) and merge the fix", who: "the person, in the repository, with your help", page: null },
|
|
541
|
+
declaration_read_failed: { text: "the committed .catalyst/catalyst.toml could not be read: fix the file (the ingest names the error) and merge the fix", who: "the person, in the repository, with your help", page: null },
|
|
542
|
+
declaration_awaiting_approval: { text: "the declaration is proposed and waits for approval: Settings → Repositories → the repository → Environment → Setup declaration → Approve this revision", who: "a workspace owner or admin", page: "/settings/repositories" },
|
|
543
|
+
};
|
|
544
|
+
const DECL_DO = "write .catalyst/catalyst.toml with references/declaring-a-repository.md, then open a pull request";
|
|
545
|
+
let declNext = "commit .catalyst/catalyst.toml to the repository and have an owner or admin approve it";
|
|
546
|
+
if (!connected) {
|
|
547
|
+
add("repository declarations", DECL_INSTRUMENT, "unreadable", ["not readable until this machine is connected"], null, null);
|
|
548
|
+
} else if (teamRows === null) {
|
|
549
|
+
add("repository declarations", DECL_INSTRUMENT, "unreadable", ["the project list could not be read, so no repository declaration can be either"], null, null).next =
|
|
550
|
+
"refresh the contract (catalyst contract --refresh), then run this again; the project list could not be read, so no repository declaration can be either";
|
|
551
|
+
} else if (teamRows.length === 0) {
|
|
552
|
+
add("repository declarations", DECL_INSTRUMENT, "unreadable", ["no project is mapped yet, so there is no repository whose declaration could be read"], null, null).next =
|
|
553
|
+
"map a project first; a repository declaration is read per project";
|
|
554
|
+
} else if (teamRows.every((t) => !Array.isArray(t.readiness?.checks) || t.readiness.checks.length === 0)) {
|
|
555
|
+
// Mapped, never checked: the contract carries no checks at all until someone presses Re-check, and
|
|
556
|
+
// that silence is not "nothing to declare".
|
|
557
|
+
const step = recheckStep();
|
|
558
|
+
add("repository declarations", DECL_INSTRUMENT, "unreadable", ["no project has a readiness check yet, so whether its repository is declared cannot be read."], step.owner, step.where).next = step.action;
|
|
559
|
+
} else {
|
|
560
|
+
const found = teamRows.flatMap((t) => (Array.isArray(t.readiness?.checks) ? t.readiness.checks : []).filter((c) => c.id === "environment_declared").map((c) => ({ ...c, team: t.key ?? t.id ?? "(unkeyed)" })));
|
|
561
|
+
const lines = [];
|
|
562
|
+
let verdict = "ok";
|
|
563
|
+
let owner = null;
|
|
564
|
+
let where = null;
|
|
565
|
+
let unknown = false;
|
|
566
|
+
const describe = (reason) => DECL_REASONS[reason] ?? { text: `environment_declared ${reason ?? "failed"} (a reason this bundle does not know; read it on the page)`, who: "a workspace owner or admin", page: "/settings/repositories" };
|
|
567
|
+
const flag = (team, repo, reason) => {
|
|
568
|
+
const r = describe(reason);
|
|
569
|
+
lines.push(`${team}${repo ? `, ${repo}` : ""}: ${r.text}`);
|
|
570
|
+
if (verdict === "ok") declNext = `${repo ? `${repo}: ` : ""}${r.text}`;
|
|
571
|
+
verdict = "unfinished";
|
|
572
|
+
owner ??= r.who;
|
|
573
|
+
where ??= r.page === null ? DECL_DO : link(r.page);
|
|
574
|
+
};
|
|
575
|
+
if (found.length === 0) {
|
|
576
|
+
lines.push("this cloud reports no repository declaration check for the mapped projects, so there is nothing to read here; the repository's Environment page is where one would show");
|
|
577
|
+
}
|
|
578
|
+
for (const c of found) {
|
|
579
|
+
if (c.state === "pass") lines.push(`${c.team}: a declaration is in effect for the project's default repository`);
|
|
580
|
+
else if (c.state === "fail") flag(c.team, null, c.reason);
|
|
581
|
+
else {
|
|
582
|
+
unknown = true;
|
|
583
|
+
lines.push(`${c.team}: environment_declared could not be read${c.reason ? ` (${c.reason})` : ""}; a re-check is needed`);
|
|
584
|
+
}
|
|
585
|
+
for (const note of Array.isArray(c.repos) ? c.repos : []) if (typeof note?.repo === "string" && note.reason) flag(c.team, note.repo, note.reason);
|
|
586
|
+
}
|
|
587
|
+
if (unknown && verdict === "ok") {
|
|
588
|
+
const step = recheckStep();
|
|
589
|
+
add("repository declarations", DECL_INSTRUMENT, "unreadable", lines, step.owner, step.where).next = step.action;
|
|
590
|
+
} else {
|
|
591
|
+
if (verdict !== "ok") lines.push("Names only: a value never passes through this script or the file. The person enters values on the repository's Environment page.");
|
|
592
|
+
add("repository declarations", DECL_INSTRUMENT, verdict, lines, verdict === "ok" ? null : owner, verdict === "ok" ? null : where);
|
|
408
593
|
}
|
|
409
594
|
}
|
|
410
595
|
|
|
@@ -424,12 +609,26 @@ const NEXT = {
|
|
|
424
609
|
: personalGithubIncomplete
|
|
425
610
|
? "install the tenant GitHub App and register its repository before connecting your personal GitHub account"
|
|
426
611
|
: "get this person's seat and Linear identity sorted",
|
|
427
|
-
account: "connect Linear
|
|
428
|
-
projects:
|
|
429
|
-
|
|
612
|
+
account: "connect the Linear integration on the Connections page (the GitHub App comes later, with the repository)",
|
|
613
|
+
projects: blockedProjects.length > 0
|
|
614
|
+
? blockedProjects.map((b) => b.action).join("; and ")
|
|
615
|
+
: verbAvailable("team map")
|
|
616
|
+
? "pick ONE project: run catalyst team list, then catalyst team map <KEY> (or team adopt <KEY>) and approve its preview"
|
|
617
|
+
: verbAdminOnly("team map")
|
|
618
|
+
? "a workspace owner or admin maps ONE project: catalyst team map <KEY>, or Map my stages on the projects page"
|
|
619
|
+
: "pick ONE project and map its stages (or adopt the Catalyst workflow)",
|
|
620
|
+
repositories: "install the GitHub App on the Connections page, granting it the repository you want worked; registering that repository on the Repositories page, attached to the project, is the step right after",
|
|
430
621
|
"coding accounts": accountsNext,
|
|
622
|
+
"repository declarations": declNext,
|
|
431
623
|
host: "connect a Catalyst host",
|
|
432
624
|
};
|
|
625
|
+
// The parts are read in the order their data allows and reported in the order a person can act on
|
|
626
|
+
// them: the coding account first (nothing runs without one, and it needs no other step), then the
|
|
627
|
+
// workspace's Linear integration, the project, the GitHub App with its repository, the person's own
|
|
628
|
+
// connected accounts (personal GitHub after the repository proves the App), the repository's
|
|
629
|
+
// declaration, and last the host. `--next` is the first unfinished part in this order.
|
|
630
|
+
const ORDER = ["machine", "coding accounts", "account", "projects", "repositories", "person", "repository declarations", "host"];
|
|
631
|
+
parts.sort((a, b) => ORDER.indexOf(a.part) - ORDER.indexOf(b.part));
|
|
433
632
|
// A personal Linear grant cannot start until the tenant's Linear workspace exists. Once that account
|
|
434
633
|
// connection is present, a missing personal grant becomes the next member step before project setup.
|
|
435
634
|
const personPart = parts.find((p) => p.part === "person");
|
|
@@ -441,16 +640,50 @@ if (personPart && personalGrantIncomplete && workspaceResolved) {
|
|
|
441
640
|
}
|
|
442
641
|
const blocked = parts.filter((p) => p.verdict !== "ok" && p.blocking);
|
|
443
642
|
const stuck = blocked[0] ?? parts.find((p) => p.verdict !== "ok") ?? null;
|
|
444
|
-
|
|
643
|
+
// ⛔ AN UNREADABLE PART NEVER BORROWS ITS "UNFINISHED" ACTION. NEXT holds what to do when a part was
|
|
644
|
+
// read and is not done. A part that could not be read has no such basis: its step is reading it
|
|
645
|
+
// again, and the parts after it depend on it, so none of theirs is named either. Coding accounts set
|
|
646
|
+
// their own unreadable step in `accountsNext`.
|
|
647
|
+
const actionFor = (p) =>
|
|
648
|
+
p.verdict === "unreadable" && p.part !== "coding accounts"
|
|
649
|
+
? (p.next ?? `run this again; the ${p.part} part could not be read, so no step after it can be named yet`)
|
|
650
|
+
: NEXT[p.part];
|
|
651
|
+
let next =
|
|
445
652
|
stuck === null
|
|
446
653
|
? null
|
|
447
|
-
: { part: stuck.part, action:
|
|
654
|
+
: { part: stuck.part, action: actionFor(stuck), owner: stuck.owner, where: stuck.where, blocking: stuck.blocking };
|
|
655
|
+
|
|
656
|
+
// A refused contract version outranks every step above: each of them was read from, or waits on, a
|
|
657
|
+
// contract this CLI would not accept. Whether an update exists is asked of npm, once, and only here.
|
|
658
|
+
function versionAdvice() {
|
|
659
|
+
const semver = (t) => (t ?? "").match(/\b(\d+)\.(\d+)\.(\d+)\b/);
|
|
660
|
+
const installed = semver(field("Bundle"));
|
|
661
|
+
const npm = spawnSync("npm", ["view", "@catalyst-cloud/cli", "version"], { encoding: "utf8", timeout: 10_000, shell: process.platform === "win32" });
|
|
662
|
+
const latest = npm.error || npm.status !== 0 ? null : semver(npm.stdout);
|
|
663
|
+
// The installer is the update path, not `catalyst install`. Its command is on the app's
|
|
664
|
+
// setup page; this names the script it fetches rather than composing the request here.
|
|
665
|
+
const reinstall = `re-run the install command from the app's setup page (it installs from ${link("/install.sh")})`;
|
|
666
|
+
if (installed === null || latest === null) {
|
|
667
|
+
return { text: `Whether a newer Catalyst CLI is published could not be checked. You can ${reinstall}; if this still appears afterwards, tell the Catalyst team.`, owner: null };
|
|
668
|
+
}
|
|
669
|
+
const order = [1, 2, 3].map((i) => Math.sign(Number(installed[i]) - Number(latest[i]))).find((d) => d !== 0) ?? 0;
|
|
670
|
+
return order >= 0
|
|
671
|
+
? { text: "A newer Catalyst CLI isn't published yet. Tell the Catalyst team; nothing on this machine needs to change.", owner: "the Catalyst team" }
|
|
672
|
+
: { text: `Update the CLI: ${reinstall}.`, owner: "you, on this machine" };
|
|
673
|
+
}
|
|
674
|
+
if (contractMismatch !== null && stuck !== null) {
|
|
675
|
+
const advice = versionAdvice();
|
|
676
|
+
const line = `the tenant serves contract ${contractMismatch.served} and this CLI accepts ${contractMismatch.accepts}. ${advice.text}`;
|
|
677
|
+
stuck.lines.push(line);
|
|
678
|
+
next = { part: stuck.part, action: line, owner: advice.owner, where: null, blocking: true };
|
|
679
|
+
}
|
|
448
680
|
const finished = parts.every((p) => p.verdict === "ok");
|
|
449
681
|
|
|
450
682
|
if (flags.json) {
|
|
451
|
-
console.log(JSON.stringify({ cli: via, connected, cloud, personalConnections, parts, localSync, next, finished }));
|
|
683
|
+
console.log(JSON.stringify({ cli: via, connected, cloud, personalConnections, parts, localSync, next, finished, notes: retireNote === null ? [] : [retireNote] }));
|
|
452
684
|
} else if (flags.next) {
|
|
453
|
-
if (
|
|
685
|
+
process.on("exit", () => { if (retireNote !== null) console.log(`note: ${retireNote}`); });
|
|
686
|
+
if (next === null) console.log("nothing left: every part is finished, a coding account is enrolled and the host check passes. Move one card into the project's start stage (usually Todo).");
|
|
454
687
|
else console.log(`${next.part}: ${next.action}${next.blocking ? "" : " (does not block the steps below)"}${next.owner ? ` — who: ${next.owner}` : ""}${next.where ? ` — ${next.where.startsWith("http") ? "where" : "do"}: ${next.where}` : ""}`);
|
|
455
688
|
} else {
|
|
456
689
|
for (const p of parts) {
|
|
@@ -463,6 +696,6 @@ if (flags.json) {
|
|
|
463
696
|
if (p.verdict !== "ok" && p.where) console.log(` ${p.where.startsWith("http") ? "where" : "do"}: ${p.where}`);
|
|
464
697
|
console.log("");
|
|
465
698
|
}
|
|
466
|
-
console.log(next === null ? "next: nothing left — move one card into the project's
|
|
699
|
+
console.log(next === null ? "next: nothing left — move one card into the project's start stage (usually Todo) and watch." : `next: ${next.part} — ${next.action}${next.owner ? ` (${next.owner})` : ""}${next.where ? ` — ${next.where}` : ""}`);
|
|
467
700
|
}
|
|
468
701
|
process.exit(finished ? 0 : 1);
|