@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/replica.js
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
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 { createRequire } from "node:module";
|
|
8
|
+
import { flagBool, flagInt, flagString, positionals } from "./args.js";
|
|
9
|
+
import { apiBase, loadConfig, replicaDbPath } from "./config.js";
|
|
10
|
+
import { detachSelf } from "./detach.js";
|
|
11
|
+
import { CliError, UsageError } from "./errors.js";
|
|
12
|
+
import { apiClient } from "./transport.js";
|
|
13
|
+
import { authStrategyFor } from "./oauth.js";
|
|
14
|
+
import { loadSdk } from "./sdk.js";
|
|
15
|
+
import { createEventSync } from "./events.js";
|
|
16
|
+
import { BUN_MIN, FIX_COMMAND } from "./runtime.js";
|
|
17
|
+
export const DEFAULT_STALE_MS = 15_000;
|
|
18
|
+
let sqliteCache = null;
|
|
19
|
+
/** Load `node:sqlite` on first use. Any failure (module absent on this runtime) becomes a named
|
|
20
|
+
* CliError pointing at the bun floor and the one fix command — never a raw ResolveMessage. */
|
|
21
|
+
export function loadSqlite(req = createRequire(import.meta.url)) {
|
|
22
|
+
if (sqliteCache)
|
|
23
|
+
return sqliteCache;
|
|
24
|
+
try {
|
|
25
|
+
sqliteCache = req("node:sqlite");
|
|
26
|
+
return sqliteCache;
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
30
|
+
throw new CliError(`node:sqlite is not available on this runtime (${detail}) — the replica needs it. ` +
|
|
31
|
+
`Supported: Node 22.5+ has node:sqlite built in; bun needs ${BUN_MIN} or newer. ` +
|
|
32
|
+
`One command fixes it without changing your default Node: ${FIX_COMMAND}`, "sqlite-unavailable");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Test seam: forget the cached module. */
|
|
36
|
+
export function resetSqliteCache() {
|
|
37
|
+
sqliteCache = null;
|
|
38
|
+
}
|
|
39
|
+
export function pidfilePath(dbPath) {
|
|
40
|
+
return `${dbPath}.pid`;
|
|
41
|
+
}
|
|
42
|
+
export function lockPath(dbPath) {
|
|
43
|
+
return `${dbPath}.writer.lock`;
|
|
44
|
+
}
|
|
45
|
+
export function writerStatePath(dbPath) {
|
|
46
|
+
return `${dbPath}.writer.state`;
|
|
47
|
+
}
|
|
48
|
+
/** Read the writer's state sidecar. A missing file, unparseable JSON, or a record with the wrong
|
|
49
|
+
* shape (a hand-edited or truncated file) all read as "no record", never a throw — readLock's
|
|
50
|
+
* contract, followed here. */
|
|
51
|
+
export function readWriterState(dbPath) {
|
|
52
|
+
try {
|
|
53
|
+
const rec = JSON.parse(readFileSync(writerStatePath(dbPath), "utf8"));
|
|
54
|
+
const stoppedOk = rec.stopped === null ||
|
|
55
|
+
(typeof rec.stopped === "object" &&
|
|
56
|
+
rec.stopped !== null &&
|
|
57
|
+
typeof rec.stopped.at === "number" &&
|
|
58
|
+
typeof rec.stopped.reason === "string" &&
|
|
59
|
+
typeof rec.stopped.restartWith === "string");
|
|
60
|
+
if (typeof rec.updatedAt !== "number" ||
|
|
61
|
+
typeof rec.pid !== "number" ||
|
|
62
|
+
typeof rec.consecutiveFailures !== "number" ||
|
|
63
|
+
!(rec.lastError === null || typeof rec.lastError === "string") ||
|
|
64
|
+
!(rec.lastFailureAt === null || typeof rec.lastFailureAt === "number") ||
|
|
65
|
+
!stoppedOk) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
return rec;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export function writeWriterState(dbPath, s, nowMs) {
|
|
75
|
+
writeFileSync(writerStatePath(dbPath), JSON.stringify({ ...s, updatedAt: nowMs }));
|
|
76
|
+
}
|
|
77
|
+
/** Remove the writer state sidecar; a no-op (never throws) when it is already gone. */
|
|
78
|
+
export function clearWriterState(dbPath) {
|
|
79
|
+
try {
|
|
80
|
+
unlinkSync(writerStatePath(dbPath));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// already gone
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export function pidAlive(pid) {
|
|
87
|
+
if (pid === null || !Number.isInteger(pid) || pid <= 0)
|
|
88
|
+
return false;
|
|
89
|
+
try {
|
|
90
|
+
process.kill(pid, 0);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
return err.code === "EPERM";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export function readPidfile(dbPath) {
|
|
98
|
+
const p = pidfilePath(dbPath);
|
|
99
|
+
if (!existsSync(p))
|
|
100
|
+
return null;
|
|
101
|
+
const n = Number(readFileSync(p, "utf8").trim());
|
|
102
|
+
return Number.isInteger(n) ? n : null;
|
|
103
|
+
}
|
|
104
|
+
function readLock(dbPath) {
|
|
105
|
+
try {
|
|
106
|
+
const rec = JSON.parse(readFileSync(lockPath(dbPath), "utf8"));
|
|
107
|
+
if (typeof rec.pid === "number" && typeof rec.heartbeat === "number")
|
|
108
|
+
return { pid: rec.pid, heartbeat: rec.heartbeat };
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Read `sync_meta.cursor` read-only through node:sqlite; null when the table or row is absent. */
|
|
116
|
+
export function readCursor(dbPath) {
|
|
117
|
+
let db = null;
|
|
118
|
+
try {
|
|
119
|
+
db = new (loadSqlite().DatabaseSync)(dbPath, { readOnly: true });
|
|
120
|
+
const table = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'sync_meta'").get();
|
|
121
|
+
if (!table)
|
|
122
|
+
return null;
|
|
123
|
+
const row = db.prepare("SELECT value FROM sync_meta WHERE key = 'cursor'").get();
|
|
124
|
+
if (!row || row.value === null || row.value === undefined || row.value === "")
|
|
125
|
+
return null;
|
|
126
|
+
const n = Number(row.value);
|
|
127
|
+
return Number.isInteger(n) ? n : null;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
db?.close();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** The in-process status logic `query` and `ready` reuse. */
|
|
137
|
+
export function replicaStatus(ctx, cfg, opts = {}) {
|
|
138
|
+
const base = {
|
|
139
|
+
verdict: "not-configured",
|
|
140
|
+
exitCode: 2,
|
|
141
|
+
dbPath: null,
|
|
142
|
+
cursor: null,
|
|
143
|
+
heartbeatAgeMs: null,
|
|
144
|
+
lockPid: null,
|
|
145
|
+
pidfilePid: null,
|
|
146
|
+
writerAlive: false,
|
|
147
|
+
reasons: [],
|
|
148
|
+
writer: null,
|
|
149
|
+
};
|
|
150
|
+
if (!cfg)
|
|
151
|
+
return { ...base, reasons: ["not connected"] };
|
|
152
|
+
let dbPath;
|
|
153
|
+
try {
|
|
154
|
+
dbPath = opts.dbPath ?? replicaDbPath(cfg, ctx.home, ctx.env);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
if (error instanceof CliError && error.code === "replica-not-configured")
|
|
158
|
+
return { ...base, reasons: [error.message] };
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
const writer = readWriterState(dbPath);
|
|
162
|
+
if (!existsSync(dbPath))
|
|
163
|
+
return { ...base, verdict: "absent", exitCode: 3, dbPath, reasons: ["no replica file"], writer };
|
|
164
|
+
const staleMs = opts.staleMs ?? DEFAULT_STALE_MS;
|
|
165
|
+
const nowMs = opts.nowMs ?? ctx.now().getTime();
|
|
166
|
+
const lock = readLock(dbPath);
|
|
167
|
+
const pidfilePid = readPidfile(dbPath);
|
|
168
|
+
const heartbeatAgeMs = lock ? Math.max(0, nowMs - lock.heartbeat) : null;
|
|
169
|
+
const writerAlive = pidAlive(lock?.pid ?? null) || pidAlive(pidfilePid);
|
|
170
|
+
const cursor = readCursor(dbPath);
|
|
171
|
+
const reasons = [];
|
|
172
|
+
if (!lock)
|
|
173
|
+
reasons.push("no writer lock");
|
|
174
|
+
else if (heartbeatAgeMs !== null && heartbeatAgeMs >= staleMs)
|
|
175
|
+
reasons.push(`heartbeat ${heartbeatAgeMs}ms old (stale after ${staleMs}ms)`);
|
|
176
|
+
if (cursor === null)
|
|
177
|
+
reasons.push("no cursor");
|
|
178
|
+
if (!writerAlive)
|
|
179
|
+
reasons.push("no live writer process");
|
|
180
|
+
const fresh = reasons.length === 0;
|
|
181
|
+
return {
|
|
182
|
+
verdict: fresh ? "fresh" : "stale",
|
|
183
|
+
exitCode: fresh ? 0 : 1,
|
|
184
|
+
dbPath,
|
|
185
|
+
cursor,
|
|
186
|
+
heartbeatAgeMs,
|
|
187
|
+
lockPid: lock?.pid ?? null,
|
|
188
|
+
pidfilePid,
|
|
189
|
+
writerAlive,
|
|
190
|
+
reasons,
|
|
191
|
+
writer,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function baseStatusLine(s) {
|
|
195
|
+
switch (s.verdict) {
|
|
196
|
+
case "not-configured":
|
|
197
|
+
return `replica: not configured (${s.reasons.join("; ")})`;
|
|
198
|
+
case "absent":
|
|
199
|
+
return `replica: absent at ${s.dbPath} — start it with: catalyst-skills replica start --detach`;
|
|
200
|
+
case "fresh":
|
|
201
|
+
return `replica: fresh at ${s.dbPath} (cursor ${s.cursor}, heartbeat ${s.heartbeatAgeMs}ms ago${s.lag !== undefined ? `, ${s.lag} behind head ${s.head}` : ""})`;
|
|
202
|
+
case "stale":
|
|
203
|
+
return `replica: stale at ${s.dbPath} (${s.reasons.join("; ")}${s.cursor !== null ? `; cursor ${s.cursor}` : ""}) — reads fall back to the API`;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/** Is a process that could still be writing this replica alive? The lock/pidfile liveness
|
|
207
|
+
* `replicaStatus` already computed, or the pid the writer stamped on its own record. Both renderers
|
|
208
|
+
* ask before describing that record in the present tense: the sidecar outlives a SIGKILL, a crash
|
|
209
|
+
* and a reboot, and "is backing off" about a dead process sends a customer off to wait for a retry
|
|
210
|
+
* that will never come (CTC-2499). */
|
|
211
|
+
export function writerIsRunning(s) {
|
|
212
|
+
return s.writerAlive || pidAlive(s.writer?.pid ?? null);
|
|
213
|
+
}
|
|
214
|
+
export function statusLine(s) {
|
|
215
|
+
const base = baseStatusLine(s);
|
|
216
|
+
const w = s.writer;
|
|
217
|
+
if (w?.stopped) {
|
|
218
|
+
return (`${base} — the writer stopped ${new Date(w.stopped.at).toISOString()}: ${w.stopped.reason} ` +
|
|
219
|
+
`(last error: ${w.lastError}); restart it with: ${w.stopped.restartWith}`);
|
|
220
|
+
}
|
|
221
|
+
if (w && w.consecutiveFailures > 0) {
|
|
222
|
+
if (!writerIsRunning(s)) {
|
|
223
|
+
return (`${base} — the writer recorded ${w.consecutiveFailures} failed snapshot pulls and is no longer running ` +
|
|
224
|
+
`(last error: ${w.lastError}); restart it with: ${REPLICA_RESTART_COMMAND}`);
|
|
225
|
+
}
|
|
226
|
+
return `${base} — the writer has failed ${w.consecutiveFailures} snapshot pulls in a row and is backing off (last error: ${w.lastError})`;
|
|
227
|
+
}
|
|
228
|
+
return base;
|
|
229
|
+
}
|
|
230
|
+
/** better-sqlite3 when it resolves and constructs, else node:sqlite — with exactly one stderr line
|
|
231
|
+
* on the fallback so the choice is visible. */
|
|
232
|
+
export async function engineFor(sdk, dbPath, ctx, deps = {}) {
|
|
233
|
+
const requireDriver = deps.requireDriver ?? (() => createRequire(import.meta.url)("better-sqlite3"));
|
|
234
|
+
try {
|
|
235
|
+
const mod = requireDriver();
|
|
236
|
+
const driver = (mod && typeof mod === "object" && "default" in mod ? mod.default : mod);
|
|
237
|
+
if (typeof driver !== "function")
|
|
238
|
+
throw new Error("better-sqlite3 resolved to a non-constructor");
|
|
239
|
+
return deps.readonly ? sdk.betterSqlite3ReadonlyEngine(driver, dbPath) : sdk.betterSqlite3Engine(driver, dbPath);
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
const why = err instanceof Error ? err.message.split("\n")[0] : String(err);
|
|
243
|
+
ctx.stderr(`[catalyst-skills] better-sqlite3 unavailable (${why}); using node:sqlite`);
|
|
244
|
+
return deps.readonly ? sdk.nodeSqliteReadonlyEngine(dbPath) : sdk.nodeSqliteEngine(dbPath);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
export const SNAPSHOT_BASE_BACKOFF_MS = 30_000;
|
|
248
|
+
export const SNAPSHOT_MAX_BACKOFF_MS = 900_000; // 15 minutes
|
|
249
|
+
export const SNAPSHOT_MAX_FAILURES = 5;
|
|
250
|
+
/** How long a live session must hold before the supervisor calls the writer recovered. A warm boot
|
|
251
|
+
* emits no "resyncing", so a writer that recovered WITHOUT a re-seed has no transition to reset its
|
|
252
|
+
* count on; staying live is that signal. The failing warm loop never reaches it — its resync demand
|
|
253
|
+
* fails and tears the attempt down first (CTC-2499). */
|
|
254
|
+
export const SNAPSHOT_LIVE_STABLE_MS = 60_000;
|
|
255
|
+
export const REPLICA_RESTART_COMMAND = "catalyst-skills replica start --detach";
|
|
256
|
+
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
257
|
+
function defaultWaitForStop() {
|
|
258
|
+
return new Promise((resolve) => {
|
|
259
|
+
const done = () => resolve();
|
|
260
|
+
process.once("SIGINT", done);
|
|
261
|
+
process.once("SIGTERM", done);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
/** Full jitter (AWS "Exponential Backoff and Jitter"): a uniform draw from [0, bound), where the
|
|
265
|
+
* bound doubles per consecutive failure and is clamped at the cap. */
|
|
266
|
+
export function backoffDelayMs(consecutiveFailures, opts) {
|
|
267
|
+
const bound = Math.min(opts.baseMs * 2 ** Math.max(0, consecutiveFailures - 1), opts.maxMs);
|
|
268
|
+
return Math.floor(opts.random() * bound);
|
|
269
|
+
}
|
|
270
|
+
function releaseOwnPidfile(dbPath) {
|
|
271
|
+
try {
|
|
272
|
+
if (readPidfile(dbPath) === process.pid)
|
|
273
|
+
unlinkSync(pidfilePath(dbPath));
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
// pidfile already gone
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
function messageOf(err) {
|
|
280
|
+
return err instanceof Error ? err.message : String(err);
|
|
281
|
+
}
|
|
282
|
+
/** Classify a `start()` rejection: null when it is an ordinary failed snapshot pull and the
|
|
283
|
+
* supervisor should count it, back off and retry. */
|
|
284
|
+
export function classifyStartError(err) {
|
|
285
|
+
const reason = messageOf(err);
|
|
286
|
+
if (err?.name === "ReplicaAccountMismatchError")
|
|
287
|
+
return { reason, recordable: false };
|
|
288
|
+
if (reason.includes("another writer owns this replica"))
|
|
289
|
+
return { reason, recordable: false };
|
|
290
|
+
// start()'s own lifecycle guards: a caller bug, never a tenant-side failure.
|
|
291
|
+
if (reason.includes("start() already called") || reason.includes("start() after close()"))
|
|
292
|
+
return { reason, recordable: false };
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
export async function cmdReplica(args, ctx, deps = {}) {
|
|
296
|
+
const [sub, ...rest] = positionals(args);
|
|
297
|
+
if (!sub)
|
|
298
|
+
throw new UsageError("replica needs a subcommand: start | stop | status | sql | schema");
|
|
299
|
+
if (sub === "status")
|
|
300
|
+
return cmdStatus(args, ctx);
|
|
301
|
+
const cfg = loadConfig(ctx.home);
|
|
302
|
+
if (!cfg)
|
|
303
|
+
throw new CliError("not connected yet — run login first", "not-configured");
|
|
304
|
+
const dbPath = flagString(args, "db") ?? replicaDbPath(cfg, ctx.home, ctx.env);
|
|
305
|
+
switch (sub) {
|
|
306
|
+
case "start":
|
|
307
|
+
return cmdStart(args, ctx, cfg, dbPath, deps);
|
|
308
|
+
case "stop":
|
|
309
|
+
return cmdStop(ctx, dbPath);
|
|
310
|
+
case "sql":
|
|
311
|
+
return cmdSql(args, ctx, dbPath, rest.join(" "), deps);
|
|
312
|
+
case "schema":
|
|
313
|
+
return cmdSchema(args, ctx, dbPath, rest[0]);
|
|
314
|
+
default:
|
|
315
|
+
throw new UsageError(`unknown replica subcommand: ${sub}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
async function cmdStatus(args, ctx) {
|
|
319
|
+
let cfg;
|
|
320
|
+
try {
|
|
321
|
+
cfg = loadConfig(ctx.home);
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
cfg = null;
|
|
325
|
+
}
|
|
326
|
+
const status = replicaStatus(ctx, cfg, {
|
|
327
|
+
staleMs: flagInt(args, "stale-ms", DEFAULT_STALE_MS),
|
|
328
|
+
dbPath: flagString(args, "db"),
|
|
329
|
+
});
|
|
330
|
+
if (flagBool(args, "probe") && cfg && status.verdict !== "not-configured") {
|
|
331
|
+
const head = await apiClient(cfg, ctx).getJson("/api/v1/snapshot", { query: { head: 1 } });
|
|
332
|
+
if (typeof head.body.cursor === "number") {
|
|
333
|
+
status.head = head.body.cursor;
|
|
334
|
+
status.lag = status.cursor === null ? head.body.cursor : head.body.cursor - status.cursor;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
ctx.stdout(args.json ? JSON.stringify(status) : statusLine(status));
|
|
338
|
+
return status.exitCode;
|
|
339
|
+
}
|
|
340
|
+
async function cmdStart(args, ctx, cfg, dbPath, deps) {
|
|
341
|
+
if (flagBool(args, "detach")) {
|
|
342
|
+
const argv = (deps.argv ?? process.argv.slice(1)).filter((a) => a !== "--detach");
|
|
343
|
+
const { pid } = (deps.detach ?? detachSelf)(argv, ctx.env);
|
|
344
|
+
writeFileSync(pidfilePath(dbPath), `${pid}\n`);
|
|
345
|
+
ctx.stdout(`replica writer started in the background (pid ${pid}, db ${dbPath}); check with: catalyst-skills replica status`);
|
|
346
|
+
return 0;
|
|
347
|
+
}
|
|
348
|
+
const sdk = await loadSdk();
|
|
349
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
350
|
+
const random = deps.random ?? Math.random;
|
|
351
|
+
const baseMs = deps.snapshotRetry?.baseBackoffMs ?? SNAPSHOT_BASE_BACKOFF_MS;
|
|
352
|
+
const maxMs = deps.snapshotRetry?.maxBackoffMs ?? SNAPSHOT_MAX_BACKOFF_MS;
|
|
353
|
+
const maxFailures = deps.snapshotRetry?.maxFailures ?? SNAPSHOT_MAX_FAILURES;
|
|
354
|
+
const liveStableMs = deps.snapshotRetry?.liveStableMs ?? SNAPSHOT_LIVE_STABLE_MS;
|
|
355
|
+
// Registered ONCE, not per attempt, so a supervised run does not pile up signal handlers.
|
|
356
|
+
let stopRequested = false;
|
|
357
|
+
const stopSignal = (deps.waitForStop ?? defaultWaitForStop)().then(() => {
|
|
358
|
+
stopRequested = true;
|
|
359
|
+
});
|
|
360
|
+
let eventSync = null;
|
|
361
|
+
let failures = 0;
|
|
362
|
+
/** A new run never inherits an old run's verdict — but it discards it only once it OWNS the
|
|
363
|
+
* replica. `start()` claims the writer lock before it does anything else, so an attempt that gets
|
|
364
|
+
* past that lock is ours; clearing up front deleted a LIVE writer's record whenever a second
|
|
365
|
+
* `replica start` lost the lock race (CTC-2499). */
|
|
366
|
+
let recordOwned = false;
|
|
367
|
+
const takeOwnershipOfRecord = () => {
|
|
368
|
+
if (recordOwned)
|
|
369
|
+
return;
|
|
370
|
+
recordOwned = true;
|
|
371
|
+
clearWriterState(dbPath);
|
|
372
|
+
};
|
|
373
|
+
const resetFailures = () => {
|
|
374
|
+
if (failures === 0)
|
|
375
|
+
return;
|
|
376
|
+
failures = 0;
|
|
377
|
+
writeWriterState(dbPath, { pid: process.pid, consecutiveFailures: 0, lastError: null, lastFailureAt: null, stopped: null }, ctx.now().getTime());
|
|
378
|
+
};
|
|
379
|
+
for (;;) {
|
|
380
|
+
const engine = await engineFor(sdk, dbPath, ctx, deps.engineDeps);
|
|
381
|
+
let seedInFlight = false; // a /snapshot pull is running RIGHT NOW
|
|
382
|
+
let seedCompleted = false; // ...and the last one finished; what follows belongs to the socket
|
|
383
|
+
let attemptError = null;
|
|
384
|
+
// A box rather than a `let`: TypeScript narrows a captured `let` initialised to null down to
|
|
385
|
+
// `never`, and the supervisor below has to read what these callbacks wrote.
|
|
386
|
+
const fatal = { stop: null };
|
|
387
|
+
let signalFailure;
|
|
388
|
+
const failed = new Promise((res) => (signalFailure = res));
|
|
389
|
+
const replica = new sdk.CatalystReplica({
|
|
390
|
+
baseUrl: apiBase(cfg),
|
|
391
|
+
account: cfg.account,
|
|
392
|
+
accountSource: "declared",
|
|
393
|
+
auth: authStrategyFor(ctx, cfg),
|
|
394
|
+
dbPath,
|
|
395
|
+
engine,
|
|
396
|
+
fetchImpl: ctx.fetch,
|
|
397
|
+
wsFactory: deps.wsFactory,
|
|
398
|
+
log: (level, msg, extra) => ctx.stderr(`[replica ${level}] ${msg}${extra ? ` ${safeJson(extra)}` : ""}`),
|
|
399
|
+
onStatus: (s) => {
|
|
400
|
+
ctx.stderr(`[replica] status=${s}`);
|
|
401
|
+
// A "live" that does NOT follow a re-seed is just the socket coming up — the warm failing
|
|
402
|
+
// loop passes through it every cycle, so it must not reset the count (CTC-2499).
|
|
403
|
+
if (s === "resyncing") {
|
|
404
|
+
seedInFlight = true;
|
|
405
|
+
seedCompleted = false;
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
// openSocket() runs only after the seed has committed its cursor, so "connecting" is the
|
|
409
|
+
// SDK's one observable "the snapshot completed" edge. Everything after it is socket work: a
|
|
410
|
+
// blocked WebSocket upgrade is not a failed snapshot pull (CTC-2499).
|
|
411
|
+
if (s === "connecting") {
|
|
412
|
+
if (seedInFlight) {
|
|
413
|
+
seedInFlight = false;
|
|
414
|
+
seedCompleted = true;
|
|
415
|
+
}
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (s === "live") {
|
|
419
|
+
if (seedCompleted) {
|
|
420
|
+
seedCompleted = false;
|
|
421
|
+
resetFailures();
|
|
422
|
+
}
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if ((s === "reconnecting" || s === "error") && seedInFlight && attemptError === null) {
|
|
426
|
+
seedInFlight = false;
|
|
427
|
+
attemptError = "the snapshot pull failed or ended early";
|
|
428
|
+
// SYNCHRONOUS inside onStatus: LiveSyncClient.stop() sets `stopped = true` as its first
|
|
429
|
+
// statement, and scheduleReconnect() — the very next statement in runResync() — returns
|
|
430
|
+
// early on it. This is what keeps the SDK from re-entering its own loop.
|
|
431
|
+
void replica.close().catch(() => { });
|
|
432
|
+
signalFailure();
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (s === "auth-required" && attemptError === null) {
|
|
436
|
+
// A refused credential is not something to retry against the tenant at all: it is stopped
|
|
437
|
+
// and recorded, not counted towards maxFailures (CTC-2499).
|
|
438
|
+
seedInFlight = false;
|
|
439
|
+
seedCompleted = false;
|
|
440
|
+
attemptError = "the tenant refused this machine's credential on /snapshot";
|
|
441
|
+
fatal.stop = { reason: attemptError, recordable: true };
|
|
442
|
+
void replica.close().catch(() => { });
|
|
443
|
+
signalFailure();
|
|
444
|
+
}
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
// Always give start() a handler, so a rejection that loses the race is never unhandled.
|
|
448
|
+
const started = replica.start().then(() => "started", (err) => {
|
|
449
|
+
fatal.stop ??= classifyStartError(err);
|
|
450
|
+
attemptError ??= messageOf(err);
|
|
451
|
+
return "failed";
|
|
452
|
+
});
|
|
453
|
+
const outcome = await Promise.race([started, failed.then(() => "failed"), stopSignal.then(() => "stop")]);
|
|
454
|
+
// Something the SDK raised so the caller would STOP: retrying it is a hot loop against a wall,
|
|
455
|
+
// and two of the three fire while another process or another tenant owns this replica — so the
|
|
456
|
+
// record is left exactly as its owner wrote it (CTC-2499).
|
|
457
|
+
const stopNow = fatal.stop;
|
|
458
|
+
if (outcome === "failed" && stopNow !== null) {
|
|
459
|
+
await replica.close().catch(() => { });
|
|
460
|
+
await eventSync?.stop();
|
|
461
|
+
releaseOwnPidfile(dbPath);
|
|
462
|
+
if (stopNow.recordable) {
|
|
463
|
+
takeOwnershipOfRecord();
|
|
464
|
+
const nowMs = ctx.now().getTime();
|
|
465
|
+
writeWriterState(dbPath, { pid: process.pid, consecutiveFailures: failures, lastError: stopNow.reason, lastFailureAt: nowMs, stopped: { at: nowMs, reason: stopNow.reason, restartWith: REPLICA_RESTART_COMMAND } }, nowMs);
|
|
466
|
+
}
|
|
467
|
+
ctx.stderr(`[replica] stopping without a retry: ${stopNow.reason}`);
|
|
468
|
+
ctx.stdout(`replica writer stopped without retrying: ${stopNow.reason} ` +
|
|
469
|
+
`The replica is optional — every read still works through the API.`);
|
|
470
|
+
return 1;
|
|
471
|
+
}
|
|
472
|
+
// Past the writer lock, so this run owns the replica and may discard a previous run's verdict.
|
|
473
|
+
// A "stop" outcome proves nothing — it can race the lock claim — so it takes no ownership.
|
|
474
|
+
if (outcome !== "stop")
|
|
475
|
+
takeOwnershipOfRecord();
|
|
476
|
+
if (outcome === "started") {
|
|
477
|
+
// The event cache is started once for the whole supervised run, not per attempt.
|
|
478
|
+
if (eventSync === null) {
|
|
479
|
+
eventSync = await createEventSync(ctx, { loadSdk: deps.loadEventsSdk });
|
|
480
|
+
void eventSync.start().catch((error) => ctx.stderr(`[events] sync failed: ${messageOf(error)}; replica remains live`));
|
|
481
|
+
}
|
|
482
|
+
ctx.stdout(`replica live at ${dbPath} (cursor ${replica.cursor ?? "none"}); event cache active — Ctrl-C to stop`);
|
|
483
|
+
// A warm boot never emits "resyncing", so a writer that recovered without a re-seed has no
|
|
484
|
+
// transition to reset its count on, and its stale count would both mis-report a healthy writer
|
|
485
|
+
// and accumulate NON-consecutive failures up to the stop threshold. A live session that holds
|
|
486
|
+
// for liveStableMs is that missing signal (CTC-2499).
|
|
487
|
+
if (failures > 0) {
|
|
488
|
+
const settled = await Promise.race([
|
|
489
|
+
failed.then(() => "failed"),
|
|
490
|
+
stopSignal.then(() => "stop"),
|
|
491
|
+
sleep(liveStableMs).then(() => "stable"),
|
|
492
|
+
]);
|
|
493
|
+
if (settled === "stable")
|
|
494
|
+
resetFailures();
|
|
495
|
+
}
|
|
496
|
+
// start() resolves at the FIRST "live", which is the socket, not the seed — keep watching.
|
|
497
|
+
await Promise.race([failed, stopSignal]);
|
|
498
|
+
}
|
|
499
|
+
await replica.close().catch(() => { });
|
|
500
|
+
if (stopRequested)
|
|
501
|
+
break;
|
|
502
|
+
failures += 1;
|
|
503
|
+
const lastError = attemptError ?? "the snapshot pull failed";
|
|
504
|
+
const nowMs = ctx.now().getTime();
|
|
505
|
+
const stopped = failures >= maxFailures
|
|
506
|
+
? { at: nowMs, reason: `${failures} consecutive snapshot failures`, restartWith: "catalyst-skills replica start --detach" }
|
|
507
|
+
: null;
|
|
508
|
+
writeWriterState(dbPath, { pid: process.pid, consecutiveFailures: failures, lastError, lastFailureAt: nowMs, stopped }, nowMs);
|
|
509
|
+
if (stopped) {
|
|
510
|
+
await eventSync?.stop();
|
|
511
|
+
releaseOwnPidfile(dbPath);
|
|
512
|
+
ctx.stderr(`[replica] stopping: ${stopped.reason}; last error: ${lastError}`);
|
|
513
|
+
ctx.stdout(`replica writer stopped after ${failures} consecutive snapshot failures (last error: ${lastError}). ` +
|
|
514
|
+
`The replica is optional — every read still works through the API. ` +
|
|
515
|
+
`Restart it with: ${stopped.restartWith}`);
|
|
516
|
+
return 1;
|
|
517
|
+
}
|
|
518
|
+
const delay = backoffDelayMs(failures, { baseMs, maxMs, random });
|
|
519
|
+
ctx.stderr(`[replica] snapshot failure ${failures} of ${maxFailures}: ${lastError}; retrying in ${delay}ms`);
|
|
520
|
+
await Promise.race([sleep(delay), stopSignal]);
|
|
521
|
+
if (stopRequested)
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
await eventSync?.stop();
|
|
525
|
+
releaseOwnPidfile(dbPath);
|
|
526
|
+
if (recordOwned)
|
|
527
|
+
clearWriterState(dbPath); // never a record this run had no right to
|
|
528
|
+
ctx.stdout("replica stopped");
|
|
529
|
+
return 0;
|
|
530
|
+
}
|
|
531
|
+
function cmdStop(ctx, dbPath) {
|
|
532
|
+
const pid = readPidfile(dbPath);
|
|
533
|
+
if (pid === null) {
|
|
534
|
+
ctx.stdout(`no pidfile at ${pidfilePath(dbPath)} — nothing to stop (a foreground writer is stopped with Ctrl-C)`);
|
|
535
|
+
return 1;
|
|
536
|
+
}
|
|
537
|
+
if (!pidAlive(pid)) {
|
|
538
|
+
unlinkSync(pidfilePath(dbPath));
|
|
539
|
+
ctx.stdout(`pid ${pid} is not running; removed the stale pidfile`);
|
|
540
|
+
return 1;
|
|
541
|
+
}
|
|
542
|
+
process.kill(pid, "SIGTERM");
|
|
543
|
+
try {
|
|
544
|
+
unlinkSync(pidfilePath(dbPath));
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
// already removed by the writer
|
|
548
|
+
}
|
|
549
|
+
ctx.stdout(`sent SIGTERM to replica writer pid ${pid}`);
|
|
550
|
+
return 0;
|
|
551
|
+
}
|
|
552
|
+
const SELECT_ONLY = /^\s*(select|with)\b/i;
|
|
553
|
+
export function assertSingleSelect(sql) {
|
|
554
|
+
const trimmed = sql.trim().replace(/;\s*$/, "");
|
|
555
|
+
if (!trimmed)
|
|
556
|
+
throw new UsageError('replica sql needs a query: replica sql "select ..."');
|
|
557
|
+
if (!SELECT_ONLY.test(trimmed) || trimmed.includes(";")) {
|
|
558
|
+
throw new UsageError("replica sql runs exactly one SELECT (or WITH ... SELECT); nothing else is accepted on a read-only handle");
|
|
559
|
+
}
|
|
560
|
+
return trimmed;
|
|
561
|
+
}
|
|
562
|
+
async function cmdSql(args, ctx, dbPath, sql, deps) {
|
|
563
|
+
const query = assertSingleSelect(sql);
|
|
564
|
+
if (!existsSync(dbPath))
|
|
565
|
+
throw new CliError(`no replica at ${dbPath} — start it with: catalyst-skills replica start --detach`, "replica-absent", 3);
|
|
566
|
+
const sdk = await loadSdk();
|
|
567
|
+
const engine = await engineFor(sdk, dbPath, ctx, { ...deps.engineDeps, readonly: true });
|
|
568
|
+
const replica = await sdk.CatalystReplica.openReadOnly({ dbPath, engine, log: () => { } });
|
|
569
|
+
try {
|
|
570
|
+
const rows = replica.sql.exec(query).toArray();
|
|
571
|
+
ctx.stdout(JSON.stringify(rows, null, args.json ? 0 : 2));
|
|
572
|
+
return 0;
|
|
573
|
+
}
|
|
574
|
+
finally {
|
|
575
|
+
await replica.close();
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
async function cmdSchema(args, ctx, dbPath, table) {
|
|
579
|
+
if (!existsSync(dbPath))
|
|
580
|
+
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);
|
|
581
|
+
const db = new (loadSqlite().DatabaseSync)(dbPath, { readOnly: true });
|
|
582
|
+
try {
|
|
583
|
+
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);
|
|
584
|
+
const wanted = table ? tables.filter((t) => t === table) : tables;
|
|
585
|
+
if (table && wanted.length === 0)
|
|
586
|
+
throw new CliError(`no table "${table}" in the replica (tables: ${tables.join(", ")})`, "table-unknown");
|
|
587
|
+
const out = {};
|
|
588
|
+
for (const t of wanted) {
|
|
589
|
+
out[t] = db.prepare(`PRAGMA table_info("${t.replace(/"/g, '""')}")`).all().map((c) => ({
|
|
590
|
+
name: c.name,
|
|
591
|
+
type: c.type,
|
|
592
|
+
notnull: c.notnull,
|
|
593
|
+
pk: c.pk,
|
|
594
|
+
}));
|
|
595
|
+
}
|
|
596
|
+
if (args.json)
|
|
597
|
+
ctx.stdout(JSON.stringify(out));
|
|
598
|
+
else
|
|
599
|
+
for (const [t, cols] of Object.entries(out))
|
|
600
|
+
ctx.stdout(`${t}: ${cols.map((c) => `${c.name} ${c.type || "ANY"}${c.pk ? " PK" : ""}`).join(", ")}`);
|
|
601
|
+
return 0;
|
|
602
|
+
}
|
|
603
|
+
finally {
|
|
604
|
+
db.close();
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
function safeJson(v) {
|
|
608
|
+
try {
|
|
609
|
+
return JSON.stringify(v);
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
return String(v);
|
|
613
|
+
}
|
|
614
|
+
}
|