@catalyst-cloud/cli 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +75 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/bin/catalyst-skills.js +8 -0
- package/bin/catalyst.js +5 -0
- package/bin/launch.js +154 -0
- package/dist/args.js +280 -0
- package/dist/ask.js +161 -0
- package/dist/browser.js +20 -0
- package/dist/cli.js +397 -0
- package/dist/config.js +241 -0
- package/dist/contract-types.js +4 -0
- package/dist/contract.js +184 -0
- package/dist/detach.js +10 -0
- package/dist/environment.js +207 -0
- package/dist/errors.js +27 -0
- package/dist/events.js +106 -0
- package/dist/execution.js +451 -0
- package/dist/oauth.js +300 -0
- package/dist/pagination.js +76 -0
- package/dist/prompt.js +35 -0
- package/dist/published.js +79 -0
- package/dist/query.js +248 -0
- package/dist/ready.js +380 -0
- package/dist/release.js +142 -0
- package/dist/replica.js +614 -0
- package/dist/runtime-store.js +135 -0
- package/dist/runtime-verb.js +66 -0
- package/dist/runtime.js +87 -0
- package/dist/sdk.js +29 -0
- package/dist/secret.js +190 -0
- package/dist/semver.js +18 -0
- package/dist/skill-shape.js +189 -0
- package/dist/skills.js +129 -0
- package/dist/transport.js +205 -0
- package/dist/ts-deps-loader.js +113 -0
- package/dist/watch/consumer.js +141 -0
- package/dist/watch/cursor-file.js +62 -0
- package/dist/watch.js +175 -0
- package/dist/write.js +224 -0
- package/package.json +60 -0
- package/skills/catalyst-github/SKILL.md +35 -0
- package/skills/catalyst-github/agents/openai.yaml +6 -0
- package/skills/catalyst-github/agents/portability.yaml +4 -0
- package/skills/catalyst-github/references/is-it-mergeable.md +57 -0
- package/skills/catalyst-github/references/what-a-pr-accumulates.md +61 -0
- package/skills/catalyst-github/scripts/is-it-mergeable.mjs +124 -0
- package/skills/catalyst-github/scripts/lib/cli.mjs +103 -0
- package/skills/catalyst-github/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-github/scripts/lib/pull.mjs +82 -0
- package/skills/catalyst-github/scripts/read-pr.mjs +97 -0
- package/skills/catalyst-linear/SKILL.md +43 -0
- package/skills/catalyst-linear/agents/openai.yaml +6 -0
- package/skills/catalyst-linear/agents/portability.yaml +5 -0
- package/skills/catalyst-linear/references/reading-a-ticket.md +52 -0
- package/skills/catalyst-linear/references/what-a-ticket-accumulates.md +53 -0
- package/skills/catalyst-linear/references/writing-to-linear.md +43 -0
- package/skills/catalyst-linear/scripts/comment.mjs +59 -0
- package/skills/catalyst-linear/scripts/create-ticket.mjs +44 -0
- package/skills/catalyst-linear/scripts/label.mjs +48 -0
- package/skills/catalyst-linear/scripts/lib/cli.mjs +164 -0
- package/skills/catalyst-linear/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-linear/scripts/move.mjs +41 -0
- package/skills/catalyst-linear/scripts/read-ticket.mjs +93 -0
- package/skills/catalyst-linear/scripts/search.mjs +49 -0
- package/skills/catalyst-onboard/SKILL.md +57 -0
- package/skills/catalyst-onboard/agents/openai.yaml +6 -0
- package/skills/catalyst-onboard/agents/portability.yaml +5 -0
- package/skills/catalyst-onboard/references/declaring-a-repository.md +23 -0
- package/skills/catalyst-onboard/references/skill-sources.md +35 -0
- package/skills/catalyst-onboard/references/the-one-path.md +149 -0
- package/skills/catalyst-onboard/references/what-a-phase-needs.md +46 -0
- package/skills/catalyst-onboard/references/what-the-browser-owns.md +50 -0
- package/skills/catalyst-onboard/references/who-fixes-what.md +44 -0
- package/skills/catalyst-onboard/scripts/lib/cli.mjs +117 -0
- package/skills/catalyst-onboard/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-onboard/scripts/where-am-i.mjs +345 -0
- package/skills/catalyst-setup/SKILL.md +36 -0
- package/skills/catalyst-setup/agents/openai.yaml +6 -0
- package/skills/catalyst-setup/agents/portability.yaml +4 -0
- package/skills/catalyst-setup/references/what-each-check-means.md +88 -0
- package/skills/catalyst-setup/scripts/check.mjs +75 -0
- package/skills/catalyst-setup/scripts/lib/cli.mjs +103 -0
- package/skills/catalyst-setup/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-setup/scripts/replica-status.mjs +46 -0
- package/skills/connect-me/SKILL.md +63 -0
- package/skills/connect-me/agents/openai.yaml +6 -0
- package/skills/connect-me/agents/portability.yaml +5 -0
- package/skills/connect-me/references/keeping-the-replica-running.md +88 -0
- package/skills/connect-me/scripts/lib/cli.mjs +185 -0
- package/skills/connect-me/scripts/lib/credential.mjs +29 -0
- package/skills/connect-me/scripts/verify-connection.mjs +68 -0
- package/skills/how-catalyst-works/SKILL.md +43 -0
- package/skills/how-catalyst-works/agents/openai.yaml +6 -0
- package/skills/how-catalyst-works/agents/portability.yaml +4 -0
- package/skills/how-catalyst-works/references/coding-accounts.md +51 -0
- package/skills/how-catalyst-works/references/stages-and-mapping.md +56 -0
- package/skills/how-catalyst-works/references/the-ladder.md +41 -0
- package/skills/how-catalyst-works/references/what-catalyst-is.md +30 -0
- package/skills/how-catalyst-works/references/what-runs-next.md +77 -0
- package/skills/how-catalyst-works/references/when-a-phase-fails.md +57 -0
- package/skills/how-catalyst-works/scripts/explain-ticket.mjs +41 -0
- package/skills/how-catalyst-works/scripts/lib/cli.mjs +164 -0
- package/skills/how-catalyst-works/scripts/lib/credential.mjs +29 -0
- package/skills/how-catalyst-works/scripts/show-my-map.mjs +94 -0
- package/skills/how-catalyst-works/scripts/whats-running.mjs +65 -0
- package/skills/run-this-project/SKILL.md +45 -0
- package/skills/run-this-project/agents/openai.yaml +6 -0
- package/skills/run-this-project/agents/portability.yaml +5 -0
- package/skills/run-this-project/assets/stall-policy.json +15 -0
- package/skills/run-this-project/references/making-work-ready.md +60 -0
- package/skills/run-this-project/references/reacting-to-events.md +76 -0
- package/skills/run-this-project/references/stalls-and-escalation.md +63 -0
- package/skills/run-this-project/scripts/lib/cli.mjs +185 -0
- package/skills/run-this-project/scripts/lib/credential.mjs +29 -0
- package/skills/run-this-project/scripts/make-ready.mjs +64 -0
- package/skills/run-this-project/scripts/scope-status.mjs +0 -0
- package/skills/run-this-project/scripts/watch-scope.mjs +61 -0
- package/skills/unstick/SKILL.md +41 -0
- package/skills/unstick/agents/openai.yaml +6 -0
- package/skills/unstick/agents/portability.yaml +5 -0
- package/skills/unstick/references/playbook.md +51 -0
- package/skills/unstick/scripts/lib/cli.mjs +135 -0
- package/skills/unstick/scripts/lib/credential.mjs +29 -0
- package/skills/unstick/scripts/unstick.mjs +57 -0
- package/skills/what-needs-me/SKILL.md +41 -0
- package/skills/what-needs-me/agents/openai.yaml +6 -0
- package/skills/what-needs-me/agents/portability.yaml +5 -0
- package/skills/what-needs-me/references/raising-a-decision.md +41 -0
- package/skills/what-needs-me/references/reading-the-inbox.md +38 -0
- package/skills/what-needs-me/references/settling-an-answer.md +37 -0
- package/skills/what-needs-me/scripts/inbox.mjs +56 -0
- package/skills/what-needs-me/scripts/lib/cli.mjs +135 -0
- package/skills/what-needs-me/scripts/lib/credential.mjs +29 -0
- package/skills/what-needs-me/scripts/raise.mjs +53 -0
- package/skills/what-needs-me/scripts/settle.mjs +73 -0
- package/skills/whats-happening/SKILL.md +43 -0
- package/skills/whats-happening/agents/openai.yaml +6 -0
- package/skills/whats-happening/agents/portability.yaml +4 -0
- package/skills/whats-happening/assets/status-reply.json +77 -0
- package/skills/whats-happening/references/reading-the-board.md +43 -0
- package/skills/whats-happening/references/reprioritising.md +37 -0
- package/skills/whats-happening/references/routing-work.md +36 -0
- package/skills/whats-happening/references/status-reply.md +34 -0
- package/skills/whats-happening/references/why-is-it-stuck.md +62 -0
- package/skills/whats-happening/scripts/explain.mjs +28 -0
- package/skills/whats-happening/scripts/lib/cli.mjs +135 -0
- package/skills/whats-happening/scripts/lib/credential.mjs +29 -0
- package/skills/whats-happening/scripts/snapshot.mjs +149 -0
- package/vendor/README.md +9 -0
- package/vendor/paths/index.d.ts +85 -0
- package/vendor/paths/index.js +148 -0
- package/vendor/paths/legacy-installer.d.ts +36 -0
- package/vendor/paths/legacy-installer.js +154 -0
- package/vendor/paths/node.d.ts +18 -0
- package/vendor/paths/node.js +102 -0
- package/vendor/paths/provenance.json +17 -0
|
@@ -0,0 +1,189 @@
|
|
|
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
|
+
/** The first release whose SKILL.md files carry a version on the provenance line. An installed
|
|
11
|
+
* skill with the marker and no version predates this release — but that only PROVES staleness
|
|
12
|
+
* once a release at or above it has actually been published (CTC-2160 D4). */
|
|
13
|
+
export const FIRST_STAMPED_VERSION = "0.7.0";
|
|
14
|
+
const PROVENANCE_VERSION_RE = new RegExp(`${PROVENANCE_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}@(\\S+)`);
|
|
15
|
+
/** The version stamped on a provenance comment line, or null when the line carries none. */
|
|
16
|
+
export function parseProvenanceVersion(line) {
|
|
17
|
+
const m = PROVENANCE_VERSION_RE.exec(line);
|
|
18
|
+
return m ? m[1] : null;
|
|
19
|
+
}
|
|
20
|
+
/** Strings no customer-facing skill file may contain. */
|
|
21
|
+
export const FORBIDDEN_CONTENT = [
|
|
22
|
+
{ name: "the maintainer tenant (tenant-0)", re: /tenant-0/ },
|
|
23
|
+
// The public bundle repository is `coalesce-labs/catalyst-cloud-skills`, whose name CONTAINS the
|
|
24
|
+
// private repository's, so this one has to end at a word boundary or it bans its own README.
|
|
25
|
+
{ name: "the private catalyst repository (coalesce-labs/catalyst)", re: /coalesce-labs\/catalyst(?![-\w])/ },
|
|
26
|
+
{ name: "the fleet thoughts repository (thoughts/)", re: /thoughts\// },
|
|
27
|
+
{ name: "an internal ticket id", re: /\bC[TL]C-\d+\b/ },
|
|
28
|
+
{ name: "a Linear CLI name (linearis)", re: /linearis/i },
|
|
29
|
+
{ name: "the internal replica tool name (catalyst-replica)", re: /catalyst-replica/ },
|
|
30
|
+
];
|
|
31
|
+
function walk(dir) {
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
34
|
+
const p = join(dir, e.name);
|
|
35
|
+
if (e.isDirectory())
|
|
36
|
+
out.push(...walk(p));
|
|
37
|
+
else
|
|
38
|
+
out.push(p);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
/** A flat `key: value` parser with block-scalar support for one key spanning indented lines. */
|
|
43
|
+
export function parseFrontmatter(lines) {
|
|
44
|
+
if (lines[0] !== "---")
|
|
45
|
+
return null;
|
|
46
|
+
const fields = {};
|
|
47
|
+
let closeIndex = -1;
|
|
48
|
+
let current = null;
|
|
49
|
+
for (let i = 1; i < lines.length; i++) {
|
|
50
|
+
const line = lines[i];
|
|
51
|
+
if (line === "---") {
|
|
52
|
+
closeIndex = i;
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
const m = /^([A-Za-z][A-Za-z0-9_-]*):\s?(.*)$/.exec(line);
|
|
56
|
+
if (m && !/^\s/.test(line)) {
|
|
57
|
+
current = m[1];
|
|
58
|
+
const v = m[2].trim();
|
|
59
|
+
fields[current] = v === "|" || v === ">" || v === "|-" || v === ">-" ? "" : v.replace(/^"(.*)"$/, "$1");
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (current && /^\s+\S/.test(line)) {
|
|
63
|
+
fields[current] = `${fields[current]}${fields[current] ? " " : ""}${line.trim()}`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return closeIndex === -1 ? { fields, closeIndex } : { fields, closeIndex };
|
|
67
|
+
}
|
|
68
|
+
const IMPORT_RE = /(?:from\s+|import\s*\(\s*|require\s*\(\s*)["']([^"']+)["']/g;
|
|
69
|
+
export function validateSkillDir(dir) {
|
|
70
|
+
const problems = [];
|
|
71
|
+
const name = basename(dir);
|
|
72
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory())
|
|
73
|
+
return [`${dir} is not a directory`];
|
|
74
|
+
const skillMd = join(dir, "SKILL.md");
|
|
75
|
+
if (!existsSync(skillMd))
|
|
76
|
+
return [`${name}: SKILL.md is missing`];
|
|
77
|
+
const md = readFileSync(skillMd, "utf8");
|
|
78
|
+
const lines = md.split("\n");
|
|
79
|
+
if (lines.at(-1) === "")
|
|
80
|
+
lines.pop();
|
|
81
|
+
if (lines.length > MAX_SKILL_LINES)
|
|
82
|
+
problems.push(`${name}: SKILL.md is ${lines.length} lines (max ${MAX_SKILL_LINES})`);
|
|
83
|
+
const fm = parseFrontmatter(lines);
|
|
84
|
+
let mutatingFrontmatter = false;
|
|
85
|
+
if (!fm)
|
|
86
|
+
problems.push(`${name}: SKILL.md must start with YAML frontmatter (---)`);
|
|
87
|
+
else {
|
|
88
|
+
if (fm.closeIndex === -1)
|
|
89
|
+
problems.push(`${name}: SKILL.md is missing a closing frontmatter fence`);
|
|
90
|
+
if (!fm.fields.name)
|
|
91
|
+
problems.push(`${name}: frontmatter is missing "name"`);
|
|
92
|
+
else if (fm.fields.name !== name)
|
|
93
|
+
problems.push(`${name}: frontmatter name "${fm.fields.name}" must match the directory`);
|
|
94
|
+
if (fm.fields.description === undefined || fm.fields.description.trim() === "")
|
|
95
|
+
problems.push(`${name}: frontmatter is missing "description"`);
|
|
96
|
+
else if (fm.fields.description.length > MAX_DESCRIPTION_CHARS)
|
|
97
|
+
problems.push(`${name}: description is ${fm.fields.description.length} characters (max ${MAX_DESCRIPTION_CHARS})`);
|
|
98
|
+
mutatingFrontmatter = fm.fields["disable-model-invocation"] === "true";
|
|
99
|
+
if (fm.closeIndex !== -1) {
|
|
100
|
+
const after = lines[fm.closeIndex + 1] ?? "";
|
|
101
|
+
if (!(after.startsWith("<!--") && after.includes(PROVENANCE_MARKER))) {
|
|
102
|
+
problems.push(`${name}: the line after the frontmatter must be the HTML provenance comment containing "${PROVENANCE_MARKER}"`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const refsDir = join(dir, "references");
|
|
107
|
+
if (existsSync(refsDir)) {
|
|
108
|
+
const refs = readdirSync(refsDir).filter((f) => f.endsWith(".md")).sort();
|
|
109
|
+
if (refs.length === 0)
|
|
110
|
+
problems.push(`${name}: references/ is empty`);
|
|
111
|
+
const table = /^## Load on demand\s*$/m.test(md);
|
|
112
|
+
if (refs.length > 0 && !table)
|
|
113
|
+
problems.push(`${name}: SKILL.md has no "## Load on demand" table`);
|
|
114
|
+
for (const f of refs) {
|
|
115
|
+
const body = readFileSync(join(refsDir, f), "utf8").split("\n");
|
|
116
|
+
if (body.at(-1) === "")
|
|
117
|
+
body.pop();
|
|
118
|
+
if (body.length > MAX_REFERENCE_LINES)
|
|
119
|
+
problems.push(`${name}: references/${f} is ${body.length} lines (max ${MAX_REFERENCE_LINES})`);
|
|
120
|
+
if (!md.includes(`references/${f}`))
|
|
121
|
+
problems.push(`${name}: references/${f} is not linked from SKILL.md by its literal path`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const scriptsDir = join(dir, "scripts");
|
|
125
|
+
if (existsSync(scriptsDir)) {
|
|
126
|
+
for (const p of walk(scriptsDir).filter((f) => f.endsWith(".mjs"))) {
|
|
127
|
+
const rel = relative(dir, p);
|
|
128
|
+
const src = readFileSync(p, "utf8");
|
|
129
|
+
if (!src.startsWith("#!/usr/bin/env node"))
|
|
130
|
+
problems.push(`${name}: ${rel} must start with #!/usr/bin/env node`);
|
|
131
|
+
if (!src.includes("--help"))
|
|
132
|
+
problems.push(`${name}: ${rel} has no --help`);
|
|
133
|
+
for (const m of src.matchAll(IMPORT_RE)) {
|
|
134
|
+
const spec = m[1];
|
|
135
|
+
if (!(spec.startsWith("node:") || spec.startsWith("./") || spec.startsWith("../") || spec.startsWith("/"))) {
|
|
136
|
+
problems.push(`${name}: ${rel} imports "${spec}"; scripts may import only node: modules and relative files`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const portability = join(dir, "agents", "portability.yaml");
|
|
142
|
+
const openai = join(dir, "agents", "openai.yaml");
|
|
143
|
+
let mutating = false;
|
|
144
|
+
let implicitFalse = false;
|
|
145
|
+
if (!existsSync(portability))
|
|
146
|
+
problems.push(`${name}: agents/portability.yaml is missing`);
|
|
147
|
+
else {
|
|
148
|
+
const y = readFileSync(portability, "utf8");
|
|
149
|
+
if (!/^effects:\s*.*$/m.test(y))
|
|
150
|
+
problems.push(`${name}: agents/portability.yaml does not declare "effects"`);
|
|
151
|
+
if (!/^exposure:\s*\[\s*"?catalog"?\s*\]\s*$/m.test(y))
|
|
152
|
+
problems.push(`${name}: agents/portability.yaml must declare exposure: [catalog]`);
|
|
153
|
+
mutating = /^mutating:\s*true\s*$/m.test(y);
|
|
154
|
+
}
|
|
155
|
+
if (!existsSync(openai))
|
|
156
|
+
problems.push(`${name}: agents/openai.yaml is missing`);
|
|
157
|
+
else {
|
|
158
|
+
const y = readFileSync(openai, "utf8");
|
|
159
|
+
if (!/^policy:\s*$/m.test(y))
|
|
160
|
+
problems.push(`${name}: agents/openai.yaml has no "policy:" block`);
|
|
161
|
+
if (!/allow_implicit_invocation:\s*(true|false)/.test(y))
|
|
162
|
+
problems.push(`${name}: agents/openai.yaml does not set policy.allow_implicit_invocation`);
|
|
163
|
+
implicitFalse = /allow_implicit_invocation:\s*false/.test(y);
|
|
164
|
+
}
|
|
165
|
+
if (mutating || mutatingFrontmatter || implicitFalse) {
|
|
166
|
+
if (!mutating)
|
|
167
|
+
problems.push(`${name}: disable-model-invocation / allow_implicit_invocation: false are set but agents/portability.yaml lacks mutating: true`);
|
|
168
|
+
if (!mutatingFrontmatter)
|
|
169
|
+
problems.push(`${name}: is mutating but SKILL.md does not set disable-model-invocation: true`);
|
|
170
|
+
if (!implicitFalse)
|
|
171
|
+
problems.push(`${name}: is mutating but agents/openai.yaml does not set policy.allow_implicit_invocation: false`);
|
|
172
|
+
}
|
|
173
|
+
for (const p of walk(dir)) {
|
|
174
|
+
const rel = relative(dir, p);
|
|
175
|
+
if (/\.log$/i.test(rel))
|
|
176
|
+
problems.push(`${name}: ${rel} is a log file; no *.log may ship in a skill`);
|
|
177
|
+
let text;
|
|
178
|
+
try {
|
|
179
|
+
text = readFileSync(p, "utf8");
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
for (const f of FORBIDDEN_CONTENT)
|
|
185
|
+
if (f.re.test(text))
|
|
186
|
+
problems.push(`${name}: ${rel} mentions ${f.name}`);
|
|
187
|
+
}
|
|
188
|
+
return problems;
|
|
189
|
+
}
|
package/dist/skills.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
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 { defaultSkillsDirFor, upgradeCommand } from "./config.js";
|
|
7
|
+
import { semverOlder } from "./semver.js";
|
|
8
|
+
import { PROVENANCE_MARKER, parseFrontmatter, parseProvenanceVersion } from "./skill-shape.js";
|
|
9
|
+
export function skillsSourceDir() {
|
|
10
|
+
return fileURLToPath(new URL("../skills", import.meta.url));
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Copy every `skills/<name>/` that has a SKILL.md. A target directory whose SKILL.md lacks the
|
|
14
|
+
* provenance marker was not installed by this package and is skipped unless `force`.
|
|
15
|
+
*
|
|
16
|
+
* `onlyExisting` is the update path: it refreshes copies this package already made and creates
|
|
17
|
+
* none. That is what keeps the update notice from planting a second copy of every skill beside a
|
|
18
|
+
* set the customer's agent installed as a plugin.
|
|
19
|
+
*/
|
|
20
|
+
export function installSkills(targetDir, opts, sourceDir = skillsSourceDir()) {
|
|
21
|
+
const result = { installed: [], skipped: [] };
|
|
22
|
+
if (existsSync(targetDir) && !statSync(targetDir).isDirectory()) {
|
|
23
|
+
throw new Error(`${targetDir} is not a directory`);
|
|
24
|
+
}
|
|
25
|
+
const names = readdirSync(sourceDir, { withFileTypes: true })
|
|
26
|
+
.filter((e) => e.isDirectory())
|
|
27
|
+
.map((e) => e.name)
|
|
28
|
+
.sort();
|
|
29
|
+
for (const name of names) {
|
|
30
|
+
const src = join(sourceDir, name);
|
|
31
|
+
if (!existsSync(join(src, "SKILL.md")))
|
|
32
|
+
continue;
|
|
33
|
+
const dst = join(targetDir, name);
|
|
34
|
+
const existingMd = join(dst, "SKILL.md");
|
|
35
|
+
if (opts.onlyExisting && !existsSync(existingMd))
|
|
36
|
+
continue;
|
|
37
|
+
if (existsSync(existingMd) && !opts.force) {
|
|
38
|
+
const existing = readFileSync(existingMd, "utf8");
|
|
39
|
+
if (!existing.includes(PROVENANCE_MARKER)) {
|
|
40
|
+
result.skipped.push({ name, reason: "foreign-skill-dir" });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
mkdirSync(dst, { recursive: true });
|
|
45
|
+
cpSync(src, dst, { recursive: true });
|
|
46
|
+
result.installed.push(name);
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
export function resolveSkillsDir(args, ctx, cfg) {
|
|
51
|
+
return args.skillsDir ?? ctx.env.CATALYST_SKILLS_CLAUDE_DIR ?? cfg?.skillsDir ?? defaultSkillsDirFor(ctx.home);
|
|
52
|
+
}
|
|
53
|
+
/** The provenance comment in a SKILL.md, wherever that file's frontmatter actually ends.
|
|
54
|
+
*
|
|
55
|
+
* `validateSkillDir` requires the comment on the line right after the closing fence and caps the
|
|
56
|
+
* FILE's length, never the frontmatter's — so a fixed head window was an unpinned assumption, and a
|
|
57
|
+
* skill with a long wrapped `description` fell past it and became invisible: no version and not even
|
|
58
|
+
* counted as unstamped, silently dropping out of the staleness check. A file whose shape is off
|
|
59
|
+
* falls back to the whole-file scan `installSkills` already uses to recognise our own copies. */
|
|
60
|
+
function provenanceLine(text) {
|
|
61
|
+
const lines = text.split("\n");
|
|
62
|
+
const fm = parseFrontmatter(lines);
|
|
63
|
+
if (fm && fm.closeIndex !== -1) {
|
|
64
|
+
const after = lines[fm.closeIndex + 1];
|
|
65
|
+
if (after?.includes(PROVENANCE_MARKER))
|
|
66
|
+
return after;
|
|
67
|
+
}
|
|
68
|
+
return lines.find((l) => l.includes(PROVENANCE_MARKER));
|
|
69
|
+
}
|
|
70
|
+
/** What skill-bundle version is actually sitting in `dir`. Reads only the provenance comment; a
|
|
71
|
+
* directory this package did not write (no marker) is ignored, not counted as stale, and a name
|
|
72
|
+
* that is not installed at all is skipped. */
|
|
73
|
+
export function installedBundleVersion(dir, names) {
|
|
74
|
+
let version = null;
|
|
75
|
+
let skill = null;
|
|
76
|
+
const unstamped = [];
|
|
77
|
+
for (const name of names) {
|
|
78
|
+
const skillMd = join(dir, name, "SKILL.md");
|
|
79
|
+
if (!existsSync(skillMd))
|
|
80
|
+
continue;
|
|
81
|
+
let text;
|
|
82
|
+
try {
|
|
83
|
+
text = readFileSync(skillMd, "utf8");
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const line = provenanceLine(text);
|
|
89
|
+
if (line === undefined)
|
|
90
|
+
continue;
|
|
91
|
+
const stamp = parseProvenanceVersion(line);
|
|
92
|
+
if (stamp === null) {
|
|
93
|
+
unstamped.push(name);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (version === null || semverOlder(stamp, version)) {
|
|
97
|
+
version = stamp;
|
|
98
|
+
skill = name;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { version, skill, unstamped };
|
|
102
|
+
}
|
|
103
|
+
export function parseChangelogEntry(changelog, version) {
|
|
104
|
+
const lines = changelog.split("\n");
|
|
105
|
+
const heading = `## ${version}`;
|
|
106
|
+
const start = lines.findIndex((l) => l.trim() === heading);
|
|
107
|
+
if (start === -1)
|
|
108
|
+
return null;
|
|
109
|
+
for (const line of lines.slice(start + 1)) {
|
|
110
|
+
const t = line.trim();
|
|
111
|
+
if (t === "")
|
|
112
|
+
continue;
|
|
113
|
+
if (t.startsWith("#"))
|
|
114
|
+
break;
|
|
115
|
+
return t;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
export function readChangelog() {
|
|
120
|
+
return readFileSync(new URL("../CHANGELOG.md", import.meta.url), "utf8");
|
|
121
|
+
}
|
|
122
|
+
export function updateNoticeLine(previous, current, entry) {
|
|
123
|
+
const summary = entry ?? "see CHANGELOG.md";
|
|
124
|
+
// `npm update -g` treats a global install as a caret range, and a caret below 1.0.0 never moves a
|
|
125
|
+
// minor — so it never crosses 0.2.x → 0.3.0. Pin @latest via `npm install`, THEN re-run login: the
|
|
126
|
+
// install alone does not rewrite customer.json.cliPath, so the skill helpers would keep spawning the
|
|
127
|
+
// stale recorded bundle and repeat this notice forever; the new global bin records its own path.
|
|
128
|
+
return `[catalyst-skills] updated ${previous} → ${current}: ${summary} · update with: ${upgradeCommand()}`;
|
|
129
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
/** Both modes this runtime offers refused the source. Naming the file and both refusals beats
|
|
21
|
+
* surfacing Node's raw `options.mode` message, which names the mode Node rejected rather than the
|
|
22
|
+
* syntax in the SOURCE that made both modes fail. */
|
|
23
|
+
export class StripModeError extends Error {
|
|
24
|
+
sourceUrl;
|
|
25
|
+
refusals;
|
|
26
|
+
constructor(sourceUrl, refusals) {
|
|
27
|
+
super(`${sourceUrl}: could not be type-stripped by any mode this runtime offers. This usually means ` +
|
|
28
|
+
`the source uses TypeScript syntax with runtime semantics (an enum, a runtime namespace, a ` +
|
|
29
|
+
`parameter property, or \`export =\`) that Node's type-stripping can never erase, on a ` +
|
|
30
|
+
`runtime that also refuses \`transform\`. ` +
|
|
31
|
+
`strip refused: ${refusals.strip} | transform refused: ${refusals.transform}`);
|
|
32
|
+
this.name = "StripModeError";
|
|
33
|
+
this.sourceUrl = sourceUrl;
|
|
34
|
+
this.refusals = refusals;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function messageOf(error) {
|
|
38
|
+
return error instanceof Error ? error.message : String(error);
|
|
39
|
+
}
|
|
40
|
+
/** CTC-2483 — strip types preferring the one mode EVERY supported Node accepts.
|
|
41
|
+
*
|
|
42
|
+
* ⭐ `strip` is always tried first, and that ordering is the whole fix. Node 26's
|
|
43
|
+
* `stripTypeScriptTypes` accepts only `{ mode: "strip" }` and throws ERR_INVALID_ARG_VALUE for
|
|
44
|
+
* `"transform"`, so a loader that reaches for `transform` first dies there before it ever learns
|
|
45
|
+
* whether `strip` would have worked. But `strip` is not a weaker `transform`: it REFUSES
|
|
46
|
+
* TypeScript syntax with runtime semantics (enums, runtime namespaces, parameter properties,
|
|
47
|
+
* `export =`) that `transform` erases, so `transform` stays reachable as a fallback on Node 22-25
|
|
48
|
+
* for sources that need it. */
|
|
49
|
+
export function stripPreferringStrip(strip, source, sourceUrl) {
|
|
50
|
+
try {
|
|
51
|
+
return strip(source, { mode: "strip", sourceUrl });
|
|
52
|
+
}
|
|
53
|
+
catch (stripError) {
|
|
54
|
+
try {
|
|
55
|
+
return strip(source, { mode: "transform", sourceUrl });
|
|
56
|
+
}
|
|
57
|
+
catch (transformError) {
|
|
58
|
+
throw new StripModeError(sourceUrl, {
|
|
59
|
+
strip: messageOf(stripError),
|
|
60
|
+
transform: messageOf(transformError),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Build the hook pair over a strip function. Pure, so a test can drive it with fake `next`s. */
|
|
66
|
+
export function makeHooks(strip, fileExists = existsSync, readFile = (p) => readFileSync(p, "utf8")) {
|
|
67
|
+
return {
|
|
68
|
+
resolve(specifier, context, next) {
|
|
69
|
+
try {
|
|
70
|
+
return next(specifier, context);
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
const code = err?.code;
|
|
74
|
+
if (code === "ERR_MODULE_NOT_FOUND" && specifier.startsWith(".") && inDeps(context.parentURL)) {
|
|
75
|
+
const base = specifier.endsWith(".js") ? specifier.slice(0, -3) : specifier;
|
|
76
|
+
const alt = new URL(`${base}.ts`, context.parentURL);
|
|
77
|
+
if (fileExists(fileURLToPath(alt)))
|
|
78
|
+
return { url: alt.href, format: "module", shortCircuit: true };
|
|
79
|
+
}
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
load(url, context, next) {
|
|
84
|
+
if (inDeps(url) && url.endsWith(".ts")) {
|
|
85
|
+
return { format: "module", shortCircuit: true, source: stripPreferringStrip(strip, readFile(fileURLToPath(url)), url) };
|
|
86
|
+
}
|
|
87
|
+
return next(url, context);
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/** Install the hooks once. Returns whether they are installed and, if not, why. */
|
|
92
|
+
export function installTsDepsLoader(mod = nodeModule) {
|
|
93
|
+
if (installed)
|
|
94
|
+
return { installed: true };
|
|
95
|
+
if (typeof mod.registerHooks !== "function") {
|
|
96
|
+
return { installed: false, reason: `node:module has no registerHooks on Node ${process.version}; Node 22.15 or newer is required` };
|
|
97
|
+
}
|
|
98
|
+
if (typeof mod.stripTypeScriptTypes !== "function") {
|
|
99
|
+
return { installed: false, reason: `node:module has no stripTypeScriptTypes on Node ${process.version}; Node 22.13 or newer is required` };
|
|
100
|
+
}
|
|
101
|
+
// Node prints "stripTypeScriptTypes is an experimental feature" once on first use; that line would
|
|
102
|
+
// land on stderr of every SDK-loading verb, so the one warning this loader provokes is dropped.
|
|
103
|
+
const emit = process.emitWarning.bind(process);
|
|
104
|
+
process.emitWarning = ((warning, ...rest) => {
|
|
105
|
+
const text = typeof warning === "string" ? warning : warning instanceof Error ? warning.message : "";
|
|
106
|
+
if (text.includes("stripTypeScriptTypes"))
|
|
107
|
+
return;
|
|
108
|
+
emit(warning, ...rest);
|
|
109
|
+
});
|
|
110
|
+
mod.registerHooks(makeHooks(mod.stripTypeScriptTypes));
|
|
111
|
+
installed = true;
|
|
112
|
+
return { installed: true };
|
|
113
|
+
}
|