@catalyst-cloud/cli 0.8.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 +75 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/bin/catalyst-skills.js +8 -0
- package/bin/catalyst.js +5 -0
- package/bin/launch.js +154 -0
- package/dist/args.js +280 -0
- package/dist/ask.js +161 -0
- package/dist/browser.js +20 -0
- package/dist/cli.js +397 -0
- package/dist/config.js +241 -0
- package/dist/contract-types.js +4 -0
- package/dist/contract.js +184 -0
- package/dist/detach.js +10 -0
- package/dist/environment.js +207 -0
- package/dist/errors.js +27 -0
- package/dist/events.js +106 -0
- package/dist/execution.js +451 -0
- package/dist/oauth.js +300 -0
- package/dist/pagination.js +76 -0
- package/dist/prompt.js +35 -0
- package/dist/published.js +79 -0
- package/dist/query.js +248 -0
- package/dist/ready.js +380 -0
- package/dist/release.js +142 -0
- package/dist/replica.js +614 -0
- package/dist/runtime-store.js +135 -0
- package/dist/runtime-verb.js +66 -0
- package/dist/runtime.js +87 -0
- package/dist/sdk.js +29 -0
- package/dist/secret.js +190 -0
- package/dist/semver.js +18 -0
- package/dist/skill-shape.js +189 -0
- package/dist/skills.js +129 -0
- package/dist/transport.js +205 -0
- package/dist/ts-deps-loader.js +113 -0
- package/dist/watch/consumer.js +141 -0
- package/dist/watch/cursor-file.js +62 -0
- package/dist/watch.js +175 -0
- package/dist/write.js +224 -0
- package/package.json +60 -0
- package/skills/catalyst-github/SKILL.md +35 -0
- package/skills/catalyst-github/agents/openai.yaml +6 -0
- package/skills/catalyst-github/agents/portability.yaml +4 -0
- package/skills/catalyst-github/references/is-it-mergeable.md +57 -0
- package/skills/catalyst-github/references/what-a-pr-accumulates.md +61 -0
- package/skills/catalyst-github/scripts/is-it-mergeable.mjs +124 -0
- package/skills/catalyst-github/scripts/lib/cli.mjs +103 -0
- package/skills/catalyst-github/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-github/scripts/lib/pull.mjs +82 -0
- package/skills/catalyst-github/scripts/read-pr.mjs +97 -0
- package/skills/catalyst-linear/SKILL.md +43 -0
- package/skills/catalyst-linear/agents/openai.yaml +6 -0
- package/skills/catalyst-linear/agents/portability.yaml +5 -0
- package/skills/catalyst-linear/references/reading-a-ticket.md +52 -0
- package/skills/catalyst-linear/references/what-a-ticket-accumulates.md +53 -0
- package/skills/catalyst-linear/references/writing-to-linear.md +43 -0
- package/skills/catalyst-linear/scripts/comment.mjs +59 -0
- package/skills/catalyst-linear/scripts/create-ticket.mjs +44 -0
- package/skills/catalyst-linear/scripts/label.mjs +48 -0
- package/skills/catalyst-linear/scripts/lib/cli.mjs +164 -0
- package/skills/catalyst-linear/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-linear/scripts/move.mjs +41 -0
- package/skills/catalyst-linear/scripts/read-ticket.mjs +93 -0
- package/skills/catalyst-linear/scripts/search.mjs +49 -0
- package/skills/catalyst-onboard/SKILL.md +57 -0
- package/skills/catalyst-onboard/agents/openai.yaml +6 -0
- package/skills/catalyst-onboard/agents/portability.yaml +5 -0
- package/skills/catalyst-onboard/references/declaring-a-repository.md +23 -0
- package/skills/catalyst-onboard/references/skill-sources.md +35 -0
- package/skills/catalyst-onboard/references/the-one-path.md +149 -0
- package/skills/catalyst-onboard/references/what-a-phase-needs.md +46 -0
- package/skills/catalyst-onboard/references/what-the-browser-owns.md +50 -0
- package/skills/catalyst-onboard/references/who-fixes-what.md +44 -0
- package/skills/catalyst-onboard/scripts/lib/cli.mjs +117 -0
- package/skills/catalyst-onboard/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-onboard/scripts/where-am-i.mjs +345 -0
- package/skills/catalyst-setup/SKILL.md +36 -0
- package/skills/catalyst-setup/agents/openai.yaml +6 -0
- package/skills/catalyst-setup/agents/portability.yaml +4 -0
- package/skills/catalyst-setup/references/what-each-check-means.md +88 -0
- package/skills/catalyst-setup/scripts/check.mjs +75 -0
- package/skills/catalyst-setup/scripts/lib/cli.mjs +103 -0
- package/skills/catalyst-setup/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-setup/scripts/replica-status.mjs +46 -0
- package/skills/connect-me/SKILL.md +63 -0
- package/skills/connect-me/agents/openai.yaml +6 -0
- package/skills/connect-me/agents/portability.yaml +5 -0
- package/skills/connect-me/references/keeping-the-replica-running.md +88 -0
- package/skills/connect-me/scripts/lib/cli.mjs +185 -0
- package/skills/connect-me/scripts/lib/credential.mjs +29 -0
- package/skills/connect-me/scripts/verify-connection.mjs +68 -0
- package/skills/how-catalyst-works/SKILL.md +43 -0
- package/skills/how-catalyst-works/agents/openai.yaml +6 -0
- package/skills/how-catalyst-works/agents/portability.yaml +4 -0
- package/skills/how-catalyst-works/references/coding-accounts.md +51 -0
- package/skills/how-catalyst-works/references/stages-and-mapping.md +56 -0
- package/skills/how-catalyst-works/references/the-ladder.md +41 -0
- package/skills/how-catalyst-works/references/what-catalyst-is.md +30 -0
- package/skills/how-catalyst-works/references/what-runs-next.md +77 -0
- package/skills/how-catalyst-works/references/when-a-phase-fails.md +57 -0
- package/skills/how-catalyst-works/scripts/explain-ticket.mjs +41 -0
- package/skills/how-catalyst-works/scripts/lib/cli.mjs +164 -0
- package/skills/how-catalyst-works/scripts/lib/credential.mjs +29 -0
- package/skills/how-catalyst-works/scripts/show-my-map.mjs +94 -0
- package/skills/how-catalyst-works/scripts/whats-running.mjs +65 -0
- package/skills/run-this-project/SKILL.md +45 -0
- package/skills/run-this-project/agents/openai.yaml +6 -0
- package/skills/run-this-project/agents/portability.yaml +5 -0
- package/skills/run-this-project/assets/stall-policy.json +15 -0
- package/skills/run-this-project/references/making-work-ready.md +60 -0
- package/skills/run-this-project/references/reacting-to-events.md +76 -0
- package/skills/run-this-project/references/stalls-and-escalation.md +63 -0
- package/skills/run-this-project/scripts/lib/cli.mjs +185 -0
- package/skills/run-this-project/scripts/lib/credential.mjs +29 -0
- package/skills/run-this-project/scripts/make-ready.mjs +64 -0
- package/skills/run-this-project/scripts/scope-status.mjs +0 -0
- package/skills/run-this-project/scripts/watch-scope.mjs +61 -0
- package/skills/unstick/SKILL.md +41 -0
- package/skills/unstick/agents/openai.yaml +6 -0
- package/skills/unstick/agents/portability.yaml +5 -0
- package/skills/unstick/references/playbook.md +51 -0
- package/skills/unstick/scripts/lib/cli.mjs +135 -0
- package/skills/unstick/scripts/lib/credential.mjs +29 -0
- package/skills/unstick/scripts/unstick.mjs +57 -0
- package/skills/what-needs-me/SKILL.md +41 -0
- package/skills/what-needs-me/agents/openai.yaml +6 -0
- package/skills/what-needs-me/agents/portability.yaml +5 -0
- package/skills/what-needs-me/references/raising-a-decision.md +41 -0
- package/skills/what-needs-me/references/reading-the-inbox.md +38 -0
- package/skills/what-needs-me/references/settling-an-answer.md +37 -0
- package/skills/what-needs-me/scripts/inbox.mjs +56 -0
- package/skills/what-needs-me/scripts/lib/cli.mjs +135 -0
- package/skills/what-needs-me/scripts/lib/credential.mjs +29 -0
- package/skills/what-needs-me/scripts/raise.mjs +53 -0
- package/skills/what-needs-me/scripts/settle.mjs +73 -0
- package/skills/whats-happening/SKILL.md +43 -0
- package/skills/whats-happening/agents/openai.yaml +6 -0
- package/skills/whats-happening/agents/portability.yaml +4 -0
- package/skills/whats-happening/assets/status-reply.json +77 -0
- package/skills/whats-happening/references/reading-the-board.md +43 -0
- package/skills/whats-happening/references/reprioritising.md +37 -0
- package/skills/whats-happening/references/routing-work.md +36 -0
- package/skills/whats-happening/references/status-reply.md +34 -0
- package/skills/whats-happening/references/why-is-it-stuck.md +62 -0
- package/skills/whats-happening/scripts/explain.mjs +28 -0
- package/skills/whats-happening/scripts/lib/cli.mjs +135 -0
- package/skills/whats-happening/scripts/lib/credential.mjs +29 -0
- package/skills/whats-happening/scripts/snapshot.mjs +149 -0
- package/vendor/README.md +9 -0
- package/vendor/paths/index.d.ts +85 -0
- package/vendor/paths/index.js +148 -0
- package/vendor/paths/legacy-installer.d.ts +36 -0
- package/vendor/paths/legacy-installer.js +154 -0
- package/vendor/paths/node.d.ts +18 -0
- package/vendor/paths/node.js +102 -0
- package/vendor/paths/provenance.json +17 -0
package/dist/query.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// query.ts — `query issues|issue|pulls|pull|projects|cycles|search|changes`: the replica when it is
|
|
2
|
+
// fresh, else the API, with the source named on stderr every time. Output shape is the read-model
|
|
3
|
+
// view either way. `pull`, `cycles`, `search` and `changes` are API-only (the SDK wraps no view for
|
|
4
|
+
// them and the read-model package is not a declared dependency of this bundle).
|
|
5
|
+
import { flagBool, flagInt, flagString, positionals } from "./args.js";
|
|
6
|
+
import { requireConfig, replicaDbPath } from "./config.js";
|
|
7
|
+
import { CliError, UsageError } from "./errors.js";
|
|
8
|
+
import { apiClient } from "./transport.js";
|
|
9
|
+
import { fetchAllPages, fetchPage, rowsOf, truncationNotice } from "./pagination.js";
|
|
10
|
+
import { engineFor, replicaStatus } from "./replica.js";
|
|
11
|
+
import { loadSdk } from "./sdk.js";
|
|
12
|
+
export { rowsOf };
|
|
13
|
+
const REPLICA_CAPABLE = new Set(["issues", "issue", "pulls", "projects"]);
|
|
14
|
+
/** The two keyset routes `--all` can follow. `projects`/`cycles`/`search` are offset-paged or
|
|
15
|
+
* unpaginated and send no cursor header. */
|
|
16
|
+
const PAGINATED = new Set(["issues", "pulls"]);
|
|
17
|
+
export async function cmdQuery(args, ctx, deps = {}) {
|
|
18
|
+
const [sub, ...rest] = positionals(args);
|
|
19
|
+
if (!sub)
|
|
20
|
+
throw new UsageError("query needs a subcommand: issues | issue <id> | pulls | pull <id> | projects | cycles | search <terms> | changes --since <n>");
|
|
21
|
+
const cfg = requireConfig(ctx);
|
|
22
|
+
const filters = {
|
|
23
|
+
team: flagString(args, "team"),
|
|
24
|
+
project: flagString(args, "project"),
|
|
25
|
+
state: flagString(args, "state"),
|
|
26
|
+
limit: flagInt(args, "limit", 50),
|
|
27
|
+
all: flagBool(args, "all"),
|
|
28
|
+
};
|
|
29
|
+
const forced = flagString(args, "source");
|
|
30
|
+
if (forced && forced !== "replica" && forced !== "api")
|
|
31
|
+
throw new UsageError("--source must be replica or api");
|
|
32
|
+
if (filters.all && !PAGINATED.has(sub)) {
|
|
33
|
+
throw new UsageError(`--all follows the cloud's keyset pages, which only ${[...PAGINATED].join(" and ")} have`);
|
|
34
|
+
}
|
|
35
|
+
if (filters.all && forced === "replica") {
|
|
36
|
+
throw new UsageError("--all follows the cloud's page cursor; --source replica reads a local mirror that has none. Pick one.");
|
|
37
|
+
}
|
|
38
|
+
if (filters.all && flagString(args, "limit") !== undefined) {
|
|
39
|
+
ctx.stderr("--all reads the whole scope, so --limit is ignored");
|
|
40
|
+
}
|
|
41
|
+
const status = replicaStatus(ctx, cfg);
|
|
42
|
+
let source;
|
|
43
|
+
let why;
|
|
44
|
+
if (!REPLICA_CAPABLE.has(sub)) {
|
|
45
|
+
source = "api";
|
|
46
|
+
why = `${sub} is api-only`;
|
|
47
|
+
}
|
|
48
|
+
else if (forced) {
|
|
49
|
+
source = forced;
|
|
50
|
+
why = `--source ${forced}`;
|
|
51
|
+
}
|
|
52
|
+
else if (filters.all) {
|
|
53
|
+
source = "api";
|
|
54
|
+
why = "--all follows the cloud's pages";
|
|
55
|
+
}
|
|
56
|
+
else if (status.verdict === "fresh") {
|
|
57
|
+
source = "replica";
|
|
58
|
+
why = `cursor ${status.cursor}`;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
source = "api";
|
|
62
|
+
why = `replica ${status.verdict}`;
|
|
63
|
+
}
|
|
64
|
+
ctx.stderr(source === "replica" ? `source: replica (${why})` : `source: api (${why})`);
|
|
65
|
+
let result;
|
|
66
|
+
if (source === "replica") {
|
|
67
|
+
if (status.verdict === "absent" || status.verdict === "not-configured") {
|
|
68
|
+
throw new UsageError(`--source replica but the replica is ${status.verdict}`);
|
|
69
|
+
}
|
|
70
|
+
result = await fromReplica(ctx, cfg, sub, rest, filters, deps);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
result = await fromApi(ctx, cfg, sub, rest, filters, args);
|
|
74
|
+
}
|
|
75
|
+
if (result === null) {
|
|
76
|
+
ctx.stderr(`${sub} ${rest[0] ?? ""}: not found`);
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
printResult(ctx, args, sub, result);
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
async function fromReplica(ctx, cfg, sub, rest, f, deps) {
|
|
83
|
+
const sdk = await loadSdk();
|
|
84
|
+
const dbPath = replicaDbPath(cfg, ctx.home, ctx.env);
|
|
85
|
+
const engine = await engineFor(sdk, dbPath, ctx, { ...deps.engineDeps, readonly: true });
|
|
86
|
+
const replica = await sdk.CatalystReplica.openReadOnly({ dbPath, engine, log: () => { } });
|
|
87
|
+
try {
|
|
88
|
+
switch (sub) {
|
|
89
|
+
case "issues": {
|
|
90
|
+
const rows = replica.issues({ limit: Math.max(f.limit, 200) });
|
|
91
|
+
return applyFilters(rows, f).slice(0, f.limit);
|
|
92
|
+
}
|
|
93
|
+
case "issue":
|
|
94
|
+
return replica.issue(needArg(rest, "issue <identifier>"));
|
|
95
|
+
case "pulls":
|
|
96
|
+
return replica.pulls({ limit: f.limit });
|
|
97
|
+
case "projects":
|
|
98
|
+
return applyFilters(replica.projects({ limit: Math.max(f.limit, 200) }), { limit: f.limit, state: f.state }).slice(0, f.limit);
|
|
99
|
+
default:
|
|
100
|
+
throw new UsageError(`${sub} is not readable from the replica`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
await replica.close();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async function fromApi(ctx, cfg, sub, rest, f, args) {
|
|
108
|
+
const api = apiClient(cfg, ctx);
|
|
109
|
+
const common = { team: f.team, project: f.project, state: f.state, limit: f.limit };
|
|
110
|
+
switch (sub) {
|
|
111
|
+
case "issues": {
|
|
112
|
+
if (f.all) {
|
|
113
|
+
const { rows } = await fetchAllPages(api, "/api/v1/issues", { team: f.team, project: f.project, state: f.state });
|
|
114
|
+
return applyFilters(rows, f); // no .slice — --all means the whole scope
|
|
115
|
+
}
|
|
116
|
+
const page = await fetchPage(api, "/api/v1/issues", common);
|
|
117
|
+
const rows = applyFilters(page.rows, f).slice(0, f.limit);
|
|
118
|
+
const notice = truncationNotice(page);
|
|
119
|
+
if (notice)
|
|
120
|
+
ctx.stderr(notice);
|
|
121
|
+
return rows;
|
|
122
|
+
}
|
|
123
|
+
case "issue": {
|
|
124
|
+
const id = needArg(rest, "issue <identifier>");
|
|
125
|
+
const res = await api.getJson(`/api/v1/issues/${encodeURIComponent(id)}`, { accept: [404] });
|
|
126
|
+
return res.status === 404 ? null : res.body;
|
|
127
|
+
}
|
|
128
|
+
case "pulls": {
|
|
129
|
+
const ticket = flagString(args, "ticket");
|
|
130
|
+
if (f.all) {
|
|
131
|
+
const { rows } = await fetchAllPages(api, "/api/v1/pulls", { ticket });
|
|
132
|
+
return rows;
|
|
133
|
+
}
|
|
134
|
+
const page = await fetchPage(api, "/api/v1/pulls", { limit: f.limit, ticket });
|
|
135
|
+
const rows = page.rows.slice(0, f.limit);
|
|
136
|
+
const notice = truncationNotice(page);
|
|
137
|
+
if (notice)
|
|
138
|
+
ctx.stderr(notice);
|
|
139
|
+
return rows;
|
|
140
|
+
}
|
|
141
|
+
case "pull": {
|
|
142
|
+
const id = needArg(rest, "pull <node id>");
|
|
143
|
+
const res = await api.getJson(`/api/v1/pulls/${encodeURIComponent(id)}`, { accept: [404] });
|
|
144
|
+
return res.status === 404 ? null : res.body;
|
|
145
|
+
}
|
|
146
|
+
case "projects": {
|
|
147
|
+
const res = await api.getJson("/api/v1/projects", { query: { limit: f.limit } });
|
|
148
|
+
return rowsOf(res.body).slice(0, f.limit);
|
|
149
|
+
}
|
|
150
|
+
case "cycles": {
|
|
151
|
+
const res = await api.getJson("/api/v1/cycles", { query: { team: f.team, limit: f.limit } });
|
|
152
|
+
return rowsOf(res.body);
|
|
153
|
+
}
|
|
154
|
+
case "search": {
|
|
155
|
+
const q = rest.join(" ").trim();
|
|
156
|
+
if (!q)
|
|
157
|
+
throw new UsageError("search needs terms: query search <terms>");
|
|
158
|
+
const res = await api.getJson("/api/v1/search", { query: { q, limit: f.limit } });
|
|
159
|
+
return rowsOf(res.body);
|
|
160
|
+
}
|
|
161
|
+
case "changes": {
|
|
162
|
+
const since = flagString(args, "since");
|
|
163
|
+
if (since === undefined)
|
|
164
|
+
throw new UsageError("changes needs --since <cursor>, or --since head to start from now");
|
|
165
|
+
return await readChanges(api, since, f.limit);
|
|
166
|
+
}
|
|
167
|
+
default:
|
|
168
|
+
throw new UsageError(`unknown query subcommand: ${sub}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** CTC-137 stamps the live head seq on EVERY `/changes` response — the 200 stream and both 409s. */
|
|
172
|
+
const HEAD_SEQ_HEADER = "x-catalyst-head-seq";
|
|
173
|
+
/**
|
|
174
|
+
* `query changes --since <cursor|head>`.
|
|
175
|
+
*
|
|
176
|
+
* ⛔ THE CHANGEFEED EVICTS, so a refusal must name a cursor that works. `buildChanges` answers a
|
|
177
|
+
* `since` below the oldest retained seq with 409 `cursor_underflow`, and one past the head with 409
|
|
178
|
+
* `cursor_ahead_of_head` — both `{resync: true}`. `--since 0`, the form the docs show, is therefore
|
|
179
|
+
* a 409 on any tenant whose log has rotated, and 0.2.0 surfaced it as a bare "GET /api/v1/changes
|
|
180
|
+
* failed (409)" that left the customer with no way to learn a usable cursor. The head is stamped on
|
|
181
|
+
* the refusal itself, so there is always something actionable to say.
|
|
182
|
+
*/
|
|
183
|
+
async function readChanges(api, since, limit) {
|
|
184
|
+
const resolved = since === "head" ? String(await headCursor(api)) : since;
|
|
185
|
+
// ⛔ NDJSON ON 200, JSON ON A REFUSAL — see `getNdjson`. Reading this with `getJson` turned every
|
|
186
|
+
// real success into "returned a non-JSON body" while every refusal parsed, which is why the verb
|
|
187
|
+
// looked fine: `--since 0` 409s on a rotated feed, so no 200 was ever reached to fail on.
|
|
188
|
+
const res = await api.getNdjson("/api/v1/changes", {
|
|
189
|
+
query: { since: resolved, limit },
|
|
190
|
+
accept: [409],
|
|
191
|
+
});
|
|
192
|
+
const head = res.headers.get(HEAD_SEQ_HEADER);
|
|
193
|
+
if (res.status !== 409) {
|
|
194
|
+
return { since: Number(resolved), head: head === null ? null : Number(head), changes: res.body };
|
|
195
|
+
}
|
|
196
|
+
const where = head === null ? "" : ` The feed's live cursor is ${head}.`;
|
|
197
|
+
throw new CliError(`cursor ${resolved} is not usable: it is either past the feed's head or no longer in the change log, which keeps only recent changes.${where} Re-run with \`--since head\` to start from now, or with a cursor the feed still holds.`, "changefeed-resync", 1, 409);
|
|
198
|
+
}
|
|
199
|
+
/** The live head, read off any `/changes` response's own header — a 409 carries it too, which is
|
|
200
|
+
* what makes this work on a tenant whose log has rotated past every cursor the caller could guess. */
|
|
201
|
+
async function headCursor(api) {
|
|
202
|
+
const probe = await api.getNdjson("/api/v1/changes", { query: { since: "0", limit: 1 }, accept: [409] });
|
|
203
|
+
const raw = probe.headers.get(HEAD_SEQ_HEADER);
|
|
204
|
+
const head = raw === null ? Number.NaN : Number(raw);
|
|
205
|
+
if (!Number.isFinite(head)) {
|
|
206
|
+
throw new CliError(`--since head needs the feed's live cursor, and this cloud did not send one (no ${HEAD_SEQ_HEADER} header). Pass an explicit --since <cursor>.`, "changefeed-head-unknown", 1);
|
|
207
|
+
}
|
|
208
|
+
return head;
|
|
209
|
+
}
|
|
210
|
+
function needArg(rest, usage) {
|
|
211
|
+
const v = rest[0];
|
|
212
|
+
if (!v)
|
|
213
|
+
throw new UsageError(`query ${usage}`);
|
|
214
|
+
return v;
|
|
215
|
+
}
|
|
216
|
+
export function applyFilters(rows, f) {
|
|
217
|
+
return rows.filter((r) => {
|
|
218
|
+
if (f.team && !(String(r.identifier ?? "").toUpperCase().startsWith(`${f.team.toUpperCase()}-`) || r.team_id === f.team || r.team_key === f.team))
|
|
219
|
+
return false;
|
|
220
|
+
if (f.project && r.project_id !== f.project)
|
|
221
|
+
return false;
|
|
222
|
+
if (f.state && String(r.state ?? "").toLowerCase() !== f.state.toLowerCase())
|
|
223
|
+
return false;
|
|
224
|
+
return true;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
function printResult(ctx, args, sub, result) {
|
|
228
|
+
if (args.json || !Array.isArray(result)) {
|
|
229
|
+
ctx.stdout(JSON.stringify(result, null, args.json ? 0 : 2));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
for (const row of result)
|
|
233
|
+
ctx.stdout(summaryLine(sub, row));
|
|
234
|
+
}
|
|
235
|
+
export function summaryLine(sub, r) {
|
|
236
|
+
switch (sub) {
|
|
237
|
+
case "issues":
|
|
238
|
+
return `${r.identifier ?? r.id} ${r.state ?? "?"} ${r.title ?? ""}${r.assignee_name || r.assignee ? ` (${r.assignee_name ?? r.assignee})` : ""}`;
|
|
239
|
+
case "pulls":
|
|
240
|
+
return `#${r.number ?? "?"} ${r.state ?? "?"}${r.merged ? " merged" : ""} ${r.title ?? ""} [${r.node_id ?? ""}]`;
|
|
241
|
+
case "projects":
|
|
242
|
+
return `${r.id} ${r.state ?? "?"} ${r.name ?? ""}`;
|
|
243
|
+
case "cycles":
|
|
244
|
+
return `cycle ${r.number ?? r.id} ${r.name ?? ""} ${r.starts_at ?? ""} → ${r.ends_at ?? ""}`;
|
|
245
|
+
default:
|
|
246
|
+
return JSON.stringify(r);
|
|
247
|
+
}
|
|
248
|
+
}
|
package/dist/ready.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// ready.ts — `ready`: the machine checks plus the tenant's own readiness vector from the contract,
|
|
2
|
+
// one verdict, and per failure the fix and who can apply it. The replica is optional, so its absence
|
|
3
|
+
// is a note, never a failure; the SDK loading IS a check, because the replica and watch need it.
|
|
4
|
+
import { accessSync, constants, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
5
|
+
import { basename, delimiter, dirname, join } from "node:path";
|
|
6
|
+
import { LEGACY_PACKAGE_NAME, PACKAGE_NAME, defaultSkillsDirFor, loadConfig, readManifest, upgradeCommand } from "./config.js";
|
|
7
|
+
import { contractVersionInRange, readContractCache } from "./contract.js";
|
|
8
|
+
import { CliError } from "./errors.js";
|
|
9
|
+
import { latestPublishedVersion } from "./published.js";
|
|
10
|
+
import { replicaStatus, writerIsRunning } from "./replica.js";
|
|
11
|
+
import { loadSdk } from "./sdk.js";
|
|
12
|
+
import { FIX_COMMAND, detectRuntime, runtimeVerdict, supportedRangeText } from "./runtime.js";
|
|
13
|
+
import { semverOlder } from "./semver.js";
|
|
14
|
+
import { FIRST_STAMPED_VERSION } from "./skill-shape.js";
|
|
15
|
+
import { installedBundleVersion } from "./skills.js";
|
|
16
|
+
export { semverOlder };
|
|
17
|
+
/** `ready`'s own wording for the replica. It never recommends adopting the replica — until the
|
|
18
|
+
* mirror's snapshot path is safe for a large tenant, a customer following that advice is the
|
|
19
|
+
* failure mode. It DOES name the restart command when the writer gave up, because that is an
|
|
20
|
+
* instruction about a thing already running, not a nudge to adopt one (CTC-2499). */
|
|
21
|
+
function readyReplicaLine(s) {
|
|
22
|
+
const w = s.writer;
|
|
23
|
+
if (w?.stopped) {
|
|
24
|
+
return (`replica: the writer stopped ${new Date(w.stopped.at).toISOString()}: ${w.stopped.reason} ` +
|
|
25
|
+
`(last error: ${w.lastError}) — the replica is optional and every read ` +
|
|
26
|
+
`still works through the API; restart it with: ${w.stopped.restartWith}`);
|
|
27
|
+
}
|
|
28
|
+
if (w && w.consecutiveFailures > 0) {
|
|
29
|
+
// Present tense only for a writer that is actually running: the record survives a SIGKILL, and
|
|
30
|
+
// "is backing off" about a dead process promises a retry that will never come (CTC-2499).
|
|
31
|
+
if (!writerIsRunning(s)) {
|
|
32
|
+
return `replica: the writer recorded ${w.consecutiveFailures} failed snapshot pulls and is no longer running (last error: ${w.lastError}) — the replica is optional and every read still works through the API`;
|
|
33
|
+
}
|
|
34
|
+
return `replica: the writer has failed ${w.consecutiveFailures} snapshot pulls in a row and is backing off (last error: ${w.lastError}) — reads fall back to the API`;
|
|
35
|
+
}
|
|
36
|
+
switch (s.verdict) {
|
|
37
|
+
case "not-configured":
|
|
38
|
+
return "replica: not configured — run login first";
|
|
39
|
+
case "absent":
|
|
40
|
+
return `replica: absent at ${s.dbPath} — optional, and off by default for large tenants while the snapshot path is being made safe; every read works through the API`;
|
|
41
|
+
case "fresh":
|
|
42
|
+
return `replica: fresh at ${s.dbPath} (cursor ${s.cursor}, heartbeat ${s.heartbeatAgeMs}ms ago${s.lag !== undefined ? `, ${s.lag} behind head ${s.head}` : ""})`;
|
|
43
|
+
case "stale":
|
|
44
|
+
return `replica: stale at ${s.dbPath} (${s.reasons.join("; ")}${s.cursor !== null ? `; cursor ${s.cursor}` : ""}) — reads fall back to the API`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** The first executable file called `name` on `env.PATH`, or null. */
|
|
48
|
+
export function firstOnPath(name, env) {
|
|
49
|
+
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
|
50
|
+
if (dir === "")
|
|
51
|
+
continue;
|
|
52
|
+
const candidate = join(dir, name);
|
|
53
|
+
try {
|
|
54
|
+
accessSync(candidate, constants.X_OK);
|
|
55
|
+
if (statSync(candidate).isFile())
|
|
56
|
+
return candidate;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// not here, or not executable: keep looking, as a shell would
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
/** True when `path` resolves to the `catalyst` launcher of this CLI or of its forwarder package. */
|
|
65
|
+
export function isThisCliLauncher(path) {
|
|
66
|
+
let real;
|
|
67
|
+
try {
|
|
68
|
+
real = realpathSync(path);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
if (basename(real) !== "catalyst.js" || basename(dirname(real)) !== "bin")
|
|
74
|
+
return false;
|
|
75
|
+
try {
|
|
76
|
+
const raw = JSON.parse(readFileSync(join(dirname(dirname(real)), "package.json"), "utf8"));
|
|
77
|
+
return raw.name === PACKAGE_NAME || raw.name === LEGACY_PACKAGE_NAME;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* CTC-3479 — the name `catalyst` is also the catalyst-dev router, which install-cli.sh links into
|
|
85
|
+
* ~/.catalyst/bin and puts at the front of PATH. Where it wins, `catalyst ready` runs the router, not
|
|
86
|
+
* this CLI. `ready` cannot fix that, but it can say so: a note (never a failure, because
|
|
87
|
+
* `catalyst-skills` still reaches this CLI) naming the program that answers to `catalyst`. Nothing
|
|
88
|
+
* on PATH is normal while the old name is still the one installed, so that emits nothing. POSIX
|
|
89
|
+
* only: on Windows npm installs `.cmd` shims, and PATHEXT lookup is not worth guessing at here.
|
|
90
|
+
*/
|
|
91
|
+
export function catalystCommandCheck(env, platform = process.platform) {
|
|
92
|
+
if (platform === "win32")
|
|
93
|
+
return null;
|
|
94
|
+
const found = firstOnPath("catalyst", env);
|
|
95
|
+
if (found === null)
|
|
96
|
+
return null;
|
|
97
|
+
if (isThisCliLauncher(found))
|
|
98
|
+
return { id: "command", ok: true, line: `command: catalyst on PATH is this CLI (${found})` };
|
|
99
|
+
return {
|
|
100
|
+
id: "command",
|
|
101
|
+
ok: false,
|
|
102
|
+
note: true,
|
|
103
|
+
line: `command: catalyst on PATH is ${found}, a different program with the same name, so \`catalyst <verb>\` does not reach this CLI. ` +
|
|
104
|
+
"Keep using catalyst-skills on this machine, or put the npm global bin directory ahead of that one on PATH.",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function nameList(v) {
|
|
108
|
+
return Array.isArray(v) ? v.filter((n) => typeof n === "string" && n.length > 0) : [];
|
|
109
|
+
}
|
|
110
|
+
/** Entries whose name and references are both present; anything malformed is dropped, not guessed. */
|
|
111
|
+
function unresolvedList(v) {
|
|
112
|
+
if (!Array.isArray(v))
|
|
113
|
+
return [];
|
|
114
|
+
return v.flatMap((u) => {
|
|
115
|
+
if (typeof u !== "object" || u === null)
|
|
116
|
+
return [];
|
|
117
|
+
const { name, references } = u;
|
|
118
|
+
const refs = nameList(references);
|
|
119
|
+
return typeof name === "string" && name.length > 0 && refs.length > 0 ? [{ name, references: refs }] : [];
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
function unresolvedLine(u) {
|
|
123
|
+
const which = u.references.length === 1 ? "which has" : "which have";
|
|
124
|
+
return `${u.name} references ${u.references.join(", ")}, ${which} no value; the checkout refuses it before work starts`;
|
|
125
|
+
}
|
|
126
|
+
/** A team check's fix line. A check that carries `names` (CTC-3561: `required_values`, contract
|
|
127
|
+
* 1.24.0) names them and where to set them. CTC-3606: it also names each `unresolved` variable and
|
|
128
|
+
* the reference with no value, and each other repository's missing names from `repos[]`. `unresolved`
|
|
129
|
+
* is a subset of `names` (the variable exists; its reference does not), so those are not told to be
|
|
130
|
+
* "set". Every field is optional so an older cloud still works. Everything printed is a declared
|
|
131
|
+
* identifier or a repository name, never a value, and nothing here reads a value. Any other check
|
|
132
|
+
* keeps the generic line. */
|
|
133
|
+
function teamCheckFix(label, c) {
|
|
134
|
+
const unresolved = unresolvedList(c.unresolved);
|
|
135
|
+
const unresolvedNames = new Set(unresolved.map((u) => u.name));
|
|
136
|
+
const names = nameList(c.names).filter((n) => !unresolvedNames.has(n));
|
|
137
|
+
const parts = [];
|
|
138
|
+
if (names.length > 0) {
|
|
139
|
+
parts.push(`set ${names.join(", ")} on the repository's Environment page under Settings → Repositories ` +
|
|
140
|
+
`(team ${label}; ${names.length === 1 ? "it has" : "they have"} no value at repository or account scope)`);
|
|
141
|
+
}
|
|
142
|
+
for (const u of unresolved)
|
|
143
|
+
parts.push(unresolvedLine(u));
|
|
144
|
+
for (const note of Array.isArray(c.repos) ? c.repos : []) {
|
|
145
|
+
if (typeof note !== "object" || note === null || typeof note.repo !== "string" || note.repo.length === 0)
|
|
146
|
+
continue;
|
|
147
|
+
const repoUnresolved = unresolvedList(note.unresolved);
|
|
148
|
+
const skip = new Set(repoUnresolved.map((u) => u.name));
|
|
149
|
+
const missing = nameList(note.names).filter((n) => !skip.has(n));
|
|
150
|
+
if (missing.length > 0)
|
|
151
|
+
parts.push(`${note.repo} is missing ${missing.join(", ")}`);
|
|
152
|
+
for (const u of repoUnresolved)
|
|
153
|
+
parts.push(`in ${note.repo}, ${unresolvedLine(u)}`);
|
|
154
|
+
}
|
|
155
|
+
if (parts.length === 0)
|
|
156
|
+
return `open settings for team ${label} and resolve ${c.id}`;
|
|
157
|
+
return parts.join(". ");
|
|
158
|
+
}
|
|
159
|
+
function whoCanAnswer(doc) {
|
|
160
|
+
const roles = doc.humans.map((h) => `${h.role} ${h.linearUserId}`);
|
|
161
|
+
return roles.length ? roles.join(", ") : "a tenant owner or admin (none resolved on the contract)";
|
|
162
|
+
}
|
|
163
|
+
export async function readyReport(ctx, deps) {
|
|
164
|
+
const checks = [];
|
|
165
|
+
const facts = deps.runtime ?? detectRuntime();
|
|
166
|
+
const verdict = runtimeVerdict(facts, readManifest().enginesNode);
|
|
167
|
+
checks.push({
|
|
168
|
+
id: "runtime",
|
|
169
|
+
ok: verdict.supported,
|
|
170
|
+
line: verdict.line,
|
|
171
|
+
...(verdict.supported ? {} : { fix: verdict.fix, who: "you" }),
|
|
172
|
+
});
|
|
173
|
+
let cfg = null;
|
|
174
|
+
try {
|
|
175
|
+
cfg = loadConfig(ctx.home);
|
|
176
|
+
checks.push(cfg
|
|
177
|
+
? {
|
|
178
|
+
id: "config",
|
|
179
|
+
ok: true,
|
|
180
|
+
// `principal` is "service" for every api key (it means "a key, not a browser session"); it
|
|
181
|
+
// never names a person. Since CTC-2076 a personal key's /me carries a `user` block, so name
|
|
182
|
+
// the connected person when there is one, and fall back to the account for a host key.
|
|
183
|
+
line: cfg.user
|
|
184
|
+
? `config: joined ${cfg.name} as ${cfg.user.label} (${cfg.user.role})`
|
|
185
|
+
: `config: joined ${cfg.name} (${cfg.slug}) as ${cfg.principal}`,
|
|
186
|
+
}
|
|
187
|
+
: { id: "config", ok: false, line: "config: not connected", fix: "npx @catalyst-cloud/catalyst-skills login (keyless; or pass --key / set CATALYST_CLOUD_TOKEN)", who: "you (approve the login in your browser)" });
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
checks.push({ id: "config", ok: false, line: `config: ${err instanceof CliError ? err.message : String(err)}`, fix: "re-run login to rewrite it", who: "you" });
|
|
191
|
+
}
|
|
192
|
+
const cache = readContractCache(ctx.home);
|
|
193
|
+
const range = readManifest().tenantContractRange;
|
|
194
|
+
if (!cache) {
|
|
195
|
+
checks.push({ id: "contract", ok: false, line: "contract: not cached", fix: "catalyst-skills contract --refresh", who: "you" });
|
|
196
|
+
}
|
|
197
|
+
else if (contractVersionInRange(cache.contractVersion, range) !== true) {
|
|
198
|
+
checks.push({ id: "contract", ok: false, line: `contract: version ${cache.contractVersion} is outside this bundle's range ${range}`, fix: upgradeCommand(), who: "you" });
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
checks.push({ id: "contract", ok: true, line: `contract: ${cache.contractVersion} cached ${cache.fetchedAt} (range ${range})` });
|
|
202
|
+
}
|
|
203
|
+
// The cloud MAY publish the bundle it expects (catalyst-cloud#3746). Read it defensively: warn (never
|
|
204
|
+
// refuse) when this installed bundle is older than the minimum, and emit nothing when the field is
|
|
205
|
+
// absent (an older cloud) — a stale bundle is a degrading note, so `ready` still returns READY.
|
|
206
|
+
const minVersion = cache?.doc.skillsBundle?.minVersion;
|
|
207
|
+
if (minVersion) {
|
|
208
|
+
const installed = readManifest().version;
|
|
209
|
+
if (semverOlder(installed, minVersion)) {
|
|
210
|
+
checks.push({
|
|
211
|
+
id: "bundle",
|
|
212
|
+
ok: false,
|
|
213
|
+
note: true,
|
|
214
|
+
line: `bundle: ${installed} installed is older than the tenant's minimum ${minVersion} — upgrade: ${upgradeCommand()}`,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// CTC-2160 — the two artefacts a customer installs drift apart: the CLI comes from npm, the skill
|
|
219
|
+
// files come from `npx skills add` (GitHub) or the plugin, and only the CLI's own version was ever
|
|
220
|
+
// knowable here. Both are NOTES: being behind a publish is a degrading fact about an install that
|
|
221
|
+
// still works, so `ready` stays READY. This is the one place `ready` touches the network, and it is
|
|
222
|
+
// capped, cached and skippable — see src/published.ts.
|
|
223
|
+
const skillsDir = cfg?.skillsDir ?? defaultSkillsDirFor(ctx.home);
|
|
224
|
+
const offline = deps.offline === true || ctx.env.CATALYST_SKILLS_OFFLINE === "1";
|
|
225
|
+
if (!offline) {
|
|
226
|
+
const pub = await (deps.fetchLatestRelease ?? (() => latestPublishedVersion(ctx)))();
|
|
227
|
+
if (pub.latest === null) {
|
|
228
|
+
checks.push({
|
|
229
|
+
id: "cliRelease",
|
|
230
|
+
ok: false,
|
|
231
|
+
note: true,
|
|
232
|
+
line: `cliRelease: could not check for a newer release (${pub.reason}) — nothing about this install is known to be wrong; re-run when the network is back`,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
// clause 2 — the CLI. Suppressed when the tenant-minimum note already named this binary (D9).
|
|
237
|
+
const installedCli = readManifest().version;
|
|
238
|
+
const bundleNoteFired = checks.some((c) => c.id === "bundle");
|
|
239
|
+
if (!bundleNoteFired && semverOlder(installedCli, pub.latest)) {
|
|
240
|
+
checks.push({
|
|
241
|
+
id: "cliRelease",
|
|
242
|
+
ok: false,
|
|
243
|
+
note: true,
|
|
244
|
+
line: `cliRelease: ${installedCli} installed is older than the latest published ${pub.latest} — upgrade: ${upgradeCommand()}`,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
// clause 1 — the skill files on disk, read independently of whichever CLI is answering. Both
|
|
248
|
+
// facts can hold at once: a machine that pulled at different hours carries some skills stamped
|
|
249
|
+
// behind AND some old enough to carry no stamp at all — the field report's own mixed install.
|
|
250
|
+
// So they are ADDITIVE, not exclusive: an unstamped file's real version is unknown and may be
|
|
251
|
+
// older than any stamp, which is why the stamped half says "oldest STAMPED" and the unstamped
|
|
252
|
+
// names are always printed rather than hidden behind whichever branch happened to win.
|
|
253
|
+
const found = installedBundleVersion(skillsDir, deps.skillNames);
|
|
254
|
+
const stampBehind = found.version !== null && semverOlder(found.version, pub.latest);
|
|
255
|
+
const unstampedBehind = found.unstamped.length > 0 && !semverOlder(pub.latest, FIRST_STAMPED_VERSION);
|
|
256
|
+
if (stampBehind || unstampedBehind) {
|
|
257
|
+
const facts = [];
|
|
258
|
+
if (stampBehind)
|
|
259
|
+
facts.push(`the oldest stamped skill is ${found.version} (${found.skill})`);
|
|
260
|
+
if (unstampedBehind) {
|
|
261
|
+
const one = found.unstamped.length === 1;
|
|
262
|
+
facts.push(`${found.unstamped.join(", ")} ${one ? "carries" : "carry"} no version and so ${one ? "predates" : "predate"} ${FIRST_STAMPED_VERSION}`);
|
|
263
|
+
}
|
|
264
|
+
checks.push({
|
|
265
|
+
id: "skillsRelease",
|
|
266
|
+
ok: false,
|
|
267
|
+
note: true,
|
|
268
|
+
line: `skillsRelease: in ${skillsDir} ${facts.join(", and ")}, while the published bundle is ${pub.latest} — update: npx skills update -y`,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (cfg) {
|
|
274
|
+
checks.push(cfg.cliPath && existsSync(cfg.cliPath)
|
|
275
|
+
? { id: "cliPath", ok: true, line: `cliPath: ${cfg.cliPath}` }
|
|
276
|
+
: { id: "cliPath", ok: false, line: `cliPath: ${cfg.cliPath ? `${cfg.cliPath} does not exist` : "not recorded"}`, fix: "re-run login so the skill scripts can find this CLI", who: "you" });
|
|
277
|
+
}
|
|
278
|
+
const command = catalystCommandCheck(ctx.env);
|
|
279
|
+
if (command)
|
|
280
|
+
checks.push(command);
|
|
281
|
+
// The skills are installed by the customer's own agent (a plugin, or `npx skills add`), so an
|
|
282
|
+
// empty copy directory is the normal case and must not read as NOT READY. A PARTIAL copy is the
|
|
283
|
+
// one broken state this check can see: half a set this package put there and never finished.
|
|
284
|
+
const present = deps.skillNames.filter((n) => existsSync(join(skillsDir, n, "SKILL.md")));
|
|
285
|
+
const missing = deps.skillNames.filter((n) => !present.includes(n));
|
|
286
|
+
if (present.length === 0) {
|
|
287
|
+
checks.push({ id: "skills", ok: true, note: true, line: `skills: none copied to ${skillsDir} — you are reading one, so your agent installed them its own way` });
|
|
288
|
+
}
|
|
289
|
+
else if (missing.length === 0) {
|
|
290
|
+
checks.push({ id: "skills", ok: true, line: `skills: all ${deps.skillNames.length} present in ${skillsDir}` });
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
checks.push({ id: "skills", ok: false, line: `skills: ${present.length} of ${deps.skillNames.length} in ${skillsDir}, missing ${missing.join(", ")}`, fix: "catalyst-skills install", who: "you" });
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
await (deps.loadSdk ?? loadSdk)();
|
|
297
|
+
checks.push({ id: "sdk", ok: true, line: "sdk: loads (replica and watch are available)" });
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
checks.push({
|
|
301
|
+
id: "sdk",
|
|
302
|
+
ok: false,
|
|
303
|
+
line: `sdk: ${err instanceof Error ? err.message : String(err)}`,
|
|
304
|
+
fix: `${verdict.fix ?? FIX_COMMAND} — supported: ${supportedRangeText(readManifest().enginesNode)}; every read still works through the API meanwhile`,
|
|
305
|
+
who: "you",
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
const replica = replicaStatus(ctx, cfg);
|
|
309
|
+
checks.push({ id: "replica", ok: replica.verdict === "fresh", note: true, line: readyReplicaLine(replica) });
|
|
310
|
+
if (cache) {
|
|
311
|
+
const doc = cache.doc;
|
|
312
|
+
const who = whoCanAnswer(doc);
|
|
313
|
+
for (const team of doc.teams) {
|
|
314
|
+
const label = team.key ?? team.id;
|
|
315
|
+
// The team's dispatch gate, straight off the cached contract: an older cloud omits it and this
|
|
316
|
+
// emits nothing (the `skillsBundle` precedent above). A shut gate means NOTHING in the team can
|
|
317
|
+
// start, so it is a FAIL carrying the cloud's own remedy as the fix — never an informational
|
|
318
|
+
// note. It is emitted before the `unchecked` branch below so a team whose readiness was never
|
|
319
|
+
// checked still reports its gate, which is exactly the team most likely to be unmapped.
|
|
320
|
+
const dg = team.dispatchGate;
|
|
321
|
+
if (dg && typeof dg.status === "string") {
|
|
322
|
+
const slots = (dg.missingSlots ?? []).join(", ");
|
|
323
|
+
checks.push(dg.status === "open"
|
|
324
|
+
? { id: `team:${label}:dispatchGate`, ok: true, line: `team ${label}: dispatch gate open` }
|
|
325
|
+
: {
|
|
326
|
+
id: `team:${label}:dispatchGate`,
|
|
327
|
+
ok: false,
|
|
328
|
+
line: `team ${label}: dispatch gate ${dg.status}${slots ? ` (${slots})` : ""}, blocking`,
|
|
329
|
+
fix: dg.remedy ?? `open settings for team ${label} and map its stages`,
|
|
330
|
+
who,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
if (team.readiness.status === "unchecked") {
|
|
334
|
+
checks.push({ id: `team:${label}`, ok: true, note: true, line: `team ${label}: readiness not checked yet` });
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const bad = team.readiness.checks.filter((c) => c.state !== "pass");
|
|
338
|
+
if (bad.length === 0 && team.readiness.status === "ready") {
|
|
339
|
+
checks.push({ id: `team:${label}`, ok: true, line: `team ${label}: ready` });
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
for (const c of bad) {
|
|
343
|
+
const meta = doc.readinessChecks.find((r) => r.id === c.id);
|
|
344
|
+
const needsAnswer = meta?.needsAnswer ?? true;
|
|
345
|
+
checks.push({
|
|
346
|
+
id: `team:${label}:${c.id}`,
|
|
347
|
+
ok: !needsAnswer && c.state !== "fail",
|
|
348
|
+
note: !needsAnswer,
|
|
349
|
+
line: `team ${label}: ${c.id} is ${c.state}${c.reason ? ` (${c.reason}${c.count !== undefined ? ` ×${c.count}` : ""})` : ""}${meta ? `, ${meta.severity}` : ""}`,
|
|
350
|
+
fix: teamCheckFix(label, c),
|
|
351
|
+
who: needsAnswer ? who : "nobody yet; it is informational",
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
if (bad.length === 0 && team.readiness.status !== "ready") {
|
|
355
|
+
checks.push({ id: `team:${label}`, ok: team.readiness.status !== "blocked", note: team.readiness.status === "degraded", line: `team ${label}: ${team.readiness.status}`, fix: `open settings for team ${label}`, who });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const ready = checks.every((c) => c.ok || c.note);
|
|
360
|
+
return { ready, checks, replica };
|
|
361
|
+
}
|
|
362
|
+
export async function cmdReady(args, ctx, deps) {
|
|
363
|
+
const report = await readyReport(ctx, deps);
|
|
364
|
+
if (args.json) {
|
|
365
|
+
ctx.stdout(JSON.stringify(report));
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
for (const c of report.checks) {
|
|
369
|
+
ctx.stdout(`${c.note ? "note" : c.ok ? "ok " : "FAIL"} ${c.line}`);
|
|
370
|
+
if (!c.ok && !c.note) {
|
|
371
|
+
if (c.fix)
|
|
372
|
+
ctx.stdout(` fix: ${c.fix}`);
|
|
373
|
+
if (c.who)
|
|
374
|
+
ctx.stdout(` who: ${c.who}`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
ctx.stdout(report.ready ? "READY" : "NOT READY");
|
|
378
|
+
}
|
|
379
|
+
return report.ready ? 0 : 1;
|
|
380
|
+
}
|