@yawlabs/lemonsqueezy-mcp 0.13.0 → 0.13.1

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 CHANGED
@@ -1,7 +1,19 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
4
-
3
+ ## [Unreleased]
4
+
5
+ ### Changed
6
+ - **The minimum oam version is now actually enforced.** `oamVersion()` and `atLeast()` were defined but never called, so `OAM_MIN` was dead code and any oam on the box was spawned regardless of version — including the pre-0.9.0 releases the floor exists to exclude, where `child_process.execFile` ran its arguments through a shell, `exec` accepted `timeout` and ignored it, and `stdio: 'inherit'` behaved as `'pipe'`. This launcher shells out on its main paths, so those were reachable bugs. An oam below the floor is now refused under `LEMONSQUEEZY_MCP_RUNTIME=oam` and bypassed for Node under `auto`, in both cases saying which version it found.
7
+
8
+ ### Fixed
9
+ - **The launcher no longer dies with a raw stack trace when `spawn` fails.** Node throws synchronously rather than emitting `error` for some unexecutable targets — notably a `.cmd`/`.bat` on Windows — and the `error` listener is registered *after* the `spawn` call, so it could never observe that throw. Both failure modes now route through one handler.
10
+ - **Windows `PATH` discovery accepts `oam.exe` only**, instead of walking every `PATHEXT` entry and returning an `oam.cmd` Node cannot execute. A skipped shim is still **named** in the diagnostic, so an npm-style install no longer reports as "no oam binary was found".
11
+ - **A failing in-process fallback no longer escapes as an unhandled rejection.** `void runInProcess()` discarded the promise, replacing the launcher's own diagnostic with a raw stack trace.
12
+ - **Diagnostics that precede `process.exit` are written synchronously.** stderr is async for TTYs and pipes on Windows, so the exit could truncate them. They route through one helper that also handles short writes and macOS `EAGAIN` on a non-blocking piped stderr.
13
+ - Removed a literal backspace byte (`U+0008`) from the runtime-discovery comment, which made git treat the file as binary so its diff could not be reviewed.
14
+ - **Windows: the launcher no longer hard-kills the server on the first Ctrl-C.** There are no POSIX signals on Windows — `child.kill(sig)` ignores the name and calls `TerminateProcess`, an immediate hard kill (verified: a child with a `SIGTERM` handler never runs it and dies with `code=null`). The launcher forwarded anyway, on the stated assumption that this was a "no-op on Windows", so it aborted the graceful shutdown the console's own Ctrl-C had just started and skipped the server's `process.on("exit")` cleanup. The console already delivers the event to the whole process group, so on Windows the launcher now forwards nothing.
15
+ - **A wedged server no longer leaves the launcher hanging.** Forwarding was gated on `child.killed`, which records only that `kill()` was *called* — never that the child is gone — so every signal after the first was swallowed and there was no escape hatch. Escalation is now armed by a timer on the first signal: one press is enough, and a child still alive after a 2s grace window is killed. Using a timer rather than counting signals also stops the ordinary supervisor sequence (`SIGINT` then `SIGTERM` milliseconds apart) from being misread as impatience.
16
+
5
17
  ## [0.12.0] — 2026-08-07
6
18
 
7
19
  ### Added
@@ -22,37 +22,37 @@
22
22
  * For an MCP host config, point straight at oam and skip this file:
23
23
  * { "command": "oam", "args": ["run", "<abs>/dist/index.js"] }
24
24
  *
25
- * THE `--permission` SANDBOX (oam 0.9.0+, opt-in)
26
- * `LEMONSQUEEZY_MCP_SANDBOX=1` runs the server under oam's permission model:
27
- * network limited to api.lemonsqueezy.com, filesystem denied outright.
28
- *
29
- * LEMONSQUEEZY_SINK_URL is operator-configured, so its host is parsed out and
30
- * added to the grant when set rather than assumed. Child-process stays denied
31
- * UNLESS LEMONSQUEEZY_API_KEY_COMMAND is configured -- that feature shells out
32
- * to fetch the key, so the grant is tied to the feature instead of handed over
33
- * unconditionally.
34
- *
35
- * Opt-in, not default, because a wrong grant does not fail loudly. oam denies a
36
- * non-granted environment variable by making it ABSENT from process.env rather
37
- * than throwing, so an under-granted LEMONSQUEEZY_API_KEY reads as
38
- * "unauthenticated" rather than "denied". The env list is derived from what the
39
- * shipped bundle actually reads -- keep it in step with the bundle.
40
- *
41
- * MINIMUM OAM VERSION
42
- * 0.9.0. Below it `child_process.execFile` ran its arguments through a SHELL,
43
- * `exec` accepted `timeout` and ignored it, `spawnSync` truncated at
44
- * `maxBuffer` while reporting success, and `stdio: 'inherit'`/`'ignore'` both
45
- * behaved as `'pipe'`. This server shells out only when
46
- * LEMONSQUEEZY_API_KEY_COMMAND is configured, so the execFile bug was reachable
47
- * on exactly that path -- the command's arguments were re-split by a shell.
48
- * An older oam is not an error: the launcher falls back to Node and says so on
49
- * stderr. Pinning the floor here is what makes that fallback automatic.
50
- *
25
+ * THE `--permission` SANDBOX (oam 0.9.0+, opt-in)
26
+ * `LEMONSQUEEZY_MCP_SANDBOX=1` runs the server under oam's permission model:
27
+ * network limited to api.lemonsqueezy.com, filesystem denied outright.
28
+ *
29
+ * LEMONSQUEEZY_SINK_URL is operator-configured, so its host is parsed out and
30
+ * added to the grant when set rather than assumed. Child-process stays denied
31
+ * UNLESS LEMONSQUEEZY_API_KEY_COMMAND is configured -- that feature shells out
32
+ * to fetch the key, so the grant is tied to the feature instead of handed over
33
+ * unconditionally.
34
+ *
35
+ * Opt-in, not default, because a wrong grant does not fail loudly. oam denies a
36
+ * non-granted environment variable by making it ABSENT from process.env rather
37
+ * than throwing, so an under-granted LEMONSQUEEZY_API_KEY reads as
38
+ * "unauthenticated" rather than "denied". The env list is derived from what the
39
+ * shipped bundle actually reads -- keep it in step with the bundle.
40
+ *
41
+ * MINIMUM OAM VERSION
42
+ * 0.9.0. Below it `child_process.execFile` ran its arguments through a SHELL,
43
+ * `exec` accepted `timeout` and ignored it, `spawnSync` truncated at
44
+ * `maxBuffer` while reporting success, and `stdio: 'inherit'`/`'ignore'` both
45
+ * behaved as `'pipe'`. This server shells out only when
46
+ * LEMONSQUEEZY_API_KEY_COMMAND is configured, so the execFile bug was reachable
47
+ * on exactly that path -- the command's arguments were re-split by a shell.
48
+ * An older oam is not an error: the launcher falls back to Node and says so on
49
+ * stderr. Pinning the floor here is what makes that fallback automatic.
50
+ *
51
51
  * SELECTION
52
52
  * LEMONSQUEEZY_MCP_RUNTIME=oam require oam; fail loudly if it is missing
53
53
  * LEMONSQUEEZY_MCP_RUNTIME=node never use oam
54
54
  * LEMONSQUEEZY_MCP_RUNTIME=auto prefer oam, silently fall back (default)
55
- * LEMONSQUEEZY_MCP_SANDBOX=1 run oam under --permission (oam 0.9.0+)
55
+ * LEMONSQUEEZY_MCP_SANDBOX=1 run oam under --permission (oam 0.9.0+)
56
56
  * OAM_BIN=/path/to/oam explicit binary, checked before any discovery
57
57
  */
58
58
 
@@ -60,9 +60,9 @@ import { execFileSync, spawn } from "node:child_process";
60
60
  import { existsSync } from "node:fs";
61
61
  import { constants, homedir } from "node:os";
62
62
  import { delimiter, join } from "node:path";
63
- import { fileURLToPath } from "node:url";
64
-
65
- /** Oldest oam whose `child_process` matches Node. See MINIMUM OAM VERSION above. */
63
+ import { fileURLToPath } from "node:url";
64
+
65
+ /** Oldest oam whose `child_process` matches Node. See MINIMUM OAM VERSION above. */
66
66
  const OAM_MIN = [0, 9, 0];
67
67
 
68
68
  // Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
@@ -88,7 +88,7 @@ function findOam() {
88
88
  // point deliberately at a dev build.
89
89
  //
90
90
  // Both forms are checked on Windows: the installer defaults to
91
- // %LOCALAPPDATA%oamin there, but oam's docs name ~/.oam/bin first and
91
+ // %LOCALAPPDATA%\oam\bin there, but oam's docs name ~/.oam/bin first and
92
92
  // OAM_INSTALL_DIR can pick either, so checking one silently misses a real
93
93
  // install.
94
94
  const installed = [join(homedir(), ".oam", "bin", exe)];
@@ -101,82 +101,127 @@ function findOam() {
101
101
 
102
102
  // 3. PATH, resolved manually rather than by spawning `which`/`where`, which
103
103
  // would cost a subprocess on every launch just to decide whether to spawn.
104
- const pathExt = isWin ? (process.env.PATHEXT ?? ".EXE").split(";").filter(Boolean) : [""];
104
+ // Windows: `.exe` ONLY -- deliberately narrower than PATHEXT. Node refuses to
105
+ // run a .cmd/.bat through execFile/spawn without `shell: true` (EINVAL, and
106
+ // for spawn it throws SYNCHRONOUSLY rather than emitting 'error'), so walking
107
+ // the full PATHEXT list would hand back a path this launcher cannot execute.
108
+ // Discovery has to agree with execution. A skipped shim is still reported --
109
+ // see findOamShim.
105
110
  for (const dir of (process.env.PATH ?? "").split(delimiter)) {
106
111
  if (!dir) continue;
107
- for (const ext of isWin ? pathExt : [""]) {
108
- const candidate = join(dir, isWin ? `oam${ext.toLowerCase()}` : "oam");
109
- if (existsSync(candidate)) return candidate;
112
+ const candidate = join(dir, exe);
113
+ if (existsSync(candidate)) return candidate;
114
+ }
115
+
116
+ return null;
117
+ }
118
+
119
+ /**
120
+ * `oam --version` -> [major, minor, patch], or null when it cannot be read.
121
+ * A pre-release suffix (0.9.0-rc.1) truncates to its base version.
122
+ */
123
+ function oamVersion(cmd) {
124
+ try {
125
+ const out = execFileSync(cmd, ["--version"], {
126
+ encoding: "utf-8",
127
+ stdio: ["ignore", "pipe", "ignore"],
128
+ });
129
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(out);
130
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
131
+ } catch {
132
+ // Not executable, wrong arch, or deleted since the stat. Caller degrades.
133
+ return null;
134
+ }
135
+ }
136
+
137
+ /** True when `v` is at least `min`, comparing major/minor/patch in order. */
138
+ function atLeast(v, min) {
139
+ if (!v) return false;
140
+ for (let i = 0; i < min.length; i++) {
141
+ if (v[i] > min[i]) return true;
142
+ if (v[i] < min[i]) return false;
143
+ }
144
+ return true;
145
+ }
146
+
147
+ /**
148
+ * The `--permission` grant list, or [] when the sandbox is not requested.
149
+ *
150
+ * These are oam's PROCESS-level flags: they belong before the `run` subcommand,
151
+ * not after it. `oam run --permission file.js` is rejected outright, which is a
152
+ * good failure but only because it is loud -- ordering here is load-bearing.
153
+ *
154
+ * Net grants prefix-match `host` for fetch and `host:port` for sockets.
155
+ * A denied environment variable is ABSENT from process.env rather than throwing,
156
+ * so the env list below is derived from what the bundle actually reads; trimming
157
+ * it produces silent misbehaviour, not a clear denial.
158
+ */
159
+ function sandboxFlags() {
160
+ if (process.env.LEMONSQUEEZY_MCP_SANDBOX !== "1") return [];
161
+
162
+ const hosts = ["api.lemonsqueezy.com"];
163
+ // LEMONSQUEEZY_SINK_URL is operator-configured, so its host has to be learned, not assumed.
164
+ if (process.env.LEMONSQUEEZY_SINK_URL) {
165
+ try {
166
+ const { hostname } = new URL(process.env.LEMONSQUEEZY_SINK_URL);
167
+ if (hostname && !hosts.includes(hostname)) hosts.push(hostname);
168
+ } catch {
169
+ // Malformed URL: the feature is already broken on its own terms; adding
170
+ // nothing here keeps the grant honest rather than guessing a host.
110
171
  }
111
172
  }
173
+ const netFlag = `--allow-net=${hosts.join(",")}`;
112
174
 
175
+ const env = ["LEMONSQUEEZY_ALLOWED_STORE_IDS","LEMONSQUEEZY_API_KEY","LEMONSQUEEZY_API_KEY_COMMAND","LEMONSQUEEZY_DESTRUCTIVE_RATE_LIMIT","LEMONSQUEEZY_DISABLE_CLASSES","LEMONSQUEEZY_LOG","LEMONSQUEEZY_MAX_REFUND_AMOUNT_CENTS","LEMONSQUEEZY_RATE_LIMIT_PER_CLASS","LEMONSQUEEZY_SINK_ADMIN_TOKEN","LEMONSQUEEZY_SINK_URL","PATH"];
176
+
177
+ const flags = ["--permission", netFlag, `--allow-env=${env.join(",")}`];
178
+ // Tied to the feature that needs it, not granted unconditionally.
179
+ if (process.env.LEMONSQUEEZY_API_KEY_COMMAND) flags.push("--allow-child-process");
180
+ return flags;
181
+ }
182
+
183
+ /**
184
+ * Write a diagnostic to stderr synchronously, so a following process.exit
185
+ * cannot truncate it.
186
+ *
187
+ * Not a bare writeSync: that call can short-write (it returns a byte count) and
188
+ * on macOS it can throw EAGAIN, because Node makes a piped stderr non-blocking
189
+ * there rather than blocking the write. Loop over the remaining bytes, and if
190
+ * stderr turns out to be unusable give up quietly -- failing to print a
191
+ * diagnostic is not worth crashing a stdio server over.
192
+ */
193
+ async function errSync(message) {
194
+ const { writeSync } = await import("node:fs");
195
+ const buf = Buffer.from(message);
196
+ let off = 0;
197
+ for (let attempts = 0; off < buf.length && attempts < 1000; attempts++) {
198
+ try {
199
+ off += writeSync(2, buf, off, buf.length - off);
200
+ } catch (err) {
201
+ if (err?.code !== "EAGAIN") return;
202
+ // Pipe is full and the reader has not drained yet -- retry.
203
+ }
204
+ }
205
+ }
206
+
207
+ /**
208
+ * An oam-named .cmd/.bat on PATH: a real install in a shape this launcher
209
+ * cannot spawn. Reported rather than ignored, because "no oam binary was found"
210
+ * reads as "install oam" -- the one thing that will not help. Windows only;
211
+ * there is no such shim concept on POSIX.
212
+ */
213
+ function findOamShim() {
214
+ if (!isWin) return null;
215
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
216
+ if (!dir) continue;
217
+ for (const ext of [".cmd", ".bat"]) {
218
+ const candidate = join(dir, `oam${ext}`);
219
+ if (existsSync(candidate)) return candidate;
220
+ }
221
+ }
113
222
  return null;
114
223
  }
115
224
 
116
- /**
117
- * `oam --version` -> [major, minor, patch], or null when it cannot be read.
118
- * A pre-release suffix (0.9.0-rc.1) truncates to its base version.
119
- */
120
- function oamVersion(cmd) {
121
- try {
122
- const out = execFileSync(cmd, ["--version"], {
123
- encoding: "utf-8",
124
- stdio: ["ignore", "pipe", "ignore"],
125
- });
126
- const m = /(\d+)\.(\d+)\.(\d+)/.exec(out);
127
- return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
128
- } catch {
129
- // Not executable, wrong arch, or deleted since the stat. Caller degrades.
130
- return null;
131
- }
132
- }
133
-
134
- /** True when `v` is at least `min`, comparing major/minor/patch in order. */
135
- function atLeast(v, min) {
136
- if (!v) return false;
137
- for (let i = 0; i < min.length; i++) {
138
- if (v[i] > min[i]) return true;
139
- if (v[i] < min[i]) return false;
140
- }
141
- return true;
142
- }
143
-
144
- /**
145
- * The `--permission` grant list, or [] when the sandbox is not requested.
146
- *
147
- * These are oam's PROCESS-level flags: they belong before the `run` subcommand,
148
- * not after it. `oam run --permission file.js` is rejected outright, which is a
149
- * good failure but only because it is loud -- ordering here is load-bearing.
150
- *
151
- * Net grants prefix-match `host` for fetch and `host:port` for sockets.
152
- * A denied environment variable is ABSENT from process.env rather than throwing,
153
- * so the env list below is derived from what the bundle actually reads; trimming
154
- * it produces silent misbehaviour, not a clear denial.
155
- */
156
- function sandboxFlags() {
157
- if (process.env.LEMONSQUEEZY_MCP_SANDBOX !== "1") return [];
158
-
159
- const hosts = ["api.lemonsqueezy.com"];
160
- // LEMONSQUEEZY_SINK_URL is operator-configured, so its host has to be learned, not assumed.
161
- if (process.env.LEMONSQUEEZY_SINK_URL) {
162
- try {
163
- const { hostname } = new URL(process.env.LEMONSQUEEZY_SINK_URL);
164
- if (hostname && !hosts.includes(hostname)) hosts.push(hostname);
165
- } catch {
166
- // Malformed URL: the feature is already broken on its own terms; adding
167
- // nothing here keeps the grant honest rather than guessing a host.
168
- }
169
- }
170
- const netFlag = `--allow-net=${hosts.join(",")}`;
171
-
172
- const env = ["LEMONSQUEEZY_ALLOWED_STORE_IDS","LEMONSQUEEZY_API_KEY","LEMONSQUEEZY_API_KEY_COMMAND","LEMONSQUEEZY_DESTRUCTIVE_RATE_LIMIT","LEMONSQUEEZY_DISABLE_CLASSES","LEMONSQUEEZY_LOG","LEMONSQUEEZY_MAX_REFUND_AMOUNT_CENTS","LEMONSQUEEZY_RATE_LIMIT_PER_CLASS","LEMONSQUEEZY_SINK_ADMIN_TOKEN","LEMONSQUEEZY_SINK_URL","PATH"];
173
-
174
- const flags = ["--permission", netFlag, `--allow-env=${env.join(",")}`];
175
- // Tied to the feature that needs it, not granted unconditionally.
176
- if (process.env.LEMONSQUEEZY_API_KEY_COMMAND) flags.push("--allow-child-process");
177
- return flags;
178
- }
179
-
180
225
  /** Run the server in THIS process. The zero-overhead fallback. */
181
226
  async function runInProcess() {
182
227
  // A server may gate its bootstrap on being the process ENTRY POINT --
@@ -199,8 +244,25 @@ if (mode === "node") {
199
244
  await runInProcess();
200
245
  } else {
201
246
  const oam = findOam();
247
+ // Read the version ONCE, and only when discovery found something: the gate
248
+ // below has to tell "too old" apart from "could not be read at all", and
249
+ // re-probing inside the branch would cost a second subprocess.
250
+ //
251
+ // This is the first subprocess the launcher runs -- discovery itself is
252
+ // stat-only. Paid on every launch that finds an oam, including the ones
253
+ // that go on to fall back to Node.
254
+ const found = oam ? oamVersion(oam) : null;
202
255
 
203
256
  if (!oam) {
257
+ // An oam-named .cmd/.bat on PATH is a real install in a shape this
258
+ // launcher cannot spawn. Naming it turns "no oam binary was found" --
259
+ // which reads as "install oam", the one thing that will not help --
260
+ // into something the user can act on.
261
+ const oamShim = findOamShim();
262
+ const shimNote = oamShim
263
+ ? `Found ${oamShim}, but Node cannot execute a .cmd/.bat directly.\n` +
264
+ "Install the native oam binary, or point OAM_BIN at one.\n"
265
+ : "";
204
266
  if (mode === "oam") {
205
267
  // Explicitly demanded, so this is a real misconfiguration. writeSync
206
268
  // because stderr is async for TTYs/pipes on Windows and process.exit
@@ -208,56 +270,153 @@ if (mode === "node") {
208
270
  const { writeSync } = await import("node:fs");
209
271
  writeSync(
210
272
  2,
211
- "lemonsqueezy-mcp: LEMONSQUEEZY_MCP_RUNTIME=oam but no oam binary was found.\n" +
273
+ "lemonsqueezy-mcp: LEMONSQUEEZY_MCP_RUNTIME=oam but no runnable oam binary was found.\n" + shimNote +
212
274
  "Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use LEMONSQUEEZY_MCP_RUNTIME=node.\n",
213
275
  );
214
276
  process.exit(1);
215
277
  }
278
+ // auto: falling back is correct, but silence is how someone never learns
279
+ // their oam install is a shape this launcher skips.
280
+ if (oamShim) await errSync(`lemonsqueezy-mcp: ${shimNote}Using Node instead.\n`);
281
+ await runInProcess();
282
+ } else if (!atLeast(found, OAM_MIN)) {
283
+ const min = OAM_MIN.join(".");
284
+ // Two different causes reach this branch and they need different
285
+ // remedies. `found === null` is NOT "old": oamVersion returns null when
286
+ // the binary could not be run at all (not executable, wrong arch, a
287
+ // .cmd/.bat Node refuses, deleted between the stat and the probe) or
288
+ // when its --version output did not parse. Telling that user to
289
+ // `oam self-update` sends them after the one cause it definitely is not.
290
+ const detail = found
291
+ ? `${oam} is oam ${found.join(".")}, older than ${min}`
292
+ : `${oam} could not be run, or did not report a version this launcher understands`;
293
+ const remedy = found
294
+ ? "Run `oam self-update`, or use LEMONSQUEEZY_MCP_RUNTIME=node.\n"
295
+ : "Check that it is an executable oam binary for this platform, or use LEMONSQUEEZY_MCP_RUNTIME=node.\n";
296
+ if (mode === "oam") {
297
+ await errSync(`lemonsqueezy-mcp: LEMONSQUEEZY_MCP_RUNTIME=oam but ${detail}.\n${remedy}`);
298
+ process.exit(1);
299
+ }
300
+ // auto: neither cause is worth failing over -- prefer Node. Say so,
301
+ // because a silent downgrade is how someone keeps running an oam they
302
+ // meant to update, or never learns their oam is unexecutable.
303
+ await errSync(`lemonsqueezy-mcp: ${detail}; using Node instead.\n`);
216
304
  await runInProcess();
217
305
  } else {
218
306
  // `--` separates oam's own flags from the script's argv, so `lemonsqueezy-mcp
219
307
  // --version` and any host-supplied flags survive the hop unchanged.
220
- const child = spawn(oam, [...sandboxFlags(), "run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
221
- // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
222
- // stdin/stdout is untouched and the host's stdin-close still reaches the
223
- // server's shutdown path.
224
- stdio: "inherit",
225
- env: process.env,
226
- windowsHide: true,
227
- });
228
-
229
- // If oam cannot be executed at all (deleted between the stat and the spawn,
230
- // wrong arch, permission), fall back rather than failing the whole server.
231
- // `spawned` prevents falling back AFTER the child started, which would
232
- // double-start the server on the same stdio.
233
- let spawned = false;
234
- child.on("spawn", () => {
235
- spawned = true;
236
- });
237
- child.on("error", (err) => {
238
- if (spawned) return;
308
+ // Every "oam could not be executed" outcome lands here: the synchronous
309
+ // throw from spawn() and the async 'error' event mean the same thing and
310
+ // must degrade the same way, so the handling lives in one place.
311
+ // errSync rather than process.stderr.write because stderr is async for
312
+ // TTYs and pipes on Windows and the process.exit below truncates pending
313
+ // writes.
314
+ const launchFailed = async (err) => {
239
315
  if (mode === "oam") {
240
- process.stderr.write(`lemonsqueezy-mcp: failed to launch oam (${err.message})\n`);
316
+ await errSync(`lemonsqueezy-mcp: failed to launch oam (${err?.message ?? err})\n`);
241
317
  process.exit(1);
242
318
  }
243
- void runInProcess();
244
- });
319
+ await runInProcess();
320
+ };
245
321
 
246
- // Forward termination so the server's own shutdown path runs in the child
247
- // rather than the child being orphaned. No-op on Windows, harmless to add.
248
- for (const sig of ["SIGINT", "SIGTERM"]) {
249
- process.on(sig, () => {
250
- if (!child.killed) child.kill(sig);
322
+ // ONE reporter shared by both launchFailed call sites, so the sync-throw
323
+ // path and the 'error'-event path cannot drift apart. Either can reject:
324
+ // runInProcess() is a bare import() that rejects when dist/index.js is
325
+ // missing, and at ESM top level an unhandled rejection is an uncaught
326
+ // exception -- the exact failure this handling exists to prevent.
327
+ const fallbackFailed = (e) => {
328
+ process.stderr.write(`lemonsqueezy-mcp: fallback to Node failed (${e?.message ?? e})\n`);
329
+ process.exitCode = 1;
330
+ };
331
+
332
+ let child = null;
333
+ try {
334
+ child = spawn(oam, [...sandboxFlags(), "run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
335
+ // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
336
+ // stdin/stdout is untouched and the host's stdin-close still reaches the
337
+ // server's shutdown path.
338
+ stdio: "inherit",
339
+ env: process.env,
340
+ windowsHide: true,
251
341
  });
342
+ } catch (err) {
343
+ // spawn() THROWS for some failures instead of emitting 'error', and the
344
+ // 'error' listener is registered AFTER this call, so it can never observe
345
+ // one -- an uncaught throw here kills the launcher with a raw stack trace
346
+ // instead of falling back to Node.
347
+ await launchFailed(err).catch(fallbackFailed);
252
348
  }
253
349
 
254
- child.on("exit", (code, signal) => {
255
- // Mirror the child's fate: a signal death becomes 128+n so callers see a
256
- // conventional shell exit status rather than a bare 0.
257
- if (signal) {
258
- process.exit(128 + (constants.signals[signal] ?? 15));
350
+ if (child) {
351
+
352
+ // If oam cannot be executed at all (deleted between the stat and the spawn,
353
+ // wrong arch, permission), fall back rather than failing the whole server.
354
+ // `spawned` prevents falling back AFTER the child started, which would
355
+ // double-start the server on the same stdio.
356
+ let spawned = false;
357
+ child.on("spawn", () => {
358
+ spawned = true;
359
+ });
360
+ child.on("error", (err) => {
361
+ if (spawned) return;
362
+ // Handle the rejection instead of discarding it: a failing in-process
363
+ // fallback would otherwise escape as an unhandled rejection, replacing
364
+ // this launcher's diagnostic with a raw stack trace.
365
+ launchFailed(err).catch(fallbackFailed);
366
+ });
367
+
368
+ // Forward termination so the server's own shutdown path runs in the child
369
+ // rather than the child being orphaned.
370
+ //
371
+ // Registering ANY handler for these suppresses Node's default
372
+ // terminate-on-signal, so the parent's exit has to be arranged explicitly.
373
+ // `child.killed` only records that kill() was CALLED, never that the child
374
+ // is gone, so gating on it swallows every signal after the first and wedges
375
+ // the launcher with no escape hatch.
376
+ //
377
+ // Escalation is driven by a TIMER, not by counting signals. Counting is
378
+ // ambiguous: a supervisor routinely sends SIGINT then SIGTERM milliseconds
379
+ // apart, and a terminal Ctrl-C reaches the whole process group, so reading
380
+ // "a second signal" as impatience hard-kills a child that is already
381
+ // shutting down cleanly. A timer makes the count irrelevant -- ONE press is
382
+ // enough, and a wedged child dies on schedule. setTimeout is monotonic, so
383
+ // a wall-clock step cannot mis-gate the window either.
384
+ //
385
+ // POSIX vs Windows, and why we do NOT forward on Windows.
386
+ // On POSIX child.kill(sig) delivers a real, catchable signal, so forwarding
387
+ // is what lets the child run its shutdown. On Windows there are no POSIX
388
+ // signals: child.kill IGNORES the name and calls TerminateProcess -- an
389
+ // immediate hard kill (verified: a child with a SIGTERM handler never runs
390
+ // it and dies with code=null, signal=SIGTERM). Forwarding there ABORTS the
391
+ // graceful shutdown the console's own Ctrl-C just started, skipping the
392
+ // child's process.on("exit") cleanup. The console has already notified the
393
+ // child, so on Windows the timer below is the only kill we issue.
394
+ const ESCALATE_AFTER_MS = 2000;
395
+ let escalation = null;
396
+ for (const sig of ["SIGINT", "SIGTERM"]) {
397
+ process.on(sig, () => {
398
+ // No try/catch: kill() on an already-exited child returns false, it does
399
+ // not throw. It throws only for a signal the platform does not know,
400
+ // which SIGINT/SIGTERM/SIGKILL never are.
401
+ if (!isWin) child.kill(sig);
402
+ if (escalation) return; // already counting down; further signals are noise
403
+ escalation = setTimeout(() => {
404
+ // Still here after its grace window. Stop waiting on it.
405
+ child.kill("SIGKILL");
406
+ process.exit(128 + (constants.signals[sig] ?? 15));
407
+ }, ESCALATE_AFTER_MS);
408
+ });
259
409
  }
260
- process.exit(code ?? 0);
261
- });
410
+
411
+ child.on("exit", (code, signal) => {
412
+ if (escalation) clearTimeout(escalation);
413
+ // Mirror the child's fate: a signal death becomes 128+n so callers see a
414
+ // conventional shell exit status rather than a bare 0.
415
+ if (signal) {
416
+ process.exit(128 + (constants.signals[signal] ?? 15));
417
+ }
418
+ process.exit(code ?? 0);
419
+ });
420
+ }
262
421
  }
263
422
  }
package/dist/index.js CHANGED
@@ -23793,7 +23793,7 @@ function readAuditLogResource(uri) {
23793
23793
  }
23794
23794
 
23795
23795
  // src/index.ts
23796
- var version2 = true ? "0.13.0" : createRequire(import.meta.url)("../package.json").version;
23796
+ var version2 = true ? "0.13.1" : createRequire(import.meta.url)("../package.json").version;
23797
23797
  var subcommand = process.argv[2];
23798
23798
  if (subcommand === "version" || subcommand === "--version") {
23799
23799
  console.log(version2);
package/package.json CHANGED
@@ -1,61 +1,61 @@
1
- {
2
- "name": "@yawlabs/lemonsqueezy-mcp",
3
- "version": "0.13.0",
4
- "mcpName": "io.github.YawLabs/lemonsqueezy-mcp",
5
- "description": "LemonSqueezy MCP server for managing your store from AI assistants",
6
- "license": "MIT",
7
- "author": "YawLabs <contact@yaw.sh>",
8
- "repository": {
9
- "type": "git",
10
- "url": "git+https://github.com/YawLabs/lemonsqueezy-mcp.git"
11
- },
12
- "keywords": [
13
- "lemonsqueezy",
14
- "lemon-squeezy",
15
- "mcp",
16
- "model-context-protocol",
17
- "ai",
18
- "payments",
19
- "subscriptions",
20
- "licensing"
21
- ],
22
- "type": "module",
23
- "main": "dist/index.js",
24
- "bin": {
25
- "lemonsqueezy-mcp": "bin/lemonsqueezy-mcp.mjs"
26
- },
27
- "files": [
28
- "bin/lemonsqueezy-mcp.mjs",
29
- "dist/index.js",
30
- "CHANGELOG.md"
31
- ],
32
- "scripts": {
33
- "build": "tsc && node build.mjs",
34
- "dev": "tsc --watch",
35
- "start": "node dist/index.js",
36
- "test": "npm run build && node --test \"dist/**/*.test.js\"",
37
- "test:integration": "npm run build && node --test dist/integration/*.test.js",
38
- "lint": "biome check src/",
39
- "lint:fix": "biome check --write src/",
40
- "check:oam": "oam check",
41
- "build:binary:oam": "node scripts/build-binary.mjs --oam",
42
- "gen:containerfile": "node scripts/sync-containerfile.mjs",
43
- "check:containerfile": "node scripts/sync-containerfile.mjs --check",
44
- "prepublishOnly": "npm run build"
45
- },
46
- "overrides": {
47
- "hono": "^4.12.14"
48
- },
49
- "devDependencies": {
50
- "@biomejs/biome": "^2.4.12",
51
- "@modelcontextprotocol/sdk": "^1.29.0",
52
- "@types/node": "^26.1.1",
53
- "esbuild": "^0.28.1",
54
- "postject": "^1.0.0-alpha.6",
55
- "typescript": "^7.0.2",
56
- "zod": "^4.3.6"
57
- },
58
- "engines": {
59
- "node": ">=22"
60
- }
61
- }
1
+ {
2
+ "name": "@yawlabs/lemonsqueezy-mcp",
3
+ "version": "0.13.1",
4
+ "mcpName": "io.github.YawLabs/lemonsqueezy-mcp",
5
+ "description": "LemonSqueezy MCP server for managing your store from AI assistants",
6
+ "license": "MIT",
7
+ "author": "YawLabs <contact@yaw.sh>",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/YawLabs/lemonsqueezy-mcp.git"
11
+ },
12
+ "keywords": [
13
+ "lemonsqueezy",
14
+ "lemon-squeezy",
15
+ "mcp",
16
+ "model-context-protocol",
17
+ "ai",
18
+ "payments",
19
+ "subscriptions",
20
+ "licensing"
21
+ ],
22
+ "type": "module",
23
+ "main": "dist/index.js",
24
+ "bin": {
25
+ "lemonsqueezy-mcp": "bin/lemonsqueezy-mcp.mjs"
26
+ },
27
+ "files": [
28
+ "bin/lemonsqueezy-mcp.mjs",
29
+ "dist/index.js",
30
+ "CHANGELOG.md"
31
+ ],
32
+ "scripts": {
33
+ "build": "tsc && node build.mjs",
34
+ "dev": "tsc --watch",
35
+ "start": "node dist/index.js",
36
+ "test": "npm run build && node --test-timeout=300000 --test \"dist/**/*.test.js\"",
37
+ "test:integration": "npm run build && node --test-timeout=300000 --test dist/integration/*.test.js",
38
+ "lint": "biome check src/",
39
+ "lint:fix": "biome check --write src/",
40
+ "check:oam": "oam check",
41
+ "build:binary:oam": "node scripts/build-binary.mjs --oam",
42
+ "gen:containerfile": "node scripts/sync-containerfile.mjs",
43
+ "check:containerfile": "node scripts/sync-containerfile.mjs --check",
44
+ "prepublishOnly": "npm run build"
45
+ },
46
+ "overrides": {
47
+ "hono": "^4.12.14"
48
+ },
49
+ "devDependencies": {
50
+ "@biomejs/biome": "^2.4.12",
51
+ "@modelcontextprotocol/sdk": "^1.29.0",
52
+ "@types/node": "^26.1.1",
53
+ "esbuild": "^0.28.1",
54
+ "postject": "^1.0.0-alpha.6",
55
+ "typescript": "^7.0.2",
56
+ "zod": "^4.3.6"
57
+ },
58
+ "engines": {
59
+ "node": ">=22"
60
+ }
61
+ }