@yawlabs/ctxlint 0.25.0 → 0.25.2

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/bin/ctxlint.mjs CHANGED
@@ -1,360 +1,410 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Runtime launcher for @yawlabs/ctxlint.
4
- *
5
- * Prefers the oam runtime (https://oamjs.org) and falls back to the Node process
6
- * already running this file. The CLI itself (`dist/index.js`) is
7
- * runtime-agnostic -- a pre-bundled ESM entry using only `node:` builtins that
8
- * oam implements -- so neither path changes behavior. This covers both modes the
9
- * binary has: the linter (`ctxlint audit ...`) and the MCP server
10
- * (`ctxlint serve`), since every argument passes through untouched.
11
- *
12
- * WHY THE FALLBACK COSTS NOTHING
13
- * npm has already started Node to run this launcher, so falling back is a plain
14
- * `import()` of the CLI into THIS process: no extra spawn, no extra startup,
15
- * byte-identical to invoking dist/index.js directly. Discovery is stat-only --
16
- * never a subprocess -- so the miss case stays sub-millisecond.
17
- *
18
- * WHAT THE OAM PATH COSTS
19
- * Reaching oam through an npm `bin` means Node boots first and oam boots second,
20
- * so the launcher is slower than either runtime alone. It exists so `npx` users
21
- * get oam automatically. To skip it -- and for `serve`, which an MCP host starts
22
- * once per session, this is the better config -- point at oam directly:
23
- * { "command": "oam", "args": ["run", "<abs>/dist/index.js", "--", "serve"] }
24
- *
25
- * NO SANDBOX HERE -- DELIBERATELY
26
- * oam 0.9.0's `--permission` is real hardening, but it does not fit a linter.
27
- * ctxlint's whole purpose is to read context files the caller names at run time
28
- * -- CLAUDE.md, skills, agent transcripts, MCP configs, anywhere on disk -- so a
29
- * filesystem-read grant would have to be `*` to keep the tool working. Narrowing
30
- * it would turn "this path is not linted" into a silent clean result, which is
31
- * the worst failure mode a linter has. What is left to deny (network, child
32
- * process) it never uses anyway, so the sandbox would gate nothing real.
33
- *
34
- * MINIMUM OAM VERSION
35
- * 0.9.0. Below it `child_process.execFile` ran its arguments through a SHELL,
36
- * `exec` accepted `timeout` and ignored it, `spawnSync` truncated at `maxBuffer`
37
- * while reporting success, and `stdio: 'inherit'`/`'ignore'` both behaved as
38
- * `'pipe'`. This tool spawns nothing in shipped code, so the floor is enforced
39
- * for consistency across @yawlabs/*-mcp rather than because this launcher was
40
- * exposed. An older oam is not an error: the launcher falls back to Node and
41
- * says so on stderr.
42
- *
43
- * SELECTION
44
- * CTXLINT_RUNTIME=oam require oam; fail loudly if it is missing
45
- * CTXLINT_RUNTIME=node never use oam
46
- * CTXLINT_RUNTIME=auto prefer oam, silently fall back (default)
47
- * OAM_BIN=/path/to/oam explicit binary, checked before any discovery
48
- */
49
-
50
- import { execFileSync, spawn } from "node:child_process";
51
- import { existsSync } from "node:fs";
52
- import { constants, homedir } from "node:os";
53
- import { delimiter, join } from "node:path";
54
- import { fileURLToPath } from "node:url";
55
-
56
- /** Oldest oam whose `child_process` matches Node. See MINIMUM OAM VERSION above. */
57
- const OAM_MIN = [0, 9, 0];
58
-
59
- // Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
60
- // with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
61
- // in-process fallback must use the file:// URL. spawn() needs a real path.
62
- const SERVER_URL = new URL("../dist/index.js", import.meta.url);
63
- const SERVER_ENTRY = fileURLToPath(SERVER_URL);
64
- const isWin = process.platform === "win32";
65
- const exe = isWin ? "oam.exe" : "oam";
66
-
67
- /** Locate an oam binary, or null. Every branch is a stat, never a subprocess. */
68
- function findOam() {
69
- // 1. Explicit override wins and is never second-guessed.
70
- const override = process.env.OAM_BIN;
71
- if (override) return existsSync(override) ? override : null;
72
-
73
- // 2. Installed locations, BEFORE PATH. Someone who develops oam itself usually
74
- // has oam/target/release on PATH, and a build directory is the wrong thing
75
- // for a user-facing launcher to bind to: cargo replaces the binary
76
- // underneath running processes, and the dev build is not the release the
77
- // user installed. OAM_BIN remains the way to point at a dev build.
78
- const installed = [join(homedir(), ".oam", "bin", exe)];
79
- if (isWin) {
80
- installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
81
- }
82
- for (const candidate of installed) {
83
- if (existsSync(candidate)) return candidate;
84
- }
85
-
86
- // 3. PATH, resolved manually rather than by spawning `which`/`where`, which
87
- // would cost a subprocess on every launch just to decide whether to spawn.
88
- // Windows: `.exe` ONLY -- deliberately narrower than PATHEXT. Node refuses to
89
- // run a .cmd/.bat through execFile/spawn without `shell: true` (EINVAL, and
90
- // for spawn it throws SYNCHRONOUSLY rather than emitting 'error'), so walking
91
- // the full PATHEXT list would hand back a path this launcher cannot execute.
92
- // Discovery has to agree with execution. A skipped shim is still reported --
93
- // see findOamShim.
94
- for (const dir of (process.env.PATH ?? "").split(delimiter)) {
95
- if (!dir) continue;
96
- const candidate = join(dir, exe);
97
- if (existsSync(candidate)) return candidate;
98
- }
99
-
100
- return null;
101
- }
102
-
103
- /**
104
- * `oam --version` -> [major, minor, patch], or null when it cannot be read.
105
- * A pre-release suffix (0.9.0-rc.1) truncates to its base version.
106
- */
107
- function oamVersion(cmd) {
108
- try {
109
- const out = execFileSync(cmd, ["--version"], {
110
- encoding: "utf-8",
111
- stdio: ["ignore", "pipe", "ignore"],
112
- });
113
- const m = /(\d+)\.(\d+)\.(\d+)/.exec(out);
114
- return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
115
- } catch {
116
- // Not executable, wrong arch, or deleted since the stat. Caller degrades.
117
- return null;
118
- }
119
- }
120
-
121
- /** True when `v` is at least `min`, comparing major/minor/patch in order. */
122
- function atLeast(v, min) {
123
- if (!v) return false;
124
- for (let i = 0; i < min.length; i++) {
125
- if (v[i] > min[i]) return true;
126
- if (v[i] < min[i]) return false;
127
- }
128
- return true;
129
- }
130
-
131
- /**
132
- * Write a diagnostic to stderr synchronously, so a following process.exit
133
- * cannot truncate it.
134
- *
135
- * Not a bare writeSync: that call can short-write (it returns a byte count) and
136
- * on macOS it can throw EAGAIN, because Node makes a piped stderr non-blocking
137
- * there rather than blocking the write. Loop over the remaining bytes, and if
138
- * stderr turns out to be unusable give up quietly -- failing to print a
139
- * diagnostic is not worth crashing a stdio server over.
140
- */
141
- async function errSync(message) {
142
- const { writeSync } = await import("node:fs");
143
- const buf = Buffer.from(message);
144
- let off = 0;
145
- for (let attempts = 0; off < buf.length && attempts < 1000; attempts++) {
146
- try {
147
- off += writeSync(2, buf, off, buf.length - off);
148
- } catch (err) {
149
- if (err?.code !== "EAGAIN") return;
150
- // Pipe is full and the reader has not drained yet -- retry.
151
- }
152
- }
153
- }
154
-
155
- /**
156
- * An oam-named .cmd/.bat on PATH: a real install in a shape this launcher
157
- * cannot spawn. Reported rather than ignored, because "no oam binary was found"
158
- * reads as "install oam" -- the one thing that will not help. Windows only;
159
- * there is no such shim concept on POSIX.
160
- */
161
- function findOamShim() {
162
- if (!isWin) return null;
163
- for (const dir of (process.env.PATH ?? "").split(delimiter)) {
164
- if (!dir) continue;
165
- for (const ext of [".cmd", ".bat"]) {
166
- const candidate = join(dir, `oam${ext}`);
167
- if (existsSync(candidate)) return candidate;
168
- }
169
- }
170
- return null;
171
- }
172
-
173
- /** Run the CLI in THIS process. The zero-overhead fallback. */
174
- async function runInProcess() {
175
- // Point argv[1] at the CLI first, so the in-process path is indistinguishable
176
- // from having executed the file directly -- an entry-point guard
177
- // (`import.meta.url === pathToFileURL(process.argv[1]).href`) must read true.
178
- process.argv[1] = SERVER_ENTRY;
179
- await import(SERVER_URL.href);
180
- }
181
-
182
- const mode = (process.env.CTXLINT_RUNTIME ?? "auto").toLowerCase();
183
-
184
- if (mode === "node") {
185
- await runInProcess();
186
- } else {
187
- const oam = findOam();
188
- // Read the version ONCE, and only when discovery found something: the
189
- // gate below has to tell "too old" apart from "could not be read at all",
190
- // and re-probing inside the branch would cost a second subprocess.
191
- const found = oam ? oamVersion(oam) : null;
192
-
193
- if (!oam) {
194
- // An oam-named .cmd/.bat on PATH is a real install in a shape this
195
- // launcher cannot spawn. Naming it turns "no oam binary was found" --
196
- // which reads as "install oam", the one thing that will not help --
197
- // into something the user can act on.
198
- const oamShim = findOamShim();
199
- const shimNote = oamShim
200
- ? `Found ${oamShim}, but Node cannot execute a .cmd/.bat directly.\n` +
201
- "Install the native oam binary, or point OAM_BIN at one.\n"
202
- : "";
203
- if (mode === "oam") {
204
- // Explicitly demanded, so this is a real misconfiguration -- do not
205
- // silently do something else. writeSync because stderr is async for
206
- // TTYs/pipes on Windows and process.exit truncates pending writes.
207
- const { writeSync } = await import("node:fs");
208
- writeSync(
209
- 2,
210
- "ctxlint: CTXLINT_RUNTIME=oam but no runnable oam binary was found.\n" + shimNote +
211
- "Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use CTXLINT_RUNTIME=node.\n",
212
- );
213
- process.exit(1);
214
- }
215
- // auto: falling back is correct, but silence is how someone never learns
216
- // their oam install is a shape this launcher skips.
217
- if (oamShim) await errSync(`ctxlint: ${shimNote}Using Node instead.\n`);
218
- await runInProcess();
219
- } else if (!atLeast(found, OAM_MIN)) {
220
- const min = OAM_MIN.join(".");
221
- // Two different causes reach this branch and they need different
222
- // remedies. `found === null` is NOT "old": oamVersion returns null when
223
- // the binary could not be run at all (not executable, wrong arch, a
224
- // .cmd/.bat Node refuses, deleted between the stat and the probe) or
225
- // when its --version output did not parse. Telling that user to
226
- // `oam self-update` sends them after the one cause it definitely is not.
227
- const detail = found
228
- ? `${oam} is oam ${found.join(".")}, older than ${min}`
229
- : `${oam} could not be run, or did not report a version this launcher understands`;
230
- const remedy = found
231
- ? "Run \`oam self-update\`, or use CTXLINT_RUNTIME=node.\n"
232
- : "Check that it is an executable oam binary for this platform, or use CTXLINT_RUNTIME=node.\n";
233
- if (mode === "oam") {
234
- await errSync(`ctxlint: CTXLINT_RUNTIME=oam but ${detail}.\n${remedy}`);
235
- process.exit(1);
236
- }
237
- // auto: neither cause is worth failing over -- prefer Node. Say so,
238
- // because a silent downgrade is how someone keeps running an oam they
239
- // meant to update, or never learns their oam is unexecutable.
240
- await errSync(`ctxlint: ${detail}; using Node instead.\n`);
241
- await runInProcess();
242
- } else {
243
- // `--` separates oam's own flags from the script's argv. Everything after it
244
- // lands in process.argv for the CLI, so `audit`, `serve` and every flag
245
- // survive the hop unchanged.
246
- // Every "oam could not be executed" outcome lands here: the synchronous
247
- // throw from spawn() and the async 'error' event mean the same thing and
248
- // must degrade the same way, so the handling lives in one place.
249
- // errSync rather than process.stderr.write because stderr is async for
250
- // TTYs and pipes on Windows and the process.exit below truncates pending
251
- // writes.
252
- const launchFailed = async (err) => {
253
- if (mode === "oam") {
254
- await errSync(`ctxlint: failed to launch oam (${err?.message ?? err})\n`);
255
- process.exit(1);
256
- }
257
- await runInProcess();
258
- };
259
-
260
- // ONE reporter shared by both launchFailed call sites, so the sync-throw
261
- // path and the 'error'-event path cannot drift apart. Either can reject:
262
- // runInProcess() is a bare import() that rejects when dist/index.js is
263
- // missing, and at ESM top level an unhandled rejection is an uncaught
264
- // exception -- the exact failure this handling exists to prevent.
265
- const fallbackFailed = (e) => {
266
- process.stderr.write(`ctxlint: fallback to Node failed (${e?.message ?? e})\n`);
267
- process.exitCode = 1;
268
- };
269
-
270
- let child = null;
271
- try {
272
- child = spawn(oam, ["run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
273
- // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
274
- // stdin/stdout under `serve` is untouched, and the linter's exit-code and
275
- // output behavior is identical to running it directly.
276
- stdio: "inherit",
277
- env: process.env,
278
- windowsHide: true,
279
- });
280
- } catch (err) {
281
- // spawn() THROWS for some failures instead of emitting 'error', and the
282
- // 'error' listener is registered AFTER this call, so it can never observe
283
- // one -- an uncaught throw here kills the launcher with a raw stack trace
284
- // instead of falling back to Node.
285
- await launchFailed(err).catch(fallbackFailed);
286
- }
287
-
288
- if (child) {
289
-
290
- // If oam cannot be executed at all (deleted between the stat and the spawn,
291
- // wrong arch, permission), fall back rather than failing outright.
292
- // `spawned` guards against falling back AFTER the child has begun running.
293
- let spawned = false;
294
- child.on("spawn", () => {
295
- spawned = true;
296
- });
297
- child.on("error", (err) => {
298
- if (spawned) return;
299
- // Handle the rejection instead of discarding it: a failing in-process
300
- // fallback would otherwise escape as an unhandled rejection, replacing
301
- // this launcher's diagnostic with a raw stack trace.
302
- launchFailed(err).catch(fallbackFailed);
303
- });
304
-
305
- // Forward termination so the server's own shutdown path runs in the child
306
- // rather than the child being orphaned.
307
- //
308
- // Registering ANY handler for these suppresses Node's default
309
- // terminate-on-signal, so the parent's exit has to be arranged explicitly.
310
- // `child.killed` only records that kill() was CALLED, never that the child
311
- // is gone, so gating on it swallows every signal after the first and wedges
312
- // the launcher with no escape hatch.
313
- //
314
- // Escalation is driven by a TIMER, not by counting signals. Counting is
315
- // ambiguous: a supervisor routinely sends SIGINT then SIGTERM milliseconds
316
- // apart, and a terminal Ctrl-C reaches the whole process group, so reading
317
- // "a second signal" as impatience hard-kills a child that is already
318
- // shutting down cleanly. A timer makes the count irrelevant -- ONE press is
319
- // enough, and a wedged child dies on schedule. setTimeout is monotonic, so
320
- // a wall-clock step cannot mis-gate the window either.
321
- //
322
- // POSIX vs Windows, and why we do NOT forward on Windows.
323
- // On POSIX child.kill(sig) delivers a real, catchable signal, so forwarding
324
- // is what lets the child run its shutdown. On Windows there are no POSIX
325
- // signals: child.kill IGNORES the name and calls TerminateProcess -- an
326
- // immediate hard kill (verified: a child with a SIGTERM handler never runs
327
- // it and dies with code=null, signal=SIGTERM). Forwarding there ABORTS the
328
- // graceful shutdown the console's own Ctrl-C just started, skipping the
329
- // child's process.on("exit") cleanup. The console has already notified the
330
- // child, so on Windows the timer below is the only kill we issue.
331
- const ESCALATE_AFTER_MS = 2000;
332
- let escalation = null;
333
- for (const sig of ["SIGINT", "SIGTERM"]) {
334
- process.on(sig, () => {
335
- // No try/catch: kill() on an already-exited child returns false, it does
336
- // not throw. It throws only for a signal the platform does not know,
337
- // which SIGINT/SIGTERM/SIGKILL never are.
338
- if (!isWin) child.kill(sig);
339
- if (escalation) return; // already counting down; further signals are noise
340
- escalation = setTimeout(() => {
341
- // Still here after its grace window. Stop waiting on it.
342
- child.kill("SIGKILL");
343
- process.exit(128 + (constants.signals[sig] ?? 15));
344
- }, ESCALATE_AFTER_MS);
345
- });
346
- }
347
-
348
- child.on("exit", (code, signal) => {
349
- if (escalation) clearTimeout(escalation);
350
- // Mirror the child's fate: a signal death becomes 128+n so callers see a
351
- // conventional shell exit status rather than a bare 0. ctxlint's exit code
352
- // is how CI reads a lint failure, so passing it through is load-bearing.
353
- if (signal) {
354
- process.exit(128 + (constants.signals[signal] ?? 15));
355
- }
356
- process.exit(code ?? 0);
357
- });
358
- }
359
- }
360
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Runtime launcher for @yawlabs/ctxlint.
4
+ *
5
+ * Prefers the oam runtime (https://oamjs.org) and falls back to the Node process
6
+ * already running this file. The CLI itself (`dist/index.js`) is
7
+ * runtime-agnostic -- a pre-bundled ESM entry using only `node:` builtins that
8
+ * oam implements -- so neither path changes behavior. This covers both modes the
9
+ * binary has: the linter (`ctxlint audit ...`) and the MCP server
10
+ * (`ctxlint serve`), since every argument passes through untouched.
11
+ *
12
+ * WHY THE FALLBACK COSTS NOTHING
13
+ * npm has already started Node to run this launcher, so falling back is a plain
14
+ * `import()` of the CLI into THIS process: no extra spawn, no extra startup,
15
+ * byte-identical to invoking dist/index.js directly. Discovery is stat-only --
16
+ * never a subprocess -- so the miss case stays sub-millisecond.
17
+ *
18
+ * WHAT THE OAM PATH COSTS
19
+ * Reaching oam through an npm `bin` means Node boots first and oam boots second,
20
+ * so the launcher is slower than either runtime alone. It exists so `npx` users
21
+ * get oam automatically. To skip it -- and for `serve`, which an MCP host starts
22
+ * once per session, this is the better config -- point at oam directly:
23
+ * { "command": "oam", "args": ["run", "<abs>/dist/index.js", "--", "serve"] }
24
+ *
25
+ * ALREADY RUNNING ON OAM
26
+ * A host can resolve this package's `bin` and launch `oam run <this file>`
27
+ * instead of `node <this file>` -- Yaw MCP does, and so does oam's sidecar
28
+ * regression matrix. This launcher used to discover oam and spawn it anyway,
29
+ * so one server cost two runtime boots: measured on Windows, oam.exe with a
30
+ * NESTED oam.exe + conhost.exe underneath it. Now, when `process.versions.oam`
31
+ * clears the same MINIMUM OAM VERSION a discovered binary has to, the CLI is
32
+ * imported into THIS process exactly as the Node fallback is -- no discovery,
33
+ * no `oam --version` probe, no second oam. OAM_BIN is a discovery input, so it
34
+ * is not consulted on that path: the host has already chosen which oam runs.
35
+ *
36
+ * With no sandbox (below), nothing here needs a FRESH oam, so the only case
37
+ * that still spawns is a host oam below the floor, which takes the discovery
38
+ * path exactly as it always did.
39
+ *
40
+ * NO SANDBOX HERE -- DELIBERATELY
41
+ * oam 0.9.0's `--permission` is real hardening, but it does not fit a linter.
42
+ * ctxlint's whole purpose is to read context files the caller names at run time
43
+ * -- CLAUDE.md, skills, agent transcripts, MCP configs, anywhere on disk -- so a
44
+ * filesystem-read grant would have to be `*` to keep the tool working. Narrowing
45
+ * it would turn "this path is not linted" into a silent clean result, which is
46
+ * the worst failure mode a linter has. What is left to deny (network, child
47
+ * process) it never uses anyway, so the sandbox would gate nothing real.
48
+ *
49
+ * MINIMUM OAM VERSION
50
+ * 0.9.0. Below it `child_process.execFile` ran its arguments through a SHELL,
51
+ * `exec` accepted `timeout` and ignored it, `spawnSync` truncated at `maxBuffer`
52
+ * while reporting success, and `stdio: 'inherit'`/`'ignore'` both behaved as
53
+ * `'pipe'`. This tool spawns nothing in shipped code, so the floor is enforced
54
+ * for consistency across @yawlabs/*-mcp rather than because this launcher was
55
+ * exposed. An older oam is not an error: the launcher falls back to Node and
56
+ * says so on stderr.
57
+ *
58
+ * SELECTION
59
+ * CTXLINT_RUNTIME=oam require oam; fail loudly if it is missing
60
+ * (already running on oam satisfies it)
61
+ * CTXLINT_RUNTIME=node never use oam
62
+ * CTXLINT_RUNTIME=auto prefer oam, silently fall back (default)
63
+ * OAM_BIN=/path/to/oam explicit binary, checked before any discovery
64
+ */
65
+
66
+ import { execFileSync, spawn } from 'node:child_process';
67
+ import { existsSync } from 'node:fs';
68
+ import { constants, homedir } from 'node:os';
69
+ import { delimiter, join } from 'node:path';
70
+ import { fileURLToPath } from 'node:url';
71
+
72
+ /** Oldest oam whose `child_process` matches Node. See MINIMUM OAM VERSION above. */
73
+ const OAM_MIN = [0, 9, 0];
74
+
75
+ // Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
76
+ // with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
77
+ // in-process fallback must use the file:// URL. spawn() needs a real path.
78
+ const SERVER_URL = new URL('../dist/index.js', import.meta.url);
79
+ const SERVER_ENTRY = fileURLToPath(SERVER_URL);
80
+ const isWin = process.platform === 'win32';
81
+ const exe = isWin ? 'oam.exe' : 'oam';
82
+
83
+ /** Locate an oam binary, or null. Every branch is a stat, never a subprocess. */
84
+ function findOam() {
85
+ // 1. Explicit override wins and is never second-guessed.
86
+ const override = process.env.OAM_BIN;
87
+ if (override) return existsSync(override) ? override : null;
88
+
89
+ // 2. Installed locations, BEFORE PATH. Someone who develops oam itself usually
90
+ // has oam/target/release on PATH, and a build directory is the wrong thing
91
+ // for a user-facing launcher to bind to: cargo replaces the binary
92
+ // underneath running processes, and the dev build is not the release the
93
+ // user installed. OAM_BIN remains the way to point at a dev build.
94
+ const installed = [join(homedir(), '.oam', 'bin', exe)];
95
+ if (isWin) {
96
+ installed.unshift(
97
+ join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'oam', 'bin', exe),
98
+ );
99
+ }
100
+ for (const candidate of installed) {
101
+ if (existsSync(candidate)) return candidate;
102
+ }
103
+
104
+ // 3. PATH, resolved manually rather than by spawning `which`/`where`, which
105
+ // would cost a subprocess on every launch just to decide whether to spawn.
106
+ // Windows: `.exe` ONLY -- deliberately narrower than PATHEXT. Node refuses to
107
+ // run a .cmd/.bat through execFile/spawn without `shell: true` (EINVAL, and
108
+ // for spawn it throws SYNCHRONOUSLY rather than emitting 'error'), so walking
109
+ // the full PATHEXT list would hand back a path this launcher cannot execute.
110
+ // Discovery has to agree with execution. A skipped shim is still reported --
111
+ // see findOamShim.
112
+ for (const dir of (process.env.PATH ?? '').split(delimiter)) {
113
+ if (!dir) continue;
114
+ const candidate = join(dir, exe);
115
+ if (existsSync(candidate)) return candidate;
116
+ }
117
+
118
+ return null;
119
+ }
120
+
121
+ /**
122
+ * Version text -> [major, minor, patch], or null when it holds no version.
123
+ * A pre-release suffix (0.9.0-rc.1) truncates to its base version.
124
+ *
125
+ * Shared by the two places a version is read -- a discovered binary's
126
+ * `oam --version` output ("oam 0.15.1") and the host's own
127
+ * `process.versions.oam` ("0.15.1") -- so they cannot disagree about what a
128
+ * version string means, or which floor it has to clear.
129
+ */
130
+ function parseVersion(text) {
131
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(text);
132
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
133
+ }
134
+
135
+ /** `oam --version` -> [major, minor, patch], or null when it cannot be read. */
136
+ function oamVersion(cmd) {
137
+ try {
138
+ const out = execFileSync(cmd, ['--version'], {
139
+ encoding: 'utf-8',
140
+ stdio: ['ignore', 'pipe', 'ignore'],
141
+ });
142
+ return parseVersion(out);
143
+ } catch {
144
+ // Not executable, wrong arch, or deleted since the stat. Caller degrades.
145
+ return null;
146
+ }
147
+ }
148
+
149
+ /** True when `v` is at least `min`, comparing major/minor/patch in order. */
150
+ function atLeast(v, min) {
151
+ if (!v) return false;
152
+ for (let i = 0; i < min.length; i++) {
153
+ if (v[i] > min[i]) return true;
154
+ if (v[i] < min[i]) return false;
155
+ }
156
+ return true;
157
+ }
158
+
159
+ /**
160
+ * Where the CLI runs, decided BEFORE any discovery:
161
+ * "in-process" import it into THIS process
162
+ * "discover" find an oam binary, gate its version, spawn it -- or fall
163
+ * back to Node in-process when that fails
164
+ *
165
+ * `hostOam` is `process.versions.oam`: oam's own key, absent on Node, so on
166
+ * Node every mode but `node` is the discovery path it always was. There is no
167
+ * sandbox input because this launcher has no sandbox; see NO SANDBOX HERE
168
+ * above. The floor is OAM_MIN itself, not a parameter, so a host oam and a
169
+ * discovered one can never be held to different minimums.
170
+ *
171
+ * Pure on purpose: every input is passed in, so the whole decision is testable
172
+ * without booting a runtime.
173
+ */
174
+ function runtimePlan({ mode, hostOam }) {
175
+ if (mode === 'node') return 'in-process';
176
+ return atLeast(parseVersion(hostOam ?? ''), OAM_MIN) ? 'in-process' : 'discover';
177
+ }
178
+
179
+ /**
180
+ * Write a diagnostic to stderr synchronously, so a following process.exit
181
+ * cannot truncate it.
182
+ *
183
+ * Not a bare writeSync: that call can short-write (it returns a byte count) and
184
+ * on macOS it can throw EAGAIN, because Node makes a piped stderr non-blocking
185
+ * there rather than blocking the write. Loop over the remaining bytes, and if
186
+ * stderr turns out to be unusable give up quietly -- failing to print a
187
+ * diagnostic is not worth crashing a stdio server over.
188
+ */
189
+ async function errSync(message) {
190
+ const { writeSync } = await import('node:fs');
191
+ const buf = Buffer.from(message);
192
+ let off = 0;
193
+ for (let attempts = 0; off < buf.length && attempts < 1000; attempts++) {
194
+ try {
195
+ off += writeSync(2, buf, off, buf.length - off);
196
+ } catch (err) {
197
+ if (err?.code !== 'EAGAIN') return;
198
+ // Pipe is full and the reader has not drained yet -- retry.
199
+ }
200
+ }
201
+ }
202
+
203
+ /**
204
+ * An oam-named .cmd/.bat on PATH: a real install in a shape this launcher
205
+ * cannot spawn. Reported rather than ignored, because "no oam binary was found"
206
+ * reads as "install oam" -- the one thing that will not help. Windows only;
207
+ * there is no such shim concept on POSIX.
208
+ */
209
+ function findOamShim() {
210
+ if (!isWin) return null;
211
+ for (const dir of (process.env.PATH ?? '').split(delimiter)) {
212
+ if (!dir) continue;
213
+ for (const ext of ['.cmd', '.bat']) {
214
+ const candidate = join(dir, `oam${ext}`);
215
+ if (existsSync(candidate)) return candidate;
216
+ }
217
+ }
218
+ return null;
219
+ }
220
+
221
+ /** Run the CLI in THIS process. The zero-overhead fallback. */
222
+ async function runInProcess() {
223
+ // Point argv[1] at the CLI first, so the in-process path is indistinguishable
224
+ // from having executed the file directly -- an entry-point guard
225
+ // (`import.meta.url === pathToFileURL(process.argv[1]).href`) must read true.
226
+ process.argv[1] = SERVER_ENTRY;
227
+ await import(SERVER_URL.href);
228
+ }
229
+
230
+ const mode = (process.env.CTXLINT_RUNTIME ?? 'auto').toLowerCase();
231
+
232
+ const plan = runtimePlan({ mode, hostOam: process.versions.oam });
233
+
234
+ if (plan === 'in-process') {
235
+ await runInProcess();
236
+ } else {
237
+ const oam = findOam();
238
+ // Read the version ONCE, and only when discovery found something: the
239
+ // gate below has to tell "too old" apart from "could not be read at all",
240
+ // and re-probing inside the branch would cost a second subprocess.
241
+ const found = oam ? oamVersion(oam) : null;
242
+
243
+ if (!oam) {
244
+ // An oam-named .cmd/.bat on PATH is a real install in a shape this
245
+ // launcher cannot spawn. Naming it turns "no oam binary was found" --
246
+ // which reads as "install oam", the one thing that will not help --
247
+ // into something the user can act on.
248
+ const oamShim = findOamShim();
249
+ const shimNote = oamShim
250
+ ? `Found ${oamShim}, but Node cannot execute a .cmd/.bat directly.\n` +
251
+ 'Install the native oam binary, or point OAM_BIN at one.\n'
252
+ : '';
253
+ if (mode === 'oam') {
254
+ // Explicitly demanded, so this is a real misconfiguration -- do not
255
+ // silently do something else. writeSync because stderr is async for
256
+ // TTYs/pipes on Windows and process.exit truncates pending writes.
257
+ const { writeSync } = await import('node:fs');
258
+ writeSync(
259
+ 2,
260
+ 'ctxlint: CTXLINT_RUNTIME=oam but no runnable oam binary was found.\n' +
261
+ shimNote +
262
+ 'Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use CTXLINT_RUNTIME=node.\n',
263
+ );
264
+ process.exit(1);
265
+ }
266
+ // auto: falling back is correct, but silence is how someone never learns
267
+ // their oam install is a shape this launcher skips.
268
+ if (oamShim) await errSync(`ctxlint: ${shimNote}Using Node instead.\n`);
269
+ await runInProcess();
270
+ } else if (!atLeast(found, OAM_MIN)) {
271
+ const min = OAM_MIN.join('.');
272
+ // Two different causes reach this branch and they need different
273
+ // remedies. `found === null` is NOT "old": oamVersion returns null when
274
+ // the binary could not be run at all (not executable, wrong arch, a
275
+ // .cmd/.bat Node refuses, deleted between the stat and the probe) or
276
+ // when its --version output did not parse. Telling that user to
277
+ // `oam self-update` sends them after the one cause it definitely is not.
278
+ const detail = found
279
+ ? `${oam} is oam ${found.join('.')}, older than ${min}`
280
+ : `${oam} could not be run, or did not report a version this launcher understands`;
281
+ const remedy = found
282
+ ? 'Run \`oam self-update\`, or use CTXLINT_RUNTIME=node.\n'
283
+ : 'Check that it is an executable oam binary for this platform, or use CTXLINT_RUNTIME=node.\n';
284
+ if (mode === 'oam') {
285
+ await errSync(`ctxlint: CTXLINT_RUNTIME=oam but ${detail}.\n${remedy}`);
286
+ process.exit(1);
287
+ }
288
+ // auto: neither cause is worth failing over -- prefer Node. Say so,
289
+ // because a silent downgrade is how someone keeps running an oam they
290
+ // meant to update, or never learns their oam is unexecutable.
291
+ await errSync(`ctxlint: ${detail}; using Node instead.\n`);
292
+ await runInProcess();
293
+ } else {
294
+ // `--` separates oam's own flags from the script's argv. Everything after it
295
+ // lands in process.argv for the CLI, so `audit`, `serve` and every flag
296
+ // survive the hop unchanged.
297
+ // Every "oam could not be executed" outcome lands here: the synchronous
298
+ // throw from spawn() and the async 'error' event mean the same thing and
299
+ // must degrade the same way, so the handling lives in one place.
300
+ // errSync rather than process.stderr.write because stderr is async for
301
+ // TTYs and pipes on Windows and the process.exit below truncates pending
302
+ // writes.
303
+ const launchFailed = async (err) => {
304
+ if (mode === 'oam') {
305
+ await errSync(`ctxlint: failed to launch oam (${err?.message ?? err})\n`);
306
+ process.exit(1);
307
+ }
308
+ await runInProcess();
309
+ };
310
+
311
+ // ONE reporter shared by both launchFailed call sites, so the sync-throw
312
+ // path and the 'error'-event path cannot drift apart. Either can reject:
313
+ // runInProcess() is a bare import() that rejects when dist/index.js is
314
+ // missing, and at ESM top level an unhandled rejection is an uncaught
315
+ // exception -- the exact failure this handling exists to prevent.
316
+ const fallbackFailed = (e) => {
317
+ process.stderr.write(`ctxlint: fallback to Node failed (${e?.message ?? e})\n`);
318
+ process.exitCode = 1;
319
+ };
320
+
321
+ let child = null;
322
+ try {
323
+ child = spawn(oam, ['run', SERVER_ENTRY, '--', ...process.argv.slice(2)], {
324
+ // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
325
+ // stdin/stdout under `serve` is untouched, and the linter's exit-code and
326
+ // output behavior is identical to running it directly.
327
+ stdio: 'inherit',
328
+ env: process.env,
329
+ windowsHide: true,
330
+ });
331
+ } catch (err) {
332
+ // spawn() THROWS for some failures instead of emitting 'error', and the
333
+ // 'error' listener is registered AFTER this call, so it can never observe
334
+ // one -- an uncaught throw here kills the launcher with a raw stack trace
335
+ // instead of falling back to Node.
336
+ await launchFailed(err).catch(fallbackFailed);
337
+ }
338
+
339
+ if (child) {
340
+ // If oam cannot be executed at all (deleted between the stat and the spawn,
341
+ // wrong arch, permission), fall back rather than failing outright.
342
+ // `spawned` guards against falling back AFTER the child has begun running.
343
+ let spawned = false;
344
+ child.on('spawn', () => {
345
+ spawned = true;
346
+ });
347
+ child.on('error', (err) => {
348
+ if (spawned) return;
349
+ // Handle the rejection instead of discarding it: a failing in-process
350
+ // fallback would otherwise escape as an unhandled rejection, replacing
351
+ // this launcher's diagnostic with a raw stack trace.
352
+ launchFailed(err).catch(fallbackFailed);
353
+ });
354
+
355
+ // Forward termination so the server's own shutdown path runs in the child
356
+ // rather than the child being orphaned.
357
+ //
358
+ // Registering ANY handler for these suppresses Node's default
359
+ // terminate-on-signal, so the parent's exit has to be arranged explicitly.
360
+ // `child.killed` only records that kill() was CALLED, never that the child
361
+ // is gone, so gating on it swallows every signal after the first and wedges
362
+ // the launcher with no escape hatch.
363
+ //
364
+ // Escalation is driven by a TIMER, not by counting signals. Counting is
365
+ // ambiguous: a supervisor routinely sends SIGINT then SIGTERM milliseconds
366
+ // apart, and a terminal Ctrl-C reaches the whole process group, so reading
367
+ // "a second signal" as impatience hard-kills a child that is already
368
+ // shutting down cleanly. A timer makes the count irrelevant -- ONE press is
369
+ // enough, and a wedged child dies on schedule. setTimeout is monotonic, so
370
+ // a wall-clock step cannot mis-gate the window either.
371
+ //
372
+ // POSIX vs Windows, and why we do NOT forward on Windows.
373
+ // On POSIX child.kill(sig) delivers a real, catchable signal, so forwarding
374
+ // is what lets the child run its shutdown. On Windows there are no POSIX
375
+ // signals: child.kill IGNORES the name and calls TerminateProcess -- an
376
+ // immediate hard kill (verified: a child with a SIGTERM handler never runs
377
+ // it and dies with code=null, signal=SIGTERM). Forwarding there ABORTS the
378
+ // graceful shutdown the console's own Ctrl-C just started, skipping the
379
+ // child's process.on("exit") cleanup. The console has already notified the
380
+ // child, so on Windows the timer below is the only kill we issue.
381
+ const ESCALATE_AFTER_MS = 2000;
382
+ let escalation = null;
383
+ for (const sig of ['SIGINT', 'SIGTERM']) {
384
+ process.on(sig, () => {
385
+ // No try/catch: kill() on an already-exited child returns false, it does
386
+ // not throw. It throws only for a signal the platform does not know,
387
+ // which SIGINT/SIGTERM/SIGKILL never are.
388
+ if (!isWin) child.kill(sig);
389
+ if (escalation) return; // already counting down; further signals are noise
390
+ escalation = setTimeout(() => {
391
+ // Still here after its grace window. Stop waiting on it.
392
+ child.kill('SIGKILL');
393
+ process.exit(128 + (constants.signals[sig] ?? 15));
394
+ }, ESCALATE_AFTER_MS);
395
+ });
396
+ }
397
+
398
+ child.on('exit', (code, signal) => {
399
+ if (escalation) clearTimeout(escalation);
400
+ // Mirror the child's fate: a signal death becomes 128+n so callers see a
401
+ // conventional shell exit status rather than a bare 0. ctxlint's exit code
402
+ // is how CI reads a lint failure, so passing it through is load-bearing.
403
+ if (signal) {
404
+ process.exit(128 + (constants.signals[signal] ?? 15));
405
+ }
406
+ process.exit(code ?? 0);
407
+ });
408
+ }
409
+ }
410
+ }