@catalyst-cloud/catalyst-skills 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +109 -53
  3. package/dist/args.js +237 -0
  4. package/dist/ask.js +137 -0
  5. package/dist/cli.js +165 -279
  6. package/dist/config.js +106 -0
  7. package/dist/contract-types.js +4 -0
  8. package/dist/contract.js +178 -0
  9. package/dist/detach.js +10 -0
  10. package/dist/errors.js +27 -0
  11. package/dist/execution.js +156 -0
  12. package/dist/http.js +142 -0
  13. package/dist/prompt.js +35 -0
  14. package/dist/query.js +187 -0
  15. package/dist/ready.js +123 -0
  16. package/dist/replica.js +318 -0
  17. package/dist/sdk.js +26 -0
  18. package/dist/skill-shape.js +179 -0
  19. package/dist/skills.js +74 -0
  20. package/dist/ts-deps-loader.js +68 -0
  21. package/dist/watch/consumer.js +141 -0
  22. package/dist/watch/cursor-file.js +62 -0
  23. package/dist/watch.js +173 -0
  24. package/dist/write.js +224 -0
  25. package/package.json +9 -5
  26. package/skills/catalyst-github/SKILL.md +35 -0
  27. package/skills/catalyst-github/agents/openai.yaml +6 -0
  28. package/skills/catalyst-github/agents/portability.yaml +4 -0
  29. package/skills/catalyst-github/references/is-it-mergeable.md +57 -0
  30. package/skills/catalyst-github/references/what-a-pr-accumulates.md +61 -0
  31. package/skills/catalyst-github/scripts/is-it-mergeable.mjs +124 -0
  32. package/skills/catalyst-github/scripts/lib/cli.mjs +102 -0
  33. package/skills/catalyst-github/scripts/lib/pull.mjs +82 -0
  34. package/skills/catalyst-github/scripts/read-pr.mjs +97 -0
  35. package/skills/catalyst-linear/SKILL.md +43 -0
  36. package/skills/catalyst-linear/agents/openai.yaml +6 -0
  37. package/skills/catalyst-linear/agents/portability.yaml +5 -0
  38. package/skills/catalyst-linear/references/reading-a-ticket.md +50 -0
  39. package/skills/catalyst-linear/references/what-a-ticket-accumulates.md +53 -0
  40. package/skills/catalyst-linear/references/writing-to-linear.md +43 -0
  41. package/skills/catalyst-linear/scripts/comment.mjs +59 -0
  42. package/skills/catalyst-linear/scripts/create-ticket.mjs +44 -0
  43. package/skills/catalyst-linear/scripts/label.mjs +48 -0
  44. package/skills/catalyst-linear/scripts/lib/cli.mjs +163 -0
  45. package/skills/catalyst-linear/scripts/move.mjs +41 -0
  46. package/skills/catalyst-linear/scripts/read-ticket.mjs +93 -0
  47. package/skills/catalyst-linear/scripts/search.mjs +49 -0
  48. package/skills/catalyst-setup/SKILL.md +32 -0
  49. package/skills/catalyst-setup/agents/openai.yaml +6 -0
  50. package/skills/catalyst-setup/agents/portability.yaml +4 -0
  51. package/skills/catalyst-setup/references/what-each-check-means.md +61 -0
  52. package/skills/catalyst-setup/scripts/check.mjs +75 -0
  53. package/skills/catalyst-setup/scripts/lib/cli.mjs +102 -0
  54. package/skills/catalyst-setup/scripts/replica-status.mjs +46 -0
  55. package/skills/connect-me/SKILL.md +55 -0
  56. package/skills/connect-me/agents/openai.yaml +6 -0
  57. package/skills/connect-me/agents/portability.yaml +5 -0
  58. package/skills/connect-me/references/keeping-the-replica-running.md +88 -0
  59. package/skills/connect-me/scripts/lib/cli.mjs +184 -0
  60. package/skills/connect-me/scripts/verify-connection.mjs +68 -0
  61. package/skills/how-catalyst-works/SKILL.md +42 -0
  62. package/skills/how-catalyst-works/agents/openai.yaml +6 -0
  63. package/skills/how-catalyst-works/agents/portability.yaml +4 -0
  64. package/skills/how-catalyst-works/references/coding-accounts.md +51 -0
  65. package/skills/how-catalyst-works/references/stages-and-mapping.md +54 -0
  66. package/skills/how-catalyst-works/references/the-ladder.md +41 -0
  67. package/skills/how-catalyst-works/references/what-runs-next.md +77 -0
  68. package/skills/how-catalyst-works/references/when-a-phase-fails.md +57 -0
  69. package/skills/how-catalyst-works/scripts/explain-ticket.mjs +41 -0
  70. package/skills/how-catalyst-works/scripts/lib/cli.mjs +163 -0
  71. package/skills/how-catalyst-works/scripts/show-my-map.mjs +94 -0
  72. package/skills/how-catalyst-works/scripts/whats-running.mjs +66 -0
  73. package/skills/run-this-project/SKILL.md +45 -0
  74. package/skills/run-this-project/agents/openai.yaml +6 -0
  75. package/skills/run-this-project/agents/portability.yaml +5 -0
  76. package/skills/run-this-project/assets/stall-policy.json +15 -0
  77. package/skills/run-this-project/references/making-work-ready.md +60 -0
  78. package/skills/run-this-project/references/reacting-to-events.md +76 -0
  79. package/skills/run-this-project/references/stalls-and-escalation.md +63 -0
  80. package/skills/run-this-project/scripts/lib/cli.mjs +184 -0
  81. package/skills/run-this-project/scripts/make-ready.mjs +64 -0
  82. package/skills/run-this-project/scripts/scope-status.mjs +0 -0
  83. package/skills/run-this-project/scripts/watch-scope.mjs +61 -0
  84. package/skills/what-needs-me/SKILL.md +41 -0
  85. package/skills/what-needs-me/agents/openai.yaml +6 -0
  86. package/skills/what-needs-me/agents/portability.yaml +5 -0
  87. package/skills/what-needs-me/references/raising-a-decision.md +41 -0
  88. package/skills/what-needs-me/references/reading-the-inbox.md +38 -0
  89. package/skills/what-needs-me/references/settling-an-answer.md +37 -0
  90. package/skills/what-needs-me/scripts/inbox.mjs +47 -0
  91. package/skills/what-needs-me/scripts/lib/cli.mjs +134 -0
  92. package/skills/what-needs-me/scripts/raise.mjs +53 -0
  93. package/skills/what-needs-me/scripts/settle.mjs +73 -0
  94. package/skills/whats-happening/SKILL.md +43 -0
  95. package/skills/whats-happening/agents/openai.yaml +6 -0
  96. package/skills/whats-happening/agents/portability.yaml +4 -0
  97. package/skills/whats-happening/assets/status-reply.json +77 -0
  98. package/skills/whats-happening/references/reading-the-board.md +43 -0
  99. package/skills/whats-happening/references/reprioritising.md +37 -0
  100. package/skills/whats-happening/references/routing-work.md +36 -0
  101. package/skills/whats-happening/references/status-reply.md +34 -0
  102. package/skills/whats-happening/references/why-is-it-stuck.md +57 -0
  103. package/skills/whats-happening/scripts/explain.mjs +28 -0
  104. package/skills/whats-happening/scripts/lib/cli.mjs +134 -0
  105. package/skills/whats-happening/scripts/snapshot.mjs +145 -0
  106. package/skills/ask/SKILL.md +0 -37
  107. package/skills/concierge/SKILL.md +0 -41
  108. package/skills/join/SKILL.md +0 -30
  109. package/skills/linearis/SKILL.md +0 -40
  110. package/skills/setup/SKILL.md +0 -27
  111. package/skills/steward/SKILL.md +0 -30
@@ -0,0 +1,179 @@
1
+ // skill-shape.ts — `validateSkillDir(dir)`: every problem with one skill directory, one string each.
2
+ // The rules are the packaging pipeline's, the plugin shape gate's, the cloud validator's, and this
3
+ // bundle's own content prohibitions, so a skill that passes here installs and publishes everywhere.
4
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
5
+ import { basename, join, relative } from "node:path";
6
+ export const PROVENANCE_MARKER = "vendored-from: @catalyst-cloud/catalyst-skills";
7
+ export const MAX_SKILL_LINES = 80;
8
+ export const MAX_REFERENCE_LINES = 150;
9
+ export const MAX_DESCRIPTION_CHARS = 1024;
10
+ /** Strings no customer-facing skill file may contain. */
11
+ export const FORBIDDEN_CONTENT = [
12
+ { name: "the maintainer tenant (tenant-0)", re: /tenant-0/ },
13
+ // The public bundle repository is `coalesce-labs/catalyst-cloud-skills`, whose name CONTAINS the
14
+ // private repository's, so this one has to end at a word boundary or it bans its own README.
15
+ { name: "the private catalyst repository (coalesce-labs/catalyst)", re: /coalesce-labs\/catalyst(?![-\w])/ },
16
+ { name: "the fleet thoughts repository (thoughts/)", re: /thoughts\// },
17
+ { name: "an internal ticket id", re: /\bC[TL]C-\d+\b/ },
18
+ { name: "a Linear CLI name (linearis)", re: /linearis/i },
19
+ { name: "the internal replica tool name (catalyst-replica)", re: /catalyst-replica/ },
20
+ ];
21
+ function walk(dir) {
22
+ const out = [];
23
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
24
+ const p = join(dir, e.name);
25
+ if (e.isDirectory())
26
+ out.push(...walk(p));
27
+ else
28
+ out.push(p);
29
+ }
30
+ return out;
31
+ }
32
+ /** A flat `key: value` parser with block-scalar support for one key spanning indented lines. */
33
+ export function parseFrontmatter(lines) {
34
+ if (lines[0] !== "---")
35
+ return null;
36
+ const fields = {};
37
+ let closeIndex = -1;
38
+ let current = null;
39
+ for (let i = 1; i < lines.length; i++) {
40
+ const line = lines[i];
41
+ if (line === "---") {
42
+ closeIndex = i;
43
+ break;
44
+ }
45
+ const m = /^([A-Za-z][A-Za-z0-9_-]*):\s?(.*)$/.exec(line);
46
+ if (m && !/^\s/.test(line)) {
47
+ current = m[1];
48
+ const v = m[2].trim();
49
+ fields[current] = v === "|" || v === ">" || v === "|-" || v === ">-" ? "" : v.replace(/^"(.*)"$/, "$1");
50
+ continue;
51
+ }
52
+ if (current && /^\s+\S/.test(line)) {
53
+ fields[current] = `${fields[current]}${fields[current] ? " " : ""}${line.trim()}`;
54
+ }
55
+ }
56
+ return closeIndex === -1 ? { fields, closeIndex } : { fields, closeIndex };
57
+ }
58
+ const IMPORT_RE = /(?:from\s+|import\s*\(\s*|require\s*\(\s*)["']([^"']+)["']/g;
59
+ export function validateSkillDir(dir) {
60
+ const problems = [];
61
+ const name = basename(dir);
62
+ if (!existsSync(dir) || !statSync(dir).isDirectory())
63
+ return [`${dir} is not a directory`];
64
+ const skillMd = join(dir, "SKILL.md");
65
+ if (!existsSync(skillMd))
66
+ return [`${name}: SKILL.md is missing`];
67
+ const md = readFileSync(skillMd, "utf8");
68
+ const lines = md.split("\n");
69
+ if (lines.at(-1) === "")
70
+ lines.pop();
71
+ if (lines.length > MAX_SKILL_LINES)
72
+ problems.push(`${name}: SKILL.md is ${lines.length} lines (max ${MAX_SKILL_LINES})`);
73
+ const fm = parseFrontmatter(lines);
74
+ let mutatingFrontmatter = false;
75
+ if (!fm)
76
+ problems.push(`${name}: SKILL.md must start with YAML frontmatter (---)`);
77
+ else {
78
+ if (fm.closeIndex === -1)
79
+ problems.push(`${name}: SKILL.md is missing a closing frontmatter fence`);
80
+ if (!fm.fields.name)
81
+ problems.push(`${name}: frontmatter is missing "name"`);
82
+ else if (fm.fields.name !== name)
83
+ problems.push(`${name}: frontmatter name "${fm.fields.name}" must match the directory`);
84
+ if (fm.fields.description === undefined || fm.fields.description.trim() === "")
85
+ problems.push(`${name}: frontmatter is missing "description"`);
86
+ else if (fm.fields.description.length > MAX_DESCRIPTION_CHARS)
87
+ problems.push(`${name}: description is ${fm.fields.description.length} characters (max ${MAX_DESCRIPTION_CHARS})`);
88
+ mutatingFrontmatter = fm.fields["disable-model-invocation"] === "true";
89
+ if (fm.closeIndex !== -1) {
90
+ const after = lines[fm.closeIndex + 1] ?? "";
91
+ if (!(after.startsWith("<!--") && after.includes(PROVENANCE_MARKER))) {
92
+ problems.push(`${name}: the line after the frontmatter must be the HTML provenance comment containing "${PROVENANCE_MARKER}"`);
93
+ }
94
+ }
95
+ }
96
+ const refsDir = join(dir, "references");
97
+ if (existsSync(refsDir)) {
98
+ const refs = readdirSync(refsDir).filter((f) => f.endsWith(".md")).sort();
99
+ if (refs.length === 0)
100
+ problems.push(`${name}: references/ is empty`);
101
+ const table = /^## Load on demand\s*$/m.test(md);
102
+ if (refs.length > 0 && !table)
103
+ problems.push(`${name}: SKILL.md has no "## Load on demand" table`);
104
+ for (const f of refs) {
105
+ const body = readFileSync(join(refsDir, f), "utf8").split("\n");
106
+ if (body.at(-1) === "")
107
+ body.pop();
108
+ if (body.length > MAX_REFERENCE_LINES)
109
+ problems.push(`${name}: references/${f} is ${body.length} lines (max ${MAX_REFERENCE_LINES})`);
110
+ if (!md.includes(`references/${f}`))
111
+ problems.push(`${name}: references/${f} is not linked from SKILL.md by its literal path`);
112
+ }
113
+ }
114
+ const scriptsDir = join(dir, "scripts");
115
+ if (existsSync(scriptsDir)) {
116
+ for (const p of walk(scriptsDir).filter((f) => f.endsWith(".mjs"))) {
117
+ const rel = relative(dir, p);
118
+ const src = readFileSync(p, "utf8");
119
+ if (!src.startsWith("#!/usr/bin/env node"))
120
+ problems.push(`${name}: ${rel} must start with #!/usr/bin/env node`);
121
+ if (!src.includes("--help"))
122
+ problems.push(`${name}: ${rel} has no --help`);
123
+ for (const m of src.matchAll(IMPORT_RE)) {
124
+ const spec = m[1];
125
+ if (!(spec.startsWith("node:") || spec.startsWith("./") || spec.startsWith("../") || spec.startsWith("/"))) {
126
+ problems.push(`${name}: ${rel} imports "${spec}"; scripts may import only node: modules and relative files`);
127
+ }
128
+ }
129
+ }
130
+ }
131
+ const portability = join(dir, "agents", "portability.yaml");
132
+ const openai = join(dir, "agents", "openai.yaml");
133
+ let mutating = false;
134
+ let implicitFalse = false;
135
+ if (!existsSync(portability))
136
+ problems.push(`${name}: agents/portability.yaml is missing`);
137
+ else {
138
+ const y = readFileSync(portability, "utf8");
139
+ if (!/^effects:\s*.*$/m.test(y))
140
+ problems.push(`${name}: agents/portability.yaml does not declare "effects"`);
141
+ if (!/^exposure:\s*\[\s*"?catalog"?\s*\]\s*$/m.test(y))
142
+ problems.push(`${name}: agents/portability.yaml must declare exposure: [catalog]`);
143
+ mutating = /^mutating:\s*true\s*$/m.test(y);
144
+ }
145
+ if (!existsSync(openai))
146
+ problems.push(`${name}: agents/openai.yaml is missing`);
147
+ else {
148
+ const y = readFileSync(openai, "utf8");
149
+ if (!/^policy:\s*$/m.test(y))
150
+ problems.push(`${name}: agents/openai.yaml has no "policy:" block`);
151
+ if (!/allow_implicit_invocation:\s*(true|false)/.test(y))
152
+ problems.push(`${name}: agents/openai.yaml does not set policy.allow_implicit_invocation`);
153
+ implicitFalse = /allow_implicit_invocation:\s*false/.test(y);
154
+ }
155
+ if (mutating || mutatingFrontmatter || implicitFalse) {
156
+ if (!mutating)
157
+ problems.push(`${name}: disable-model-invocation / allow_implicit_invocation: false are set but agents/portability.yaml lacks mutating: true`);
158
+ if (!mutatingFrontmatter)
159
+ problems.push(`${name}: is mutating but SKILL.md does not set disable-model-invocation: true`);
160
+ if (!implicitFalse)
161
+ problems.push(`${name}: is mutating but agents/openai.yaml does not set policy.allow_implicit_invocation: false`);
162
+ }
163
+ for (const p of walk(dir)) {
164
+ const rel = relative(dir, p);
165
+ if (/\.log$/i.test(rel))
166
+ problems.push(`${name}: ${rel} is a log file; no *.log may ship in a skill`);
167
+ let text;
168
+ try {
169
+ text = readFileSync(p, "utf8");
170
+ }
171
+ catch {
172
+ continue;
173
+ }
174
+ for (const f of FORBIDDEN_CONTENT)
175
+ if (f.re.test(text))
176
+ problems.push(`${name}: ${rel} mentions ${f.name}`);
177
+ }
178
+ return problems;
179
+ }
package/dist/skills.js ADDED
@@ -0,0 +1,74 @@
1
+ // skills.ts — copying the bundled skills into the user's skills directory, and the one-line update
2
+ // notice a new version prints on its next session.
3
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { PACKAGE_NAME, defaultSkillsDirFor } from "./config.js";
7
+ import { PROVENANCE_MARKER } from "./skill-shape.js";
8
+ export function skillsSourceDir() {
9
+ return fileURLToPath(new URL("../skills", import.meta.url));
10
+ }
11
+ /**
12
+ * Copy every `skills/<name>/` that has a SKILL.md. A target directory whose SKILL.md lacks the
13
+ * provenance marker was not installed by this package and is skipped unless `force`.
14
+ *
15
+ * `onlyExisting` is the update path: it refreshes copies this package already made and creates
16
+ * none. That is what keeps the update notice from planting a second copy of every skill beside a
17
+ * set the customer's agent installed as a plugin.
18
+ */
19
+ export function installSkills(targetDir, opts, sourceDir = skillsSourceDir()) {
20
+ const result = { installed: [], skipped: [] };
21
+ if (existsSync(targetDir) && !statSync(targetDir).isDirectory()) {
22
+ throw new Error(`${targetDir} is not a directory`);
23
+ }
24
+ const names = readdirSync(sourceDir, { withFileTypes: true })
25
+ .filter((e) => e.isDirectory())
26
+ .map((e) => e.name)
27
+ .sort();
28
+ for (const name of names) {
29
+ const src = join(sourceDir, name);
30
+ if (!existsSync(join(src, "SKILL.md")))
31
+ continue;
32
+ const dst = join(targetDir, name);
33
+ const existingMd = join(dst, "SKILL.md");
34
+ if (opts.onlyExisting && !existsSync(existingMd))
35
+ continue;
36
+ if (existsSync(existingMd) && !opts.force) {
37
+ const existing = readFileSync(existingMd, "utf8");
38
+ if (!existing.includes(PROVENANCE_MARKER)) {
39
+ result.skipped.push({ name, reason: "foreign-skill-dir" });
40
+ continue;
41
+ }
42
+ }
43
+ mkdirSync(dst, { recursive: true });
44
+ cpSync(src, dst, { recursive: true });
45
+ result.installed.push(name);
46
+ }
47
+ return result;
48
+ }
49
+ export function resolveSkillsDir(args, ctx, cfg) {
50
+ return args.skillsDir ?? ctx.env.CATALYST_SKILLS_CLAUDE_DIR ?? cfg?.skillsDir ?? defaultSkillsDirFor(ctx.home);
51
+ }
52
+ export function parseChangelogEntry(changelog, version) {
53
+ const lines = changelog.split("\n");
54
+ const heading = `## ${version}`;
55
+ const start = lines.findIndex((l) => l.trim() === heading);
56
+ if (start === -1)
57
+ return null;
58
+ for (const line of lines.slice(start + 1)) {
59
+ const t = line.trim();
60
+ if (t === "")
61
+ continue;
62
+ if (t.startsWith("#"))
63
+ break;
64
+ return t;
65
+ }
66
+ return null;
67
+ }
68
+ export function readChangelog() {
69
+ return readFileSync(new URL("../CHANGELOG.md", import.meta.url), "utf8");
70
+ }
71
+ export function updateNoticeLine(previous, current, entry) {
72
+ const summary = entry ?? "see CHANGELOG.md";
73
+ return `[catalyst-skills] updated ${previous} → ${current}: ${summary} · update with: npm update -g ${PACKAGE_NAME} (or: npx ${PACKAGE_NAME}@latest login)`;
74
+ }
@@ -0,0 +1,68 @@
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
+ }
@@ -0,0 +1,141 @@
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 ${opts.token}` }, 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: { kind: "token", token: opts.token },
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
+ }
@@ -0,0 +1,62 @@
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
+ }