@catalyst-cloud/catalyst-skills 0.1.0 → 0.2.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 +8 -0
- package/README.md +133 -36
- package/dist/args.js +237 -0
- package/dist/ask.js +137 -0
- package/dist/cli.js +165 -279
- package/dist/config.js +106 -0
- package/dist/contract-types.js +4 -0
- package/dist/contract.js +178 -0
- package/dist/detach.js +10 -0
- package/dist/errors.js +27 -0
- package/dist/execution.js +156 -0
- package/dist/http.js +142 -0
- package/dist/prompt.js +35 -0
- package/dist/query.js +187 -0
- package/dist/ready.js +123 -0
- package/dist/replica.js +318 -0
- package/dist/sdk.js +26 -0
- package/dist/skill-shape.js +179 -0
- package/dist/skills.js +74 -0
- package/dist/ts-deps-loader.js +68 -0
- package/dist/watch/consumer.js +141 -0
- package/dist/watch/cursor-file.js +62 -0
- package/dist/watch.js +173 -0
- package/dist/write.js +224 -0
- package/package.json +18 -6
- 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 +102 -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 +50 -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 +163 -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-setup/SKILL.md +32 -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 +61 -0
- package/skills/catalyst-setup/scripts/check.mjs +75 -0
- package/skills/catalyst-setup/scripts/lib/cli.mjs +102 -0
- package/skills/catalyst-setup/scripts/replica-status.mjs +46 -0
- package/skills/connect-me/SKILL.md +55 -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 +184 -0
- package/skills/connect-me/scripts/verify-connection.mjs +68 -0
- package/skills/how-catalyst-works/SKILL.md +42 -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 +54 -0
- package/skills/how-catalyst-works/references/the-ladder.md +41 -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 +163 -0
- package/skills/how-catalyst-works/scripts/show-my-map.mjs +94 -0
- package/skills/how-catalyst-works/scripts/whats-running.mjs +66 -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 +184 -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/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 +47 -0
- package/skills/what-needs-me/scripts/lib/cli.mjs +134 -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 +57 -0
- package/skills/whats-happening/scripts/explain.mjs +28 -0
- package/skills/whats-happening/scripts/lib/cli.mjs +134 -0
- package/skills/whats-happening/scripts/snapshot.mjs +145 -0
- package/skills/ask/SKILL.md +0 -37
- package/skills/concierge/SKILL.md +0 -41
- package/skills/join/SKILL.md +0 -30
- package/skills/linearis/SKILL.md +0 -40
- package/skills/setup/SKILL.md +0 -27
- package/skills/steward/SKILL.md +0 -30
package/dist/query.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
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 { flagInt, flagString, positionals } from "./args.js";
|
|
6
|
+
import { requireConfig, replicaDbPath } from "./config.js";
|
|
7
|
+
import { UsageError } from "./errors.js";
|
|
8
|
+
import { apiClient } from "./http.js";
|
|
9
|
+
import { engineFor, replicaStatus } from "./replica.js";
|
|
10
|
+
import { loadSdk } from "./sdk.js";
|
|
11
|
+
const REPLICA_CAPABLE = new Set(["issues", "issue", "pulls", "projects"]);
|
|
12
|
+
export async function cmdQuery(args, ctx, deps = {}) {
|
|
13
|
+
const [sub, ...rest] = positionals(args);
|
|
14
|
+
if (!sub)
|
|
15
|
+
throw new UsageError("query needs a subcommand: issues | issue <id> | pulls | pull <id> | projects | cycles | search <terms> | changes --since <n>");
|
|
16
|
+
const cfg = requireConfig(ctx);
|
|
17
|
+
const filters = {
|
|
18
|
+
team: flagString(args, "team"),
|
|
19
|
+
project: flagString(args, "project"),
|
|
20
|
+
state: flagString(args, "state"),
|
|
21
|
+
limit: flagInt(args, "limit", 50),
|
|
22
|
+
};
|
|
23
|
+
const forced = flagString(args, "source");
|
|
24
|
+
if (forced && forced !== "replica" && forced !== "api")
|
|
25
|
+
throw new UsageError("--source must be replica or api");
|
|
26
|
+
const status = replicaStatus(ctx, cfg);
|
|
27
|
+
let source;
|
|
28
|
+
let why;
|
|
29
|
+
if (!REPLICA_CAPABLE.has(sub)) {
|
|
30
|
+
source = "api";
|
|
31
|
+
why = `${sub} is api-only`;
|
|
32
|
+
}
|
|
33
|
+
else if (forced) {
|
|
34
|
+
source = forced;
|
|
35
|
+
why = `--source ${forced}`;
|
|
36
|
+
}
|
|
37
|
+
else if (status.verdict === "fresh") {
|
|
38
|
+
source = "replica";
|
|
39
|
+
why = `cursor ${status.cursor}`;
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
source = "api";
|
|
43
|
+
why = `replica ${status.verdict}`;
|
|
44
|
+
}
|
|
45
|
+
ctx.stderr(source === "replica" ? `source: replica (${why})` : `source: api (${why})`);
|
|
46
|
+
let result;
|
|
47
|
+
if (source === "replica") {
|
|
48
|
+
if (status.verdict === "absent" || status.verdict === "not-configured") {
|
|
49
|
+
throw new UsageError(`--source replica but the replica is ${status.verdict}`);
|
|
50
|
+
}
|
|
51
|
+
result = await fromReplica(ctx, cfg, sub, rest, filters, deps);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
result = await fromApi(ctx, cfg, sub, rest, filters, args);
|
|
55
|
+
}
|
|
56
|
+
if (result === null) {
|
|
57
|
+
ctx.stderr(`${sub} ${rest[0] ?? ""}: not found`);
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
printResult(ctx, args, sub, result);
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
async function fromReplica(ctx, cfg, sub, rest, f, deps) {
|
|
64
|
+
const sdk = await loadSdk();
|
|
65
|
+
const dbPath = replicaDbPath(cfg, ctx.home);
|
|
66
|
+
const engine = await engineFor(sdk, dbPath, ctx, { ...deps.engineDeps, readonly: true });
|
|
67
|
+
const replica = await sdk.CatalystReplica.openReadOnly({ dbPath, engine, log: () => { } });
|
|
68
|
+
try {
|
|
69
|
+
switch (sub) {
|
|
70
|
+
case "issues": {
|
|
71
|
+
const rows = replica.issues({ limit: Math.max(f.limit, 200) });
|
|
72
|
+
return applyFilters(rows, f).slice(0, f.limit);
|
|
73
|
+
}
|
|
74
|
+
case "issue":
|
|
75
|
+
return replica.issue(needArg(rest, "issue <identifier>"));
|
|
76
|
+
case "pulls":
|
|
77
|
+
return replica.pulls({ limit: f.limit });
|
|
78
|
+
case "projects":
|
|
79
|
+
return applyFilters(replica.projects({ limit: Math.max(f.limit, 200) }), { limit: f.limit, state: f.state }).slice(0, f.limit);
|
|
80
|
+
default:
|
|
81
|
+
throw new UsageError(`${sub} is not readable from the replica`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
await replica.close();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async function fromApi(ctx, cfg, sub, rest, f, args) {
|
|
89
|
+
const api = apiClient(cfg, ctx);
|
|
90
|
+
const common = { team: f.team, project: f.project, state: f.state, limit: f.limit };
|
|
91
|
+
switch (sub) {
|
|
92
|
+
case "issues": {
|
|
93
|
+
const res = await api.getJson("/api/v1/issues", { query: common });
|
|
94
|
+
return applyFilters(rowsOf(res.body), f).slice(0, f.limit);
|
|
95
|
+
}
|
|
96
|
+
case "issue": {
|
|
97
|
+
const id = needArg(rest, "issue <identifier>");
|
|
98
|
+
const res = await api.getJson(`/api/v1/issues/${encodeURIComponent(id)}`, { accept: [404] });
|
|
99
|
+
return res.status === 404 ? null : res.body;
|
|
100
|
+
}
|
|
101
|
+
case "pulls": {
|
|
102
|
+
const res = await api.getJson("/api/v1/pulls", { query: { limit: f.limit, ticket: flagString(args, "ticket") } });
|
|
103
|
+
return rowsOf(res.body).slice(0, f.limit);
|
|
104
|
+
}
|
|
105
|
+
case "pull": {
|
|
106
|
+
const id = needArg(rest, "pull <node id>");
|
|
107
|
+
const res = await api.getJson(`/api/v1/pulls/${encodeURIComponent(id)}`, { accept: [404] });
|
|
108
|
+
return res.status === 404 ? null : res.body;
|
|
109
|
+
}
|
|
110
|
+
case "projects": {
|
|
111
|
+
const res = await api.getJson("/api/v1/projects", { query: { limit: f.limit } });
|
|
112
|
+
return rowsOf(res.body).slice(0, f.limit);
|
|
113
|
+
}
|
|
114
|
+
case "cycles": {
|
|
115
|
+
const res = await api.getJson("/api/v1/cycles", { query: { team: f.team, limit: f.limit } });
|
|
116
|
+
return rowsOf(res.body);
|
|
117
|
+
}
|
|
118
|
+
case "search": {
|
|
119
|
+
const q = rest.join(" ").trim();
|
|
120
|
+
if (!q)
|
|
121
|
+
throw new UsageError("search needs terms: query search <terms>");
|
|
122
|
+
const res = await api.getJson("/api/v1/search", { query: { q, limit: f.limit } });
|
|
123
|
+
return rowsOf(res.body);
|
|
124
|
+
}
|
|
125
|
+
case "changes": {
|
|
126
|
+
const since = flagString(args, "since");
|
|
127
|
+
if (since === undefined)
|
|
128
|
+
throw new UsageError("changes needs --since <cursor>");
|
|
129
|
+
const res = await api.getJson("/api/v1/changes", { query: { since, limit: f.limit } });
|
|
130
|
+
return res.body;
|
|
131
|
+
}
|
|
132
|
+
default:
|
|
133
|
+
throw new UsageError(`unknown query subcommand: ${sub}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function needArg(rest, usage) {
|
|
137
|
+
const v = rest[0];
|
|
138
|
+
if (!v)
|
|
139
|
+
throw new UsageError(`query ${usage}`);
|
|
140
|
+
return v;
|
|
141
|
+
}
|
|
142
|
+
/** The API answers a list route with either a bare array or `{rows|items|issues|...: []}`. */
|
|
143
|
+
export function rowsOf(body) {
|
|
144
|
+
if (Array.isArray(body))
|
|
145
|
+
return body;
|
|
146
|
+
if (body && typeof body === "object") {
|
|
147
|
+
for (const key of ["rows", "items", "issues", "pulls", "projects", "cycles", "results", "changes", "data"]) {
|
|
148
|
+
const v = body[key];
|
|
149
|
+
if (Array.isArray(v))
|
|
150
|
+
return v;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
export function applyFilters(rows, f) {
|
|
156
|
+
return rows.filter((r) => {
|
|
157
|
+
if (f.team && !(String(r.identifier ?? "").toUpperCase().startsWith(`${f.team.toUpperCase()}-`) || r.team_id === f.team || r.team_key === f.team))
|
|
158
|
+
return false;
|
|
159
|
+
if (f.project && r.project_id !== f.project)
|
|
160
|
+
return false;
|
|
161
|
+
if (f.state && String(r.state ?? "").toLowerCase() !== f.state.toLowerCase())
|
|
162
|
+
return false;
|
|
163
|
+
return true;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
function printResult(ctx, args, sub, result) {
|
|
167
|
+
if (args.json || !Array.isArray(result)) {
|
|
168
|
+
ctx.stdout(JSON.stringify(result, null, args.json ? 0 : 2));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
for (const row of result)
|
|
172
|
+
ctx.stdout(summaryLine(sub, row));
|
|
173
|
+
}
|
|
174
|
+
export function summaryLine(sub, r) {
|
|
175
|
+
switch (sub) {
|
|
176
|
+
case "issues":
|
|
177
|
+
return `${r.identifier ?? r.id} ${r.state ?? "?"} ${r.title ?? ""}${r.assignee_name || r.assignee ? ` (${r.assignee_name ?? r.assignee})` : ""}`;
|
|
178
|
+
case "pulls":
|
|
179
|
+
return `#${r.number ?? "?"} ${r.state ?? "?"}${r.merged ? " merged" : ""} ${r.title ?? ""} [${r.node_id ?? ""}]`;
|
|
180
|
+
case "projects":
|
|
181
|
+
return `${r.id} ${r.state ?? "?"} ${r.name ?? ""}`;
|
|
182
|
+
case "cycles":
|
|
183
|
+
return `cycle ${r.number ?? r.id} ${r.name ?? ""} ${r.starts_at ?? ""} → ${r.ends_at ?? ""}`;
|
|
184
|
+
default:
|
|
185
|
+
return JSON.stringify(r);
|
|
186
|
+
}
|
|
187
|
+
}
|
package/dist/ready.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
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 { existsSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { defaultSkillsDirFor, loadConfig, readManifest } from "./config.js";
|
|
7
|
+
import { contractVersionInRange, readContractCache } from "./contract.js";
|
|
8
|
+
import { CliError } from "./errors.js";
|
|
9
|
+
import { replicaStatus, statusLine } from "./replica.js";
|
|
10
|
+
import { loadSdk } from "./sdk.js";
|
|
11
|
+
function whoCanAnswer(doc) {
|
|
12
|
+
const roles = doc.humans.map((h) => `${h.role} ${h.linearUserId}`);
|
|
13
|
+
return roles.length ? roles.join(", ") : "a tenant owner or admin (none resolved on the contract)";
|
|
14
|
+
}
|
|
15
|
+
export async function readyReport(ctx, deps) {
|
|
16
|
+
const checks = [];
|
|
17
|
+
const nodeMajor = deps.nodeMajor ?? Number(process.versions.node.split(".")[0]);
|
|
18
|
+
checks.push(nodeMajor >= 22
|
|
19
|
+
? { id: "node", ok: true, line: `node: ${nodeMajor} (22 or newer required)` }
|
|
20
|
+
: { id: "node", ok: false, line: `node: ${nodeMajor} is too old`, fix: "install Node 22 or newer (the SDK's built-in SQLite engine needs it)", who: "you" });
|
|
21
|
+
let cfg = null;
|
|
22
|
+
try {
|
|
23
|
+
cfg = loadConfig(ctx.home);
|
|
24
|
+
checks.push(cfg
|
|
25
|
+
? { id: "config", ok: true, line: `config: joined ${cfg.name} (${cfg.slug}) as ${cfg.principal}` }
|
|
26
|
+
: { id: "config", ok: false, line: "config: not connected", fix: "CATALYST_CLOUD_TOKEN=<account key> npx @catalyst-cloud/catalyst-skills login", who: "you (the key comes from your tenant admin)" });
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
checks.push({ id: "config", ok: false, line: `config: ${err instanceof CliError ? err.message : String(err)}`, fix: "re-run login to rewrite it", who: "you" });
|
|
30
|
+
}
|
|
31
|
+
const cache = readContractCache(ctx.home);
|
|
32
|
+
const range = readManifest().tenantContractRange;
|
|
33
|
+
if (!cache) {
|
|
34
|
+
checks.push({ id: "contract", ok: false, line: "contract: not cached", fix: "catalyst-skills contract --refresh (needs an account key, not a workstation key)", who: "you" });
|
|
35
|
+
}
|
|
36
|
+
else if (contractVersionInRange(cache.contractVersion, range) !== true) {
|
|
37
|
+
checks.push({ id: "contract", ok: false, line: `contract: version ${cache.contractVersion} is outside this bundle's range ${range}`, fix: "npm update -g @catalyst-cloud/catalyst-skills", who: "you" });
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
checks.push({ id: "contract", ok: true, line: `contract: ${cache.contractVersion} cached ${cache.fetchedAt} (range ${range})` });
|
|
41
|
+
}
|
|
42
|
+
if (cfg) {
|
|
43
|
+
checks.push(cfg.cliPath && existsSync(cfg.cliPath)
|
|
44
|
+
? { id: "cliPath", ok: true, line: `cliPath: ${cfg.cliPath}` }
|
|
45
|
+
: { 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" });
|
|
46
|
+
}
|
|
47
|
+
// The skills are installed by the customer's own agent (a plugin, or `npx skills add`), so an
|
|
48
|
+
// empty copy directory is the normal case and must not read as NOT READY. A PARTIAL copy is the
|
|
49
|
+
// one broken state this check can see: half a set this package put there and never finished.
|
|
50
|
+
const skillsDir = cfg?.skillsDir ?? defaultSkillsDirFor(ctx.home);
|
|
51
|
+
const present = deps.skillNames.filter((n) => existsSync(join(skillsDir, n, "SKILL.md")));
|
|
52
|
+
const missing = deps.skillNames.filter((n) => !present.includes(n));
|
|
53
|
+
if (present.length === 0) {
|
|
54
|
+
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` });
|
|
55
|
+
}
|
|
56
|
+
else if (missing.length === 0) {
|
|
57
|
+
checks.push({ id: "skills", ok: true, line: `skills: all ${deps.skillNames.length} present in ${skillsDir}` });
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
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" });
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
await (deps.loadSdk ?? loadSdk)();
|
|
64
|
+
checks.push({ id: "sdk", ok: true, line: "sdk: loads (replica and watch are available)" });
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
checks.push({ id: "sdk", ok: false, line: `sdk: ${err instanceof Error ? err.message : String(err)}`, fix: "run under Node 22.15 or newer (or bun); every read still works through the API meanwhile", who: "you" });
|
|
68
|
+
}
|
|
69
|
+
const replica = replicaStatus(ctx, cfg);
|
|
70
|
+
checks.push({ id: "replica", ok: replica.verdict === "fresh", note: true, line: statusLine(replica) });
|
|
71
|
+
if (cache) {
|
|
72
|
+
const doc = cache.doc;
|
|
73
|
+
const who = whoCanAnswer(doc);
|
|
74
|
+
for (const team of doc.teams) {
|
|
75
|
+
const label = team.key ?? team.id;
|
|
76
|
+
if (team.readiness.status === "unchecked") {
|
|
77
|
+
checks.push({ id: `team:${label}`, ok: true, note: true, line: `team ${label}: readiness not checked yet` });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const bad = team.readiness.checks.filter((c) => c.state !== "pass");
|
|
81
|
+
if (bad.length === 0 && team.readiness.status === "ready") {
|
|
82
|
+
checks.push({ id: `team:${label}`, ok: true, line: `team ${label}: ready` });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
for (const c of bad) {
|
|
86
|
+
const meta = doc.readinessChecks.find((r) => r.id === c.id);
|
|
87
|
+
const needsAnswer = meta?.needsAnswer ?? true;
|
|
88
|
+
checks.push({
|
|
89
|
+
id: `team:${label}:${c.id}`,
|
|
90
|
+
ok: !needsAnswer && c.state !== "fail",
|
|
91
|
+
note: !needsAnswer,
|
|
92
|
+
line: `team ${label}: ${c.id} is ${c.state}${c.reason ? ` (${c.reason}${c.count !== undefined ? ` ×${c.count}` : ""})` : ""}${meta ? `, ${meta.severity}` : ""}`,
|
|
93
|
+
fix: `open settings for team ${label} and resolve ${c.id}`,
|
|
94
|
+
who: needsAnswer ? who : "nobody yet; it is informational",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (bad.length === 0 && team.readiness.status !== "ready") {
|
|
98
|
+
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 });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const ready = checks.every((c) => c.ok || c.note);
|
|
103
|
+
return { ready, checks };
|
|
104
|
+
}
|
|
105
|
+
export async function cmdReady(args, ctx, deps) {
|
|
106
|
+
const report = await readyReport(ctx, deps);
|
|
107
|
+
if (args.json) {
|
|
108
|
+
ctx.stdout(JSON.stringify(report));
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
for (const c of report.checks) {
|
|
112
|
+
ctx.stdout(`${c.note ? "note" : c.ok ? "ok " : "FAIL"} ${c.line}`);
|
|
113
|
+
if (!c.ok && !c.note) {
|
|
114
|
+
if (c.fix)
|
|
115
|
+
ctx.stdout(` fix: ${c.fix}`);
|
|
116
|
+
if (c.who)
|
|
117
|
+
ctx.stdout(` who: ${c.who}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
ctx.stdout(report.ready ? "READY" : "NOT READY");
|
|
121
|
+
}
|
|
122
|
+
return report.ready ? 0 : 1;
|
|
123
|
+
}
|
package/dist/replica.js
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// replica.ts — the optional local replica: `replica start|stop|status|sql|schema`.
|
|
2
|
+
//
|
|
3
|
+
// `status` is the check every skill runs first and it needs no network and no SDK: the pidfile, the
|
|
4
|
+
// writer-lock heartbeat, and the `sync_meta.cursor` row (read through node:sqlite read-only). Exit
|
|
5
|
+
// 0 fresh, 1 present but stale, 2 not configured, 3 absent. `--probe` adds the one network call.
|
|
6
|
+
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { DatabaseSync } from "node:sqlite";
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
import { flagBool, flagInt, flagString, positionals } from "./args.js";
|
|
10
|
+
import { apiBase, loadConfig, replicaDbPath } from "./config.js";
|
|
11
|
+
import { detachSelf } from "./detach.js";
|
|
12
|
+
import { CliError, UsageError } from "./errors.js";
|
|
13
|
+
import { apiClient } from "./http.js";
|
|
14
|
+
import { loadSdk } from "./sdk.js";
|
|
15
|
+
export const DEFAULT_STALE_MS = 15_000;
|
|
16
|
+
export function pidfilePath(dbPath) {
|
|
17
|
+
return `${dbPath}.pid`;
|
|
18
|
+
}
|
|
19
|
+
export function lockPath(dbPath) {
|
|
20
|
+
return `${dbPath}.writer.lock`;
|
|
21
|
+
}
|
|
22
|
+
export function pidAlive(pid) {
|
|
23
|
+
if (pid === null || !Number.isInteger(pid) || pid <= 0)
|
|
24
|
+
return false;
|
|
25
|
+
try {
|
|
26
|
+
process.kill(pid, 0);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
return err.code === "EPERM";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function readPidfile(dbPath) {
|
|
34
|
+
const p = pidfilePath(dbPath);
|
|
35
|
+
if (!existsSync(p))
|
|
36
|
+
return null;
|
|
37
|
+
const n = Number(readFileSync(p, "utf8").trim());
|
|
38
|
+
return Number.isInteger(n) ? n : null;
|
|
39
|
+
}
|
|
40
|
+
function readLock(dbPath) {
|
|
41
|
+
try {
|
|
42
|
+
const rec = JSON.parse(readFileSync(lockPath(dbPath), "utf8"));
|
|
43
|
+
if (typeof rec.pid === "number" && typeof rec.heartbeat === "number")
|
|
44
|
+
return { pid: rec.pid, heartbeat: rec.heartbeat };
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Read `sync_meta.cursor` read-only through node:sqlite; null when the table or row is absent. */
|
|
52
|
+
export function readCursor(dbPath) {
|
|
53
|
+
let db = null;
|
|
54
|
+
try {
|
|
55
|
+
db = new DatabaseSync(dbPath, { readOnly: true });
|
|
56
|
+
const table = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'sync_meta'").get();
|
|
57
|
+
if (!table)
|
|
58
|
+
return null;
|
|
59
|
+
const row = db.prepare("SELECT value FROM sync_meta WHERE key = 'cursor'").get();
|
|
60
|
+
if (!row || row.value === null || row.value === undefined || row.value === "")
|
|
61
|
+
return null;
|
|
62
|
+
const n = Number(row.value);
|
|
63
|
+
return Number.isInteger(n) ? n : null;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
db?.close();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** The in-process status logic `query` and `ready` reuse. */
|
|
73
|
+
export function replicaStatus(ctx, cfg, opts = {}) {
|
|
74
|
+
const base = {
|
|
75
|
+
verdict: "not-configured",
|
|
76
|
+
exitCode: 2,
|
|
77
|
+
dbPath: null,
|
|
78
|
+
cursor: null,
|
|
79
|
+
heartbeatAgeMs: null,
|
|
80
|
+
lockPid: null,
|
|
81
|
+
pidfilePid: null,
|
|
82
|
+
writerAlive: false,
|
|
83
|
+
reasons: [],
|
|
84
|
+
};
|
|
85
|
+
if (!cfg)
|
|
86
|
+
return { ...base, reasons: ["not connected"] };
|
|
87
|
+
const dbPath = opts.dbPath ?? replicaDbPath(cfg, ctx.home);
|
|
88
|
+
if (!existsSync(dbPath))
|
|
89
|
+
return { ...base, verdict: "absent", exitCode: 3, dbPath, reasons: ["no replica file"] };
|
|
90
|
+
const staleMs = opts.staleMs ?? DEFAULT_STALE_MS;
|
|
91
|
+
const nowMs = opts.nowMs ?? ctx.now().getTime();
|
|
92
|
+
const lock = readLock(dbPath);
|
|
93
|
+
const pidfilePid = readPidfile(dbPath);
|
|
94
|
+
const heartbeatAgeMs = lock ? Math.max(0, nowMs - lock.heartbeat) : null;
|
|
95
|
+
const writerAlive = pidAlive(lock?.pid ?? null) || pidAlive(pidfilePid);
|
|
96
|
+
const cursor = readCursor(dbPath);
|
|
97
|
+
const reasons = [];
|
|
98
|
+
if (!lock)
|
|
99
|
+
reasons.push("no writer lock");
|
|
100
|
+
else if (heartbeatAgeMs !== null && heartbeatAgeMs >= staleMs)
|
|
101
|
+
reasons.push(`heartbeat ${heartbeatAgeMs}ms old (stale after ${staleMs}ms)`);
|
|
102
|
+
if (cursor === null)
|
|
103
|
+
reasons.push("no cursor");
|
|
104
|
+
if (!writerAlive)
|
|
105
|
+
reasons.push("no live writer process");
|
|
106
|
+
const fresh = reasons.length === 0;
|
|
107
|
+
return {
|
|
108
|
+
verdict: fresh ? "fresh" : "stale",
|
|
109
|
+
exitCode: fresh ? 0 : 1,
|
|
110
|
+
dbPath,
|
|
111
|
+
cursor,
|
|
112
|
+
heartbeatAgeMs,
|
|
113
|
+
lockPid: lock?.pid ?? null,
|
|
114
|
+
pidfilePid,
|
|
115
|
+
writerAlive,
|
|
116
|
+
reasons,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
export function statusLine(s) {
|
|
120
|
+
switch (s.verdict) {
|
|
121
|
+
case "not-configured":
|
|
122
|
+
return "replica: not configured — run login first";
|
|
123
|
+
case "absent":
|
|
124
|
+
return `replica: absent at ${s.dbPath} — start it with: catalyst-skills replica start --detach`;
|
|
125
|
+
case "fresh":
|
|
126
|
+
return `replica: fresh at ${s.dbPath} (cursor ${s.cursor}, heartbeat ${s.heartbeatAgeMs}ms ago${s.lag !== undefined ? `, ${s.lag} behind head ${s.head}` : ""})`;
|
|
127
|
+
case "stale":
|
|
128
|
+
return `replica: stale at ${s.dbPath} (${s.reasons.join("; ")}${s.cursor !== null ? `; cursor ${s.cursor}` : ""}) — reads fall back to the API`;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** better-sqlite3 when it resolves and constructs, else node:sqlite — with exactly one stderr line
|
|
132
|
+
* on the fallback so the choice is visible. */
|
|
133
|
+
export async function engineFor(sdk, dbPath, ctx, deps = {}) {
|
|
134
|
+
const requireDriver = deps.requireDriver ?? (() => createRequire(import.meta.url)("better-sqlite3"));
|
|
135
|
+
try {
|
|
136
|
+
const mod = requireDriver();
|
|
137
|
+
const driver = (mod && typeof mod === "object" && "default" in mod ? mod.default : mod);
|
|
138
|
+
if (typeof driver !== "function")
|
|
139
|
+
throw new Error("better-sqlite3 resolved to a non-constructor");
|
|
140
|
+
return deps.readonly ? sdk.betterSqlite3ReadonlyEngine(driver, dbPath) : sdk.betterSqlite3Engine(driver, dbPath);
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
const why = err instanceof Error ? err.message.split("\n")[0] : String(err);
|
|
144
|
+
ctx.stderr(`[catalyst-skills] better-sqlite3 unavailable (${why}); using node:sqlite`);
|
|
145
|
+
return deps.readonly ? sdk.nodeSqliteReadonlyEngine(dbPath) : sdk.nodeSqliteEngine(dbPath);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
export async function cmdReplica(args, ctx, deps = {}) {
|
|
149
|
+
const [sub, ...rest] = positionals(args);
|
|
150
|
+
if (!sub)
|
|
151
|
+
throw new UsageError("replica needs a subcommand: start | stop | status | sql | schema");
|
|
152
|
+
if (sub === "status")
|
|
153
|
+
return cmdStatus(args, ctx);
|
|
154
|
+
const cfg = loadConfig(ctx.home);
|
|
155
|
+
if (!cfg)
|
|
156
|
+
throw new CliError("not connected yet — run login first", "not-configured");
|
|
157
|
+
const dbPath = flagString(args, "db") ?? replicaDbPath(cfg, ctx.home);
|
|
158
|
+
switch (sub) {
|
|
159
|
+
case "start":
|
|
160
|
+
return cmdStart(args, ctx, cfg, dbPath, deps);
|
|
161
|
+
case "stop":
|
|
162
|
+
return cmdStop(ctx, dbPath);
|
|
163
|
+
case "sql":
|
|
164
|
+
return cmdSql(args, ctx, dbPath, rest.join(" "), deps);
|
|
165
|
+
case "schema":
|
|
166
|
+
return cmdSchema(args, ctx, dbPath, rest[0]);
|
|
167
|
+
default:
|
|
168
|
+
throw new UsageError(`unknown replica subcommand: ${sub}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async function cmdStatus(args, ctx) {
|
|
172
|
+
let cfg;
|
|
173
|
+
try {
|
|
174
|
+
cfg = loadConfig(ctx.home);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
cfg = null;
|
|
178
|
+
}
|
|
179
|
+
const status = replicaStatus(ctx, cfg, {
|
|
180
|
+
staleMs: flagInt(args, "stale-ms", DEFAULT_STALE_MS),
|
|
181
|
+
dbPath: flagString(args, "db"),
|
|
182
|
+
});
|
|
183
|
+
if (flagBool(args, "probe") && cfg && status.verdict !== "not-configured") {
|
|
184
|
+
const head = await apiClient(cfg, ctx).getJson("/api/v1/snapshot", { query: { head: 1 } });
|
|
185
|
+
if (typeof head.body.cursor === "number") {
|
|
186
|
+
status.head = head.body.cursor;
|
|
187
|
+
status.lag = status.cursor === null ? head.body.cursor : head.body.cursor - status.cursor;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
ctx.stdout(args.json ? JSON.stringify(status) : statusLine(status));
|
|
191
|
+
return status.exitCode;
|
|
192
|
+
}
|
|
193
|
+
async function cmdStart(args, ctx, cfg, dbPath, deps) {
|
|
194
|
+
if (flagBool(args, "detach")) {
|
|
195
|
+
const argv = (deps.argv ?? process.argv.slice(1)).filter((a) => a !== "--detach");
|
|
196
|
+
const { pid } = (deps.detach ?? detachSelf)(argv, ctx.env);
|
|
197
|
+
writeFileSync(pidfilePath(dbPath), `${pid}\n`);
|
|
198
|
+
ctx.stdout(`replica writer started in the background (pid ${pid}, db ${dbPath}); check with: catalyst-skills replica status`);
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
const sdk = await loadSdk();
|
|
202
|
+
const engine = await engineFor(sdk, dbPath, ctx, deps.engineDeps);
|
|
203
|
+
const replica = new sdk.CatalystReplica({
|
|
204
|
+
baseUrl: apiBase(cfg),
|
|
205
|
+
account: cfg.account,
|
|
206
|
+
accountSource: "declared",
|
|
207
|
+
auth: { kind: "token", token: cfg.key },
|
|
208
|
+
dbPath,
|
|
209
|
+
engine,
|
|
210
|
+
fetchImpl: ctx.fetch,
|
|
211
|
+
wsFactory: deps.wsFactory,
|
|
212
|
+
log: (level, msg, extra) => ctx.stderr(`[replica ${level}] ${msg}${extra ? ` ${safeJson(extra)}` : ""}`),
|
|
213
|
+
onStatus: (s) => ctx.stderr(`[replica] status=${s}`),
|
|
214
|
+
});
|
|
215
|
+
await replica.start();
|
|
216
|
+
ctx.stdout(`replica live at ${dbPath} (cursor ${replica.cursor ?? "none"}) — Ctrl-C to stop`);
|
|
217
|
+
const wait = deps.waitForStop ??
|
|
218
|
+
(() => new Promise((resolve) => {
|
|
219
|
+
const done = () => resolve();
|
|
220
|
+
process.once("SIGINT", done);
|
|
221
|
+
process.once("SIGTERM", done);
|
|
222
|
+
}));
|
|
223
|
+
await wait();
|
|
224
|
+
await replica.close();
|
|
225
|
+
try {
|
|
226
|
+
if (readPidfile(dbPath) === process.pid)
|
|
227
|
+
unlinkSync(pidfilePath(dbPath));
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// pidfile already gone
|
|
231
|
+
}
|
|
232
|
+
ctx.stdout("replica stopped");
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
function cmdStop(ctx, dbPath) {
|
|
236
|
+
const pid = readPidfile(dbPath);
|
|
237
|
+
if (pid === null) {
|
|
238
|
+
ctx.stdout(`no pidfile at ${pidfilePath(dbPath)} — nothing to stop (a foreground writer is stopped with Ctrl-C)`);
|
|
239
|
+
return 1;
|
|
240
|
+
}
|
|
241
|
+
if (!pidAlive(pid)) {
|
|
242
|
+
unlinkSync(pidfilePath(dbPath));
|
|
243
|
+
ctx.stdout(`pid ${pid} is not running; removed the stale pidfile`);
|
|
244
|
+
return 1;
|
|
245
|
+
}
|
|
246
|
+
process.kill(pid, "SIGTERM");
|
|
247
|
+
try {
|
|
248
|
+
unlinkSync(pidfilePath(dbPath));
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
// already removed by the writer
|
|
252
|
+
}
|
|
253
|
+
ctx.stdout(`sent SIGTERM to replica writer pid ${pid}`);
|
|
254
|
+
return 0;
|
|
255
|
+
}
|
|
256
|
+
const SELECT_ONLY = /^\s*(select|with)\b/i;
|
|
257
|
+
export function assertSingleSelect(sql) {
|
|
258
|
+
const trimmed = sql.trim().replace(/;\s*$/, "");
|
|
259
|
+
if (!trimmed)
|
|
260
|
+
throw new UsageError('replica sql needs a query: replica sql "select ..."');
|
|
261
|
+
if (!SELECT_ONLY.test(trimmed) || trimmed.includes(";")) {
|
|
262
|
+
throw new UsageError("replica sql runs exactly one SELECT (or WITH ... SELECT); nothing else is accepted on a read-only handle");
|
|
263
|
+
}
|
|
264
|
+
return trimmed;
|
|
265
|
+
}
|
|
266
|
+
async function cmdSql(args, ctx, dbPath, sql, deps) {
|
|
267
|
+
const query = assertSingleSelect(sql);
|
|
268
|
+
if (!existsSync(dbPath))
|
|
269
|
+
throw new CliError(`no replica at ${dbPath} — start it with: catalyst-skills replica start --detach`, "replica-absent", 3);
|
|
270
|
+
const sdk = await loadSdk();
|
|
271
|
+
const engine = await engineFor(sdk, dbPath, ctx, { ...deps.engineDeps, readonly: true });
|
|
272
|
+
const replica = await sdk.CatalystReplica.openReadOnly({ dbPath, engine, log: () => { } });
|
|
273
|
+
try {
|
|
274
|
+
const rows = replica.sql.exec(query).toArray();
|
|
275
|
+
ctx.stdout(JSON.stringify(rows, null, args.json ? 0 : 2));
|
|
276
|
+
return 0;
|
|
277
|
+
}
|
|
278
|
+
finally {
|
|
279
|
+
await replica.close();
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async function cmdSchema(args, ctx, dbPath, table) {
|
|
283
|
+
if (!existsSync(dbPath))
|
|
284
|
+
throw new CliError(`no replica at ${dbPath} — the schema is what the file holds; start it with: catalyst-skills replica start --detach`, "replica-absent", 3);
|
|
285
|
+
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
286
|
+
try {
|
|
287
|
+
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all().map((r) => r.name);
|
|
288
|
+
const wanted = table ? tables.filter((t) => t === table) : tables;
|
|
289
|
+
if (table && wanted.length === 0)
|
|
290
|
+
throw new CliError(`no table "${table}" in the replica (tables: ${tables.join(", ")})`, "table-unknown");
|
|
291
|
+
const out = {};
|
|
292
|
+
for (const t of wanted) {
|
|
293
|
+
out[t] = db.prepare(`PRAGMA table_info("${t.replace(/"/g, '""')}")`).all().map((c) => ({
|
|
294
|
+
name: c.name,
|
|
295
|
+
type: c.type,
|
|
296
|
+
notnull: c.notnull,
|
|
297
|
+
pk: c.pk,
|
|
298
|
+
}));
|
|
299
|
+
}
|
|
300
|
+
if (args.json)
|
|
301
|
+
ctx.stdout(JSON.stringify(out));
|
|
302
|
+
else
|
|
303
|
+
for (const [t, cols] of Object.entries(out))
|
|
304
|
+
ctx.stdout(`${t}: ${cols.map((c) => `${c.name} ${c.type || "ANY"}${c.pk ? " PK" : ""}`).join(", ")}`);
|
|
305
|
+
return 0;
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
db.close();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
function safeJson(v) {
|
|
312
|
+
try {
|
|
313
|
+
return JSON.stringify(v);
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
return String(v);
|
|
317
|
+
}
|
|
318
|
+
}
|
package/dist/sdk.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { CliError } from "./errors.js";
|
|
2
|
+
import { installTsDepsLoader } from "./ts-deps-loader.js";
|
|
3
|
+
let cached = null;
|
|
4
|
+
const realImport = () => import("@catalyst-cloud/sdk/node");
|
|
5
|
+
/** Import the SDK's node entry, installing the type-stripping loader first. Cached per process. */
|
|
6
|
+
export function loadSdk(importer = realImport) {
|
|
7
|
+
if (!cached) {
|
|
8
|
+
cached = (async () => {
|
|
9
|
+
const verdict = installTsDepsLoader();
|
|
10
|
+
try {
|
|
11
|
+
return await importer();
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
cached = null;
|
|
15
|
+
const detail = err instanceof Error ? err.message.split("\n")[0] : String(err);
|
|
16
|
+
const why = verdict.installed ? "" : ` (${verdict.reason})`;
|
|
17
|
+
throw new CliError(`the Catalyst Cloud SDK could not be loaded on Node ${process.version}${why}: ${detail} — this verb needs the SDK; run under Node 22.15 or newer, or under bun`, "sdk-unavailable");
|
|
18
|
+
}
|
|
19
|
+
})();
|
|
20
|
+
}
|
|
21
|
+
return cached;
|
|
22
|
+
}
|
|
23
|
+
/** Test seam: forget the cached import. */
|
|
24
|
+
export function resetSdkCache() {
|
|
25
|
+
cached = null;
|
|
26
|
+
}
|