@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.
Files changed (157) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/LICENSE +21 -0
  3. package/README.md +205 -0
  4. package/bin/catalyst-skills.js +8 -0
  5. package/bin/catalyst.js +5 -0
  6. package/bin/launch.js +154 -0
  7. package/dist/args.js +280 -0
  8. package/dist/ask.js +161 -0
  9. package/dist/browser.js +20 -0
  10. package/dist/cli.js +397 -0
  11. package/dist/config.js +241 -0
  12. package/dist/contract-types.js +4 -0
  13. package/dist/contract.js +184 -0
  14. package/dist/detach.js +10 -0
  15. package/dist/environment.js +207 -0
  16. package/dist/errors.js +27 -0
  17. package/dist/events.js +106 -0
  18. package/dist/execution.js +451 -0
  19. package/dist/oauth.js +300 -0
  20. package/dist/pagination.js +76 -0
  21. package/dist/prompt.js +35 -0
  22. package/dist/published.js +79 -0
  23. package/dist/query.js +248 -0
  24. package/dist/ready.js +380 -0
  25. package/dist/release.js +142 -0
  26. package/dist/replica.js +614 -0
  27. package/dist/runtime-store.js +135 -0
  28. package/dist/runtime-verb.js +66 -0
  29. package/dist/runtime.js +87 -0
  30. package/dist/sdk.js +29 -0
  31. package/dist/secret.js +190 -0
  32. package/dist/semver.js +18 -0
  33. package/dist/skill-shape.js +189 -0
  34. package/dist/skills.js +129 -0
  35. package/dist/transport.js +205 -0
  36. package/dist/ts-deps-loader.js +113 -0
  37. package/dist/watch/consumer.js +141 -0
  38. package/dist/watch/cursor-file.js +62 -0
  39. package/dist/watch.js +175 -0
  40. package/dist/write.js +224 -0
  41. package/package.json +60 -0
  42. package/skills/catalyst-github/SKILL.md +35 -0
  43. package/skills/catalyst-github/agents/openai.yaml +6 -0
  44. package/skills/catalyst-github/agents/portability.yaml +4 -0
  45. package/skills/catalyst-github/references/is-it-mergeable.md +57 -0
  46. package/skills/catalyst-github/references/what-a-pr-accumulates.md +61 -0
  47. package/skills/catalyst-github/scripts/is-it-mergeable.mjs +124 -0
  48. package/skills/catalyst-github/scripts/lib/cli.mjs +103 -0
  49. package/skills/catalyst-github/scripts/lib/credential.mjs +29 -0
  50. package/skills/catalyst-github/scripts/lib/pull.mjs +82 -0
  51. package/skills/catalyst-github/scripts/read-pr.mjs +97 -0
  52. package/skills/catalyst-linear/SKILL.md +43 -0
  53. package/skills/catalyst-linear/agents/openai.yaml +6 -0
  54. package/skills/catalyst-linear/agents/portability.yaml +5 -0
  55. package/skills/catalyst-linear/references/reading-a-ticket.md +52 -0
  56. package/skills/catalyst-linear/references/what-a-ticket-accumulates.md +53 -0
  57. package/skills/catalyst-linear/references/writing-to-linear.md +43 -0
  58. package/skills/catalyst-linear/scripts/comment.mjs +59 -0
  59. package/skills/catalyst-linear/scripts/create-ticket.mjs +44 -0
  60. package/skills/catalyst-linear/scripts/label.mjs +48 -0
  61. package/skills/catalyst-linear/scripts/lib/cli.mjs +164 -0
  62. package/skills/catalyst-linear/scripts/lib/credential.mjs +29 -0
  63. package/skills/catalyst-linear/scripts/move.mjs +41 -0
  64. package/skills/catalyst-linear/scripts/read-ticket.mjs +93 -0
  65. package/skills/catalyst-linear/scripts/search.mjs +49 -0
  66. package/skills/catalyst-onboard/SKILL.md +57 -0
  67. package/skills/catalyst-onboard/agents/openai.yaml +6 -0
  68. package/skills/catalyst-onboard/agents/portability.yaml +5 -0
  69. package/skills/catalyst-onboard/references/declaring-a-repository.md +23 -0
  70. package/skills/catalyst-onboard/references/skill-sources.md +35 -0
  71. package/skills/catalyst-onboard/references/the-one-path.md +149 -0
  72. package/skills/catalyst-onboard/references/what-a-phase-needs.md +46 -0
  73. package/skills/catalyst-onboard/references/what-the-browser-owns.md +50 -0
  74. package/skills/catalyst-onboard/references/who-fixes-what.md +44 -0
  75. package/skills/catalyst-onboard/scripts/lib/cli.mjs +117 -0
  76. package/skills/catalyst-onboard/scripts/lib/credential.mjs +29 -0
  77. package/skills/catalyst-onboard/scripts/where-am-i.mjs +345 -0
  78. package/skills/catalyst-setup/SKILL.md +36 -0
  79. package/skills/catalyst-setup/agents/openai.yaml +6 -0
  80. package/skills/catalyst-setup/agents/portability.yaml +4 -0
  81. package/skills/catalyst-setup/references/what-each-check-means.md +88 -0
  82. package/skills/catalyst-setup/scripts/check.mjs +75 -0
  83. package/skills/catalyst-setup/scripts/lib/cli.mjs +103 -0
  84. package/skills/catalyst-setup/scripts/lib/credential.mjs +29 -0
  85. package/skills/catalyst-setup/scripts/replica-status.mjs +46 -0
  86. package/skills/connect-me/SKILL.md +63 -0
  87. package/skills/connect-me/agents/openai.yaml +6 -0
  88. package/skills/connect-me/agents/portability.yaml +5 -0
  89. package/skills/connect-me/references/keeping-the-replica-running.md +88 -0
  90. package/skills/connect-me/scripts/lib/cli.mjs +185 -0
  91. package/skills/connect-me/scripts/lib/credential.mjs +29 -0
  92. package/skills/connect-me/scripts/verify-connection.mjs +68 -0
  93. package/skills/how-catalyst-works/SKILL.md +43 -0
  94. package/skills/how-catalyst-works/agents/openai.yaml +6 -0
  95. package/skills/how-catalyst-works/agents/portability.yaml +4 -0
  96. package/skills/how-catalyst-works/references/coding-accounts.md +51 -0
  97. package/skills/how-catalyst-works/references/stages-and-mapping.md +56 -0
  98. package/skills/how-catalyst-works/references/the-ladder.md +41 -0
  99. package/skills/how-catalyst-works/references/what-catalyst-is.md +30 -0
  100. package/skills/how-catalyst-works/references/what-runs-next.md +77 -0
  101. package/skills/how-catalyst-works/references/when-a-phase-fails.md +57 -0
  102. package/skills/how-catalyst-works/scripts/explain-ticket.mjs +41 -0
  103. package/skills/how-catalyst-works/scripts/lib/cli.mjs +164 -0
  104. package/skills/how-catalyst-works/scripts/lib/credential.mjs +29 -0
  105. package/skills/how-catalyst-works/scripts/show-my-map.mjs +94 -0
  106. package/skills/how-catalyst-works/scripts/whats-running.mjs +65 -0
  107. package/skills/run-this-project/SKILL.md +45 -0
  108. package/skills/run-this-project/agents/openai.yaml +6 -0
  109. package/skills/run-this-project/agents/portability.yaml +5 -0
  110. package/skills/run-this-project/assets/stall-policy.json +15 -0
  111. package/skills/run-this-project/references/making-work-ready.md +60 -0
  112. package/skills/run-this-project/references/reacting-to-events.md +76 -0
  113. package/skills/run-this-project/references/stalls-and-escalation.md +63 -0
  114. package/skills/run-this-project/scripts/lib/cli.mjs +185 -0
  115. package/skills/run-this-project/scripts/lib/credential.mjs +29 -0
  116. package/skills/run-this-project/scripts/make-ready.mjs +64 -0
  117. package/skills/run-this-project/scripts/scope-status.mjs +0 -0
  118. package/skills/run-this-project/scripts/watch-scope.mjs +61 -0
  119. package/skills/unstick/SKILL.md +41 -0
  120. package/skills/unstick/agents/openai.yaml +6 -0
  121. package/skills/unstick/agents/portability.yaml +5 -0
  122. package/skills/unstick/references/playbook.md +51 -0
  123. package/skills/unstick/scripts/lib/cli.mjs +135 -0
  124. package/skills/unstick/scripts/lib/credential.mjs +29 -0
  125. package/skills/unstick/scripts/unstick.mjs +57 -0
  126. package/skills/what-needs-me/SKILL.md +41 -0
  127. package/skills/what-needs-me/agents/openai.yaml +6 -0
  128. package/skills/what-needs-me/agents/portability.yaml +5 -0
  129. package/skills/what-needs-me/references/raising-a-decision.md +41 -0
  130. package/skills/what-needs-me/references/reading-the-inbox.md +38 -0
  131. package/skills/what-needs-me/references/settling-an-answer.md +37 -0
  132. package/skills/what-needs-me/scripts/inbox.mjs +56 -0
  133. package/skills/what-needs-me/scripts/lib/cli.mjs +135 -0
  134. package/skills/what-needs-me/scripts/lib/credential.mjs +29 -0
  135. package/skills/what-needs-me/scripts/raise.mjs +53 -0
  136. package/skills/what-needs-me/scripts/settle.mjs +73 -0
  137. package/skills/whats-happening/SKILL.md +43 -0
  138. package/skills/whats-happening/agents/openai.yaml +6 -0
  139. package/skills/whats-happening/agents/portability.yaml +4 -0
  140. package/skills/whats-happening/assets/status-reply.json +77 -0
  141. package/skills/whats-happening/references/reading-the-board.md +43 -0
  142. package/skills/whats-happening/references/reprioritising.md +37 -0
  143. package/skills/whats-happening/references/routing-work.md +36 -0
  144. package/skills/whats-happening/references/status-reply.md +34 -0
  145. package/skills/whats-happening/references/why-is-it-stuck.md +62 -0
  146. package/skills/whats-happening/scripts/explain.mjs +28 -0
  147. package/skills/whats-happening/scripts/lib/cli.mjs +135 -0
  148. package/skills/whats-happening/scripts/lib/credential.mjs +29 -0
  149. package/skills/whats-happening/scripts/snapshot.mjs +149 -0
  150. package/vendor/README.md +9 -0
  151. package/vendor/paths/index.d.ts +85 -0
  152. package/vendor/paths/index.js +148 -0
  153. package/vendor/paths/legacy-installer.d.ts +36 -0
  154. package/vendor/paths/legacy-installer.js +154 -0
  155. package/vendor/paths/node.d.ts +18 -0
  156. package/vendor/paths/node.js +102 -0
  157. package/vendor/paths/provenance.json +17 -0
@@ -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
+ }