@catalyst-cloud/catalyst-skills 0.5.0 → 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 (127) hide show
  1. package/README.md +11 -178
  2. package/bin/catalyst-skills.js +3 -49
  3. package/bin/catalyst.js +3 -0
  4. package/package.json +8 -29
  5. package/CHANGELOG.md +0 -41
  6. package/LICENSE +0 -21
  7. package/dist/args.js +0 -261
  8. package/dist/ask.js +0 -158
  9. package/dist/browser.js +0 -20
  10. package/dist/cli.js +0 -356
  11. package/dist/config.js +0 -148
  12. package/dist/contract-types.js +0 -4
  13. package/dist/contract.js +0 -178
  14. package/dist/detach.js +0 -10
  15. package/dist/errors.js +0 -27
  16. package/dist/events.js +0 -106
  17. package/dist/execution.js +0 -369
  18. package/dist/oauth.js +0 -300
  19. package/dist/prompt.js +0 -35
  20. package/dist/query.js +0 -225
  21. package/dist/ready.js +0 -163
  22. package/dist/release.js +0 -142
  23. package/dist/replica.js +0 -325
  24. package/dist/sdk.js +0 -26
  25. package/dist/skill-shape.js +0 -179
  26. package/dist/skills.js +0 -78
  27. package/dist/transport.js +0 -205
  28. package/dist/ts-deps-loader.js +0 -68
  29. package/dist/watch/consumer.js +0 -141
  30. package/dist/watch/cursor-file.js +0 -62
  31. package/dist/watch.js +0 -175
  32. package/dist/write.js +0 -224
  33. package/skills/catalyst-github/SKILL.md +0 -35
  34. package/skills/catalyst-github/agents/openai.yaml +0 -6
  35. package/skills/catalyst-github/agents/portability.yaml +0 -4
  36. package/skills/catalyst-github/references/is-it-mergeable.md +0 -57
  37. package/skills/catalyst-github/references/what-a-pr-accumulates.md +0 -61
  38. package/skills/catalyst-github/scripts/is-it-mergeable.mjs +0 -124
  39. package/skills/catalyst-github/scripts/lib/cli.mjs +0 -103
  40. package/skills/catalyst-github/scripts/lib/credential.mjs +0 -29
  41. package/skills/catalyst-github/scripts/lib/pull.mjs +0 -82
  42. package/skills/catalyst-github/scripts/read-pr.mjs +0 -97
  43. package/skills/catalyst-linear/SKILL.md +0 -43
  44. package/skills/catalyst-linear/agents/openai.yaml +0 -6
  45. package/skills/catalyst-linear/agents/portability.yaml +0 -5
  46. package/skills/catalyst-linear/references/reading-a-ticket.md +0 -50
  47. package/skills/catalyst-linear/references/what-a-ticket-accumulates.md +0 -53
  48. package/skills/catalyst-linear/references/writing-to-linear.md +0 -43
  49. package/skills/catalyst-linear/scripts/comment.mjs +0 -59
  50. package/skills/catalyst-linear/scripts/create-ticket.mjs +0 -44
  51. package/skills/catalyst-linear/scripts/label.mjs +0 -48
  52. package/skills/catalyst-linear/scripts/lib/cli.mjs +0 -164
  53. package/skills/catalyst-linear/scripts/lib/credential.mjs +0 -29
  54. package/skills/catalyst-linear/scripts/move.mjs +0 -41
  55. package/skills/catalyst-linear/scripts/read-ticket.mjs +0 -93
  56. package/skills/catalyst-linear/scripts/search.mjs +0 -49
  57. package/skills/catalyst-setup/SKILL.md +0 -32
  58. package/skills/catalyst-setup/agents/openai.yaml +0 -6
  59. package/skills/catalyst-setup/agents/portability.yaml +0 -4
  60. package/skills/catalyst-setup/references/what-each-check-means.md +0 -66
  61. package/skills/catalyst-setup/scripts/check.mjs +0 -75
  62. package/skills/catalyst-setup/scripts/lib/cli.mjs +0 -103
  63. package/skills/catalyst-setup/scripts/lib/credential.mjs +0 -29
  64. package/skills/catalyst-setup/scripts/replica-status.mjs +0 -46
  65. package/skills/connect-me/SKILL.md +0 -63
  66. package/skills/connect-me/agents/openai.yaml +0 -6
  67. package/skills/connect-me/agents/portability.yaml +0 -5
  68. package/skills/connect-me/references/keeping-the-replica-running.md +0 -88
  69. package/skills/connect-me/scripts/lib/cli.mjs +0 -185
  70. package/skills/connect-me/scripts/lib/credential.mjs +0 -29
  71. package/skills/connect-me/scripts/verify-connection.mjs +0 -68
  72. package/skills/how-catalyst-works/SKILL.md +0 -42
  73. package/skills/how-catalyst-works/agents/openai.yaml +0 -6
  74. package/skills/how-catalyst-works/agents/portability.yaml +0 -4
  75. package/skills/how-catalyst-works/references/coding-accounts.md +0 -51
  76. package/skills/how-catalyst-works/references/stages-and-mapping.md +0 -54
  77. package/skills/how-catalyst-works/references/the-ladder.md +0 -41
  78. package/skills/how-catalyst-works/references/what-runs-next.md +0 -77
  79. package/skills/how-catalyst-works/references/when-a-phase-fails.md +0 -57
  80. package/skills/how-catalyst-works/scripts/explain-ticket.mjs +0 -41
  81. package/skills/how-catalyst-works/scripts/lib/cli.mjs +0 -164
  82. package/skills/how-catalyst-works/scripts/lib/credential.mjs +0 -29
  83. package/skills/how-catalyst-works/scripts/show-my-map.mjs +0 -94
  84. package/skills/how-catalyst-works/scripts/whats-running.mjs +0 -65
  85. package/skills/run-this-project/SKILL.md +0 -45
  86. package/skills/run-this-project/agents/openai.yaml +0 -6
  87. package/skills/run-this-project/agents/portability.yaml +0 -5
  88. package/skills/run-this-project/assets/stall-policy.json +0 -15
  89. package/skills/run-this-project/references/making-work-ready.md +0 -60
  90. package/skills/run-this-project/references/reacting-to-events.md +0 -76
  91. package/skills/run-this-project/references/stalls-and-escalation.md +0 -63
  92. package/skills/run-this-project/scripts/lib/cli.mjs +0 -185
  93. package/skills/run-this-project/scripts/lib/credential.mjs +0 -29
  94. package/skills/run-this-project/scripts/make-ready.mjs +0 -64
  95. package/skills/run-this-project/scripts/scope-status.mjs +0 -0
  96. package/skills/run-this-project/scripts/watch-scope.mjs +0 -61
  97. package/skills/unstick/SKILL.md +0 -41
  98. package/skills/unstick/agents/openai.yaml +0 -6
  99. package/skills/unstick/agents/portability.yaml +0 -5
  100. package/skills/unstick/references/playbook.md +0 -51
  101. package/skills/unstick/scripts/lib/cli.mjs +0 -135
  102. package/skills/unstick/scripts/lib/credential.mjs +0 -29
  103. package/skills/unstick/scripts/unstick.mjs +0 -57
  104. package/skills/what-needs-me/SKILL.md +0 -41
  105. package/skills/what-needs-me/agents/openai.yaml +0 -6
  106. package/skills/what-needs-me/agents/portability.yaml +0 -5
  107. package/skills/what-needs-me/references/raising-a-decision.md +0 -41
  108. package/skills/what-needs-me/references/reading-the-inbox.md +0 -38
  109. package/skills/what-needs-me/references/settling-an-answer.md +0 -37
  110. package/skills/what-needs-me/scripts/inbox.mjs +0 -56
  111. package/skills/what-needs-me/scripts/lib/cli.mjs +0 -135
  112. package/skills/what-needs-me/scripts/lib/credential.mjs +0 -29
  113. package/skills/what-needs-me/scripts/raise.mjs +0 -53
  114. package/skills/what-needs-me/scripts/settle.mjs +0 -73
  115. package/skills/whats-happening/SKILL.md +0 -43
  116. package/skills/whats-happening/agents/openai.yaml +0 -6
  117. package/skills/whats-happening/agents/portability.yaml +0 -4
  118. package/skills/whats-happening/assets/status-reply.json +0 -77
  119. package/skills/whats-happening/references/reading-the-board.md +0 -43
  120. package/skills/whats-happening/references/reprioritising.md +0 -37
  121. package/skills/whats-happening/references/routing-work.md +0 -36
  122. package/skills/whats-happening/references/status-reply.md +0 -34
  123. package/skills/whats-happening/references/why-is-it-stuck.md +0 -62
  124. package/skills/whats-happening/scripts/explain.mjs +0 -28
  125. package/skills/whats-happening/scripts/lib/cli.mjs +0 -135
  126. package/skills/whats-happening/scripts/lib/credential.mjs +0 -29
  127. package/skills/whats-happening/scripts/snapshot.mjs +0 -145
package/dist/transport.js DELETED
@@ -1,205 +0,0 @@
1
- import { normalizeBaseUrl } from "./config.js";
2
- import { CliError, MeError } from "./errors.js";
3
- import { bearerFor } from "./oauth.js";
4
- export const REQUEST_TIMEOUT_MS = 15_000;
5
- export class ApiClient {
6
- baseUrl;
7
- credential;
8
- fetchImpl;
9
- constructor(baseUrl,
10
- /** Resolves the current bearer FRESH per request — a personal key verbatim, or the OAuth access
11
- * token refreshed within 60s of expiry (CTC-2112). Never captured once. */
12
- credential, fetchImpl) {
13
- this.baseUrl = baseUrl;
14
- this.credential = credential;
15
- this.fetchImpl = fetchImpl;
16
- }
17
- /** `path` is absolute under the origin (e.g. `/api/v1/issues`). */
18
- url(path, query) {
19
- const u = new URL(`${normalizeBaseUrl(this.baseUrl)}${path}`);
20
- for (const [k, v] of Object.entries(query ?? {}))
21
- if (v !== undefined)
22
- u.searchParams.set(k, String(v));
23
- return u.toString();
24
- }
25
- async getJson(path, opts = {}) {
26
- const headers = {
27
- authorization: `Bearer ${await this.credential()}`,
28
- accept: "application/json",
29
- };
30
- if (opts.etag)
31
- headers["if-none-match"] = opts.etag;
32
- const url = this.url(path, opts.query);
33
- const res = await this.send(url, { method: "GET", headers });
34
- if (opts.accept?.includes(res.status)) {
35
- return { status: res.status, body: undefined, headers: res.headers };
36
- }
37
- await this.refuseIfNotOk(res, `GET ${path}`);
38
- return { status: res.status, body: await this.parseBody(res, `GET ${path}`), headers: res.headers };
39
- }
40
- /**
41
- * A GET whose success body is NDJSON — one JSON object per line, the shape `streamNdjson` sends.
42
- *
43
- * ⛔ `/changes` and `/snapshot` ANSWER NDJSON ON 200 AND JSON ON A REFUSAL, so a caller that only
44
- * has `getJson` reads every real success as "returned a non-JSON body" while its refusals parse
45
- * perfectly. That asymmetry is why `query changes` looked like it worked: nothing reached a 200.
46
- * `accept` behaves as it does on `getJson` — those statuses come back with `rows: []` for the
47
- * caller to branch on, and their (JSON) body is deliberately not parsed here.
48
- */
49
- async getNdjson(path, opts = {}) {
50
- const url = this.url(path, opts.query);
51
- const res = await this.send(url, {
52
- method: "GET",
53
- headers: { authorization: `Bearer ${await this.credential()}`, accept: "application/x-ndjson, application/json" },
54
- });
55
- if (opts.accept?.includes(res.status))
56
- return { status: res.status, body: [], headers: res.headers };
57
- await this.refuseIfNotOk(res, `GET ${path}`);
58
- const text = await res.text();
59
- const rows = [];
60
- for (const line of text.split("\n")) {
61
- const trimmed = line.trim();
62
- if (trimmed === "")
63
- continue;
64
- try {
65
- rows.push(JSON.parse(trimmed));
66
- }
67
- catch {
68
- throw new MeError(`GET ${path} returned a line that is not JSON: ${trimmed.slice(0, 120)}`, "shape");
69
- }
70
- }
71
- return { status: res.status, body: rows, headers: res.headers };
72
- }
73
- /** `opts.accept` lists non-2xx statuses returned to the caller with their JSON body instead of thrown
74
- * — the release route answers a refusal as 409 with the refusals the caller has to print. */
75
- async postJson(path, body, opts = {}) {
76
- const url = this.url(path);
77
- const res = await this.send(url, {
78
- method: "POST",
79
- headers: {
80
- authorization: `Bearer ${await this.credential()}`,
81
- accept: "application/json",
82
- "content-type": "application/json",
83
- },
84
- body: JSON.stringify(body),
85
- });
86
- if (!opts.accept?.includes(res.status))
87
- await this.refuseIfNotOk(res, `POST ${path}`);
88
- return { status: res.status, body: await this.parseBody(res, `POST ${path}`), headers: res.headers };
89
- }
90
- async send(url, init) {
91
- try {
92
- return await this.fetchImpl(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
93
- }
94
- catch (err) {
95
- throw new MeError(`could not reach ${url}: ${err instanceof Error ? err.message : String(err)}`, "network");
96
- }
97
- }
98
- async parseBody(res, what) {
99
- try {
100
- return (await res.json());
101
- }
102
- catch {
103
- throw new MeError(`${what} returned a non-JSON body`, "shape");
104
- }
105
- }
106
- async refuseIfNotOk(res, what) {
107
- if (res.ok)
108
- return;
109
- let reason = "";
110
- let raw = "";
111
- try {
112
- raw = await res.text();
113
- const body = JSON.parse(raw);
114
- reason = body.reason ?? body.error ?? body.message ?? "";
115
- }
116
- catch {
117
- reason = raw.slice(0, 200);
118
- }
119
- if (res.status === 401) {
120
- throw new CliError(`${what} failed (401): credential not accepted — mint a personal key at Settings → API keys and log in again`, "unauthorized", 2, 401);
121
- }
122
- if (res.status === 403) {
123
- throw new CliError(`${what} failed (403): this key may not reach that route${reason ? ` (${reason})` : ""} — a route that acts as a host (claiming work, publishing artifacts) needs the tenant's account key; every read and write a person makes takes your personal key`, "forbidden", 2, 403);
124
- }
125
- if (res.status === 429) {
126
- const retryAfter = res.headers.get("retry-after");
127
- const budget = /budget/i.test(raw) || /budget/i.test(reason);
128
- throw new CliError(`${what} refused (429)${budget ? ": the per-host daily write budget is spent" : ": rate limited"}${reason ? ` — ${reason}` : ""}${retryAfter ? ` — retry after ${retryAfter}s` : ""}`, budget ? "budget" : "rate-limited", 2, 429);
129
- }
130
- throw new CliError(`${what} failed (${res.status})${reason ? `: ${reason}` : ""}`, "http", 2, res.status);
131
- }
132
- }
133
- /** Build the authenticated client for a config: the bearer resolves through {@link bearerFor}, so a
134
- * key config sends the key and an OAuth config sends a freshly-refreshed access token per request. */
135
- export function apiClient(cfg, ctx) {
136
- return new ApiClient(cfg.baseUrl, () => bearerFor(ctx, cfg), ctx.fetch);
137
- }
138
- /** GET /api/v1/me — how a key learns its own tenant. Unchanged contract from 0.1. */
139
- export async function fetchMe(baseUrl, key, fetchImpl, timeoutMs = REQUEST_TIMEOUT_MS) {
140
- const url = `${normalizeBaseUrl(baseUrl)}/api/v1/me`;
141
- let res;
142
- try {
143
- res = await fetchImpl(url, {
144
- headers: { authorization: `Bearer ${key}`, accept: "application/json" },
145
- signal: AbortSignal.timeout(timeoutMs),
146
- });
147
- }
148
- catch (err) {
149
- throw new MeError(`could not reach ${url}: ${err instanceof Error ? err.message : String(err)}`, "network");
150
- }
151
- if (!res.ok) {
152
- let reason;
153
- try {
154
- const body = (await res.json());
155
- reason = body.reason ?? body.error ?? "";
156
- }
157
- catch {
158
- reason = "";
159
- }
160
- const detail = res.status === 401
161
- ? "credential not accepted — mint a personal key at Settings → API keys and log in again"
162
- : reason || `HTTP ${res.status}`;
163
- throw new MeError(`GET /me failed (${res.status}): ${detail}`, "http", res.status);
164
- }
165
- let body;
166
- try {
167
- body = (await res.json());
168
- }
169
- catch {
170
- throw new MeError("GET /me returned a non-JSON body", "shape");
171
- }
172
- if (typeof body.account !== "string" ||
173
- typeof body.slug !== "string" ||
174
- typeof body.name !== "string" ||
175
- (body.permissions !== null && !Array.isArray(body.permissions)) ||
176
- (body.principal !== "service" && body.principal !== "session")) {
177
- throw new MeError("GET /me returned an unexpected shape", "shape");
178
- }
179
- const user = parseMeUser(body.user);
180
- return {
181
- account: body.account,
182
- slug: body.slug,
183
- name: body.name,
184
- permissions: body.permissions,
185
- principal: body.principal,
186
- ...(user ? { user } : {}),
187
- };
188
- }
189
- /** The `user` block a personal key gets from /me. Absent (undefined) is "a host key, no person";
190
- * present-but-malformed is a shape error like any other field, never silently dropped. */
191
- function parseMeUser(raw) {
192
- if (raw === undefined)
193
- return undefined;
194
- if (typeof raw !== "object" || raw === null)
195
- throw new MeError("GET /me returned an unexpected user shape", "shape");
196
- const u = raw;
197
- if (typeof u.id !== "string" ||
198
- typeof u.label !== "string" ||
199
- (u.email !== null && typeof u.email !== "string") ||
200
- (u.role !== "owner" && u.role !== "admin" && u.role !== "member") ||
201
- (u.linearUserId !== null && typeof u.linearUserId !== "string")) {
202
- throw new MeError("GET /me returned an unexpected user shape", "shape");
203
- }
204
- return { id: u.id, label: u.label, email: u.email, role: u.role, linearUserId: u.linearUserId };
205
- }
@@ -1,68 +0,0 @@
1
- // ts-deps-loader.ts — lets plain Node load the Catalyst Cloud SDK.
2
- //
3
- // `@catalyst-cloud/sdk` depends on `@catalyst-cloud/schema` and `@catalyst-cloud/replicate`, both of
4
- // which are published as TypeScript source (`exports: "./src/index.ts"`). Node refuses to strip types
5
- // for files under node_modules (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), so a bare
6
- // `import("@catalyst-cloud/sdk/node")` fails on every Node version. Bun and vitest transpile anywhere,
7
- // which is why the SDK's other consumers never see this.
8
- //
9
- // This module installs two synchronous module hooks (Node 22.15+): a `load` hook that strips types
10
- // from any `.ts` file under node_modules with Node's own `stripTypeScriptTypes`, and a `resolve` hook
11
- // that maps a failed `./x.js` relative import onto `./x.ts` (the TS-ESM suffix convention those
12
- // packages use). Nothing outside node_modules is touched, and the hooks are installed once.
13
- import * as nodeModule from "node:module";
14
- import { existsSync, readFileSync } from "node:fs";
15
- import { fileURLToPath } from "node:url";
16
- let installed = false;
17
- function inDeps(url) {
18
- return typeof url === "string" && url.startsWith("file:") && url.includes("/node_modules/");
19
- }
20
- /** Build the hook pair over a strip function. Pure, so a test can drive it with fake `next`s. */
21
- export function makeHooks(strip, fileExists = existsSync, readFile = (p) => readFileSync(p, "utf8")) {
22
- return {
23
- resolve(specifier, context, next) {
24
- try {
25
- return next(specifier, context);
26
- }
27
- catch (err) {
28
- const code = err?.code;
29
- if (code === "ERR_MODULE_NOT_FOUND" && specifier.startsWith(".") && inDeps(context.parentURL)) {
30
- const base = specifier.endsWith(".js") ? specifier.slice(0, -3) : specifier;
31
- const alt = new URL(`${base}.ts`, context.parentURL);
32
- if (fileExists(fileURLToPath(alt)))
33
- return { url: alt.href, format: "module", shortCircuit: true };
34
- }
35
- throw err;
36
- }
37
- },
38
- load(url, context, next) {
39
- if (inDeps(url) && url.endsWith(".ts")) {
40
- return { format: "module", shortCircuit: true, source: strip(readFile(fileURLToPath(url)), { mode: "transform", sourceUrl: url }) };
41
- }
42
- return next(url, context);
43
- },
44
- };
45
- }
46
- /** Install the hooks once. Returns whether they are installed and, if not, why. */
47
- export function installTsDepsLoader(mod = nodeModule) {
48
- if (installed)
49
- return { installed: true };
50
- if (typeof mod.registerHooks !== "function") {
51
- return { installed: false, reason: `node:module has no registerHooks on Node ${process.version}; Node 22.15 or newer is required` };
52
- }
53
- if (typeof mod.stripTypeScriptTypes !== "function") {
54
- return { installed: false, reason: `node:module has no stripTypeScriptTypes on Node ${process.version}; Node 22.13 or newer is required` };
55
- }
56
- // Node prints "stripTypeScriptTypes is an experimental feature" once on first use; that line would
57
- // land on stderr of every SDK-loading verb, so the one warning this loader provokes is dropped.
58
- const emit = process.emitWarning.bind(process);
59
- process.emitWarning = ((warning, ...rest) => {
60
- const text = typeof warning === "string" ? warning : warning instanceof Error ? warning.message : "";
61
- if (text.includes("stripTypeScriptTypes"))
62
- return;
63
- emit(warning, ...rest);
64
- });
65
- mod.registerHooks(makeHooks(mod.stripTypeScriptTypes));
66
- installed = true;
67
- return { installed: true };
68
- }
@@ -1,141 +0,0 @@
1
- import { readCursorFile, writeCursorFile } from "./cursor-file.js";
2
- export const LOG_PREFIX = "[live-events]";
3
- function defaultWsFactory(url) {
4
- const Ctor = globalThis.WebSocket;
5
- if (!Ctor)
6
- throw new Error("global WebSocket unavailable; pass wsFactory (Node >= 22 exposes one)");
7
- return new Ctor(url);
8
- }
9
- export function printFrame(frame) {
10
- console.log(`${LOG_PREFIX} frame ${JSON.stringify(frame)}`);
11
- }
12
- async function fetchHeadCursor(opts, signal) {
13
- const url = `${opts.baseUrl.replace(/\/+$/, "")}/snapshot?head=1`;
14
- const res = await (opts.fetchImpl ?? fetch)(url, { headers: { authorization: `Bearer ${await opts.getToken()}` }, signal });
15
- if (!res.ok) {
16
- const body = (await res.text()).slice(0, 200);
17
- throw new Error(`head-only snapshot fetch failed: ${res.status} ${res.statusText} at ${url} — ${body}`);
18
- }
19
- const head = (await res.json());
20
- if (head.accountId !== opts.accountId) {
21
- throw new Error(`head-only snapshot is for account ${JSON.stringify(head.accountId)} but this session is for ${JSON.stringify(opts.accountId)} — the key and the configured account disagree`);
22
- }
23
- if (typeof head.cursor !== "number" || !Number.isInteger(head.cursor) || head.cursor < 0) {
24
- throw new Error(`head-only snapshot returned a non-integer cursor: ${JSON.stringify(head.cursor)}`);
25
- }
26
- return head.cursor;
27
- }
28
- /**
29
- * Build the events-only client. Construction reads the cursor file ONCE (throwing on a corrupt file
30
- * or a cross-account reuse); the position is then kept in memory and persisted after every
31
- * SUCCESSFUL reaction. React first, persist second: an async reaction is awaited before its cursor
32
- * persists and later frames queue behind it in seq order; a failed reaction halts the stream and
33
- * closes the socket so the SDK's reconnect replays from the last good cursor.
34
- */
35
- export function createLiveEventsClient(sdk, opts) {
36
- let state = opts.fromHead ? null : readCursorFile(opts.cursorFile, opts.accountId);
37
- const log = opts.log ??
38
- ((level, msg, extra) => console[level === "error" ? "error" : "log"](`${LOG_PREFIX} ${msg}`, extra ?? ""));
39
- const persist = (next) => {
40
- state = next;
41
- writeCursorFile(opts.cursorFile, next);
42
- };
43
- const reseedHead = async (signal) => {
44
- const cursor = await fetchHeadCursor(opts, signal);
45
- persist({ account: opts.accountId, cursor });
46
- log("info", `cursor re-seeded to tenant head ${cursor} (head-only; no rows applied)`);
47
- opts.onReseed?.(cursor);
48
- return cursor;
49
- };
50
- const react = opts.onEvent ?? printFrame;
51
- let socket = null;
52
- let halted = false;
53
- let inFlight = null;
54
- const queued = [];
55
- const advance = (frame) => {
56
- if (state && state.cursor >= frame.seq)
57
- return;
58
- persist({ account: frame.accountId, cursor: frame.seq });
59
- };
60
- const fail = (frame, err) => {
61
- halted = true;
62
- queued.length = 0;
63
- inFlight = null;
64
- log("error", `reaction failed for ${frame.entity} seq=${frame.seq}; cursor left at ${state?.cursor ?? "none"} — reconnecting so the replay re-offers it`, err);
65
- const ws = socket;
66
- socket = null;
67
- try {
68
- ws?.close();
69
- }
70
- catch {
71
- // already closing/closed
72
- }
73
- };
74
- const settle = (frame) => {
75
- advance(frame);
76
- inFlight = null;
77
- const next = queued.shift();
78
- if (next)
79
- run(next);
80
- };
81
- const run = (frame) => {
82
- let result;
83
- try {
84
- result = react(frame);
85
- }
86
- catch (err) {
87
- fail(frame, err);
88
- return;
89
- }
90
- if (result && typeof result.then === "function") {
91
- const p = result.then(() => {
92
- if (inFlight === p)
93
- settle(frame);
94
- }, (err) => {
95
- if (inFlight === p)
96
- fail(frame, err);
97
- });
98
- inFlight = p;
99
- return;
100
- }
101
- advance(frame);
102
- };
103
- const wrappedFactory = (url) => {
104
- const ws = (opts.wsFactory ?? defaultWsFactory)(url);
105
- socket = ws;
106
- halted = false;
107
- queued.length = 0;
108
- inFlight = null;
109
- return ws;
110
- };
111
- const client = new sdk.LiveSyncClient({
112
- baseUrl: opts.baseUrl,
113
- accountId: opts.accountId,
114
- auth: opts.auth,
115
- reseed: reseedHead,
116
- getCursor: () => state?.cursor ?? null,
117
- onChange: (frame) => {
118
- if (frame.accountId !== opts.accountId) {
119
- const msg = `refusing frame for account ${JSON.stringify(frame.accountId)} — this session is for ${JSON.stringify(opts.accountId)} (cursor left at ${state?.cursor ?? "none"})`;
120
- log("error", msg);
121
- throw new Error(msg);
122
- }
123
- if (halted)
124
- return;
125
- if (inFlight) {
126
- queued.push(frame);
127
- return;
128
- }
129
- run(frame);
130
- },
131
- onStatus: opts.onStatus ??
132
- ((status) => {
133
- log("info", `status=${status}`);
134
- }),
135
- backoffMs: opts.backoffMs,
136
- maxBackoffMs: opts.maxBackoffMs,
137
- wsFactory: wrappedFactory,
138
- log,
139
- });
140
- return { client, cursor: () => state?.cursor ?? null, reseedHead };
141
- }
@@ -1,62 +0,0 @@
1
- // watch/cursor-file.ts — the durable cursor store for the events-only `watch` consumer, ported whole
2
- // from the cloud's tested host-sync implementation. The file is the whole state:
3
- //
4
- // { "account": "<tenant id>", "cursor": 41 }
5
- //
6
- // The account field is load-bearing: a cursor is a position in ONE tenant's change log, so the file
7
- // refuses to be read for a different account than the one stamped in it (a named throw naming both
8
- // accounts). A torn or corrupt file also fails loudly; the only "no state yet" is the file's absence.
9
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
- import { dirname } from "node:path";
11
- export class CursorFileError extends Error {
12
- path;
13
- constructor(message, path) {
14
- super(message);
15
- this.path = path;
16
- this.name = "CursorFileError";
17
- }
18
- }
19
- function validateShape(parsed) {
20
- if (typeof parsed !== "object" || parsed === null)
21
- return { ok: false, error: "content is not a JSON object" };
22
- const { account, cursor } = parsed;
23
- if (typeof account !== "string" || account.trim() === "")
24
- return { ok: false, error: "account is missing or not a non-empty string" };
25
- if (typeof cursor !== "number" || !Number.isInteger(cursor) || cursor < 0)
26
- return { ok: false, error: "cursor is missing or not a non-negative integer" };
27
- return { ok: true, state: { account, cursor } };
28
- }
29
- /** Returns null ONLY when the file is absent. Throws CursorFileError on an unreadable, corrupt, or
30
- * wrongly shaped file, and on a cross-account file when `expectAccount` is given. */
31
- export function readCursorFile(path, expectAccount) {
32
- if (!existsSync(path))
33
- return null;
34
- let raw;
35
- try {
36
- raw = readFileSync(path, "utf8");
37
- }
38
- catch (err) {
39
- throw new CursorFileError(`cannot read cursor file: ${err instanceof Error ? err.message : String(err)}`, path);
40
- }
41
- let parsed;
42
- try {
43
- parsed = JSON.parse(raw);
44
- }
45
- catch {
46
- throw new CursorFileError("cursor file is not valid JSON (torn write?) — delete it to re-seed from the tenant head, at the cost of replaying nothing in between", path);
47
- }
48
- const shape = validateShape(parsed);
49
- if (!shape.ok)
50
- throw new CursorFileError(`cursor file is malformed: ${shape.error}`, path);
51
- if (expectAccount !== undefined && shape.state.account !== expectAccount) {
52
- throw new CursorFileError(`cursor file belongs to account ${JSON.stringify(shape.state.account)} but this session is for ${JSON.stringify(expectAccount)} — a cursor is a position in ONE tenant's feed; move or delete the file rather than replaying one tenant's position against another`, path);
53
- }
54
- return shape.state;
55
- }
56
- /** Atomic: a sibling temp file, then rename over the target. */
57
- export function writeCursorFile(path, state) {
58
- mkdirSync(dirname(path), { recursive: true });
59
- const tmp = `${path}.tmp`;
60
- writeFileSync(tmp, `${JSON.stringify(state)}\n`, "utf8");
61
- renameSync(tmp, path);
62
- }
package/dist/watch.js DELETED
@@ -1,175 +0,0 @@
1
- // watch.ts — `watch [--team K] [--ticket T]... [--project P] [--exec CMD] [--cursor-file] [--from]`:
2
- // one JSON line per in-scope change frame, cursor advanced after the reaction, over the SDK's live
3
- // stream. Scope is filtered on the client from the frame's entity and row; a project scope resolves
4
- // an issue reference to its project through GET /api/v1/issues/:ref, cached in memory.
5
- import { spawn } from "node:child_process";
6
- import { flagList, flagString } from "./args.js";
7
- import { apiBase, requireConfig, watchCursorPathFor } from "./config.js";
8
- import { loadContract } from "./contract.js";
9
- import { CliError, UsageError } from "./errors.js";
10
- import { apiClient } from "./transport.js";
11
- import { authStrategyFor, bearerFor } from "./oauth.js";
12
- import { loadSdk } from "./sdk.js";
13
- import { createLiveEventsClient } from "./watch/consumer.js";
14
- import { CursorFileError } from "./watch/cursor-file.js";
15
- const ROW_ISSUE_REFS = ["identifier", "linear_issue_identifier", "issue_identifier", "issue_id", "linear_issue_id"];
16
- /** Decide whether one frame falls inside the scope. An empty scope admits everything. Rows that only
17
- * carry an issue reference (comments, sessions, PRs) resolve it through `resolve` for project scope. */
18
- export async function inScope(frame, scope, resolve) {
19
- const hasScope = Boolean(scope.teamId || scope.teamKey || (scope.tickets && scope.tickets.length) || scope.projectId);
20
- if (!hasScope)
21
- return true;
22
- const row = frame.row ?? {};
23
- const refs = ROW_ISSUE_REFS.map((k) => row[k]).filter((v) => typeof v === "string" && v !== "");
24
- const upperTickets = (scope.tickets ?? []).map((t) => t.toUpperCase());
25
- if (upperTickets.length > 0) {
26
- if (refs.some((r) => upperTickets.includes(r.toUpperCase())))
27
- return true;
28
- for (const r of refs) {
29
- const info = await resolve(r);
30
- if (info?.identifier && upperTickets.includes(info.identifier.toUpperCase()))
31
- return true;
32
- }
33
- }
34
- if (scope.projectId) {
35
- if (row.project_id === scope.projectId || row.id === scope.projectId)
36
- return true;
37
- for (const r of refs) {
38
- const info = await resolve(r);
39
- if (info?.projectId === scope.projectId)
40
- return true;
41
- }
42
- }
43
- if (scope.teamId || scope.teamKey) {
44
- if (scope.teamId && (row.team_id === scope.teamId || row.teamId === scope.teamId))
45
- return true;
46
- if (scope.teamKey && refs.some((r) => /^[A-Za-z]+-\d+$/.test(r) && r.toUpperCase().startsWith(`${scope.teamKey.toUpperCase()}-`)))
47
- return true;
48
- for (const r of refs) {
49
- const info = await resolve(r);
50
- if (info && scope.teamId && info.teamId === scope.teamId)
51
- return true;
52
- if (info?.identifier && scope.teamKey && info.identifier.toUpperCase().startsWith(`${scope.teamKey.toUpperCase()}-`))
53
- return true;
54
- }
55
- }
56
- return false;
57
- }
58
- /** Resolve issue references through the API, cached per process. */
59
- export function apiIssueResolver(cfg, ctx) {
60
- const api = apiClient(cfg, ctx);
61
- const cache = new Map();
62
- return async (ref) => {
63
- if (cache.has(ref))
64
- return cache.get(ref);
65
- let out = null;
66
- try {
67
- const res = await api.getJson(`/api/v1/issues/${encodeURIComponent(ref)}`, { accept: [404] });
68
- if (res.status !== 404 && res.body) {
69
- out = {
70
- id: typeof res.body.id === "string" ? res.body.id : null,
71
- identifier: typeof res.body.identifier === "string" ? res.body.identifier : null,
72
- projectId: typeof res.body.project_id === "string" ? res.body.project_id : null,
73
- teamId: typeof res.body.team_id === "string" ? res.body.team_id : null,
74
- };
75
- }
76
- }
77
- catch {
78
- out = null;
79
- }
80
- cache.set(ref, out);
81
- return out;
82
- };
83
- }
84
- /**
85
- * Run `--exec CMD` once with the frame on stdin; a non-zero exit is a failed reaction.
86
- *
87
- * The child's EXIT CODE is the whole contract, and a broken pipe is not a failure on its own. A
88
- * handler that exits before reading the frame is ordinary and often correct — `grep -q`, `head -1`,
89
- * a script that fails fast — and so is one that simply died. In every one of those cases the write
90
- * lands on a pipe with nothing at the other end and raises EPIPE asynchronously. Node delivers that
91
- * as an `error` event on the child's stdin, and a stream with no `error` listener rethrows it as an
92
- * unhandled error, which takes down the whole watch: one misbehaving handler and the customer stops
93
- * receiving their tenant's events. So the pipe is allowed to break, and the exit code still decides.
94
- */
95
- export function execReaction(cmd, env) {
96
- return (frame) => new Promise((resolve, reject) => {
97
- const child = spawn("sh", ["-c", cmd], { stdio: ["pipe", "inherit", "inherit"], env });
98
- child.on("error", reject);
99
- child.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`--exec exited ${code}`))));
100
- // Listen before writing: attaching afterwards races the synchronous part of `end()`.
101
- child.stdin.on("error", () => { });
102
- try {
103
- if (child.stdin.writable)
104
- child.stdin.end(`${JSON.stringify(frame)}\n`);
105
- }
106
- catch {
107
- // A stdin already destroyed throws here rather than emitting; same verdict, wait for close.
108
- }
109
- });
110
- }
111
- export async function runWatch(ctx, cfg, opts, deps = {}) {
112
- const sdk = deps.sdk ?? (await loadSdk());
113
- const resolve = deps.resolver ?? apiIssueResolver(cfg, ctx);
114
- const react = opts.exec ? execReaction(opts.exec, ctx.env) : undefined;
115
- let handle;
116
- try {
117
- handle = buildHandle();
118
- }
119
- catch (err) {
120
- if (err instanceof CursorFileError)
121
- throw new CliError(`${err.message} (${err.path})`, "cursor-file");
122
- throw err;
123
- }
124
- deps.onHandle?.(handle);
125
- const stopped = deps.waitForStop ??
126
- (() => new Promise((done) => {
127
- process.once("SIGINT", () => done());
128
- process.once("SIGTERM", () => done());
129
- }));
130
- const running = handle.client.start();
131
- await stopped();
132
- handle.client.stop();
133
- await running;
134
- return 0;
135
- function buildHandle() {
136
- return createLiveEventsClient(sdk, {
137
- baseUrl: apiBase(cfg),
138
- accountId: cfg.account,
139
- auth: authStrategyFor(ctx, cfg),
140
- getToken: () => bearerFor(ctx, cfg),
141
- cursorFile: opts.cursorFile ?? watchCursorPathFor(ctx.home),
142
- fromHead: opts.fromHead,
143
- fetchImpl: ctx.fetch,
144
- wsFactory: deps.wsFactory,
145
- backoffMs: deps.backoffMs,
146
- maxBackoffMs: deps.maxBackoffMs,
147
- log: (level, msg) => ctx.stderr(`[watch ${level}] ${msg}`),
148
- onStatus: (s) => ctx.stderr(`[watch] status=${s}`),
149
- onReseed: (cursor) => ctx.stderr(`[watch] resync: cursor moved to head ${cursor}`),
150
- onEvent: async (frame) => {
151
- if (!(await inScope(frame, opts.scope, resolve)))
152
- return;
153
- ctx.stdout(JSON.stringify(frame));
154
- if (react)
155
- await react(frame);
156
- },
157
- });
158
- }
159
- }
160
- export async function cmdWatch(args, ctx, deps = {}) {
161
- const cfg = requireConfig(ctx);
162
- const from = flagString(args, "from") ?? "cursor";
163
- if (from !== "cursor" && from !== "head")
164
- throw new UsageError("--from must be cursor or head");
165
- const scope = { tickets: flagList(args, "ticket"), projectId: flagString(args, "project") };
166
- const teamKey = flagString(args, "team");
167
- if (teamKey) {
168
- const { doc } = await loadContract(ctx, cfg);
169
- const team = doc.teams.find((t) => (t.key ?? "").toUpperCase() === teamKey.toUpperCase());
170
- scope.teamKey = teamKey;
171
- if (team)
172
- scope.teamId = team.id;
173
- }
174
- return runWatch(ctx, cfg, { scope, exec: flagString(args, "exec"), cursorFile: flagString(args, "cursor-file"), fromHead: from === "head" }, deps);
175
- }