@yawlabs/lemonsqueezy-mcp 0.13.2 → 0.13.4
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 +42 -2
- package/LICENSE +21 -21
- package/README.md +398 -396
- package/bin/lemonsqueezy-mcp.mjs +500 -435
- package/dist/index.js +11847 -1869
- package/package.json +72 -61
package/bin/lemonsqueezy-mcp.mjs
CHANGED
|
@@ -1,435 +1,500 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Runtime launcher for @yawlabs/lemonsqueezy-mcp.
|
|
4
|
-
*
|
|
5
|
-
* Prefers the oam runtime (https://oamjs.org) and falls back to the Node
|
|
6
|
-
* process already running this file.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* WHY THE FALLBACK COSTS NOTHING
|
|
10
|
-
* npm has already started Node to run this launcher, so falling back is a
|
|
11
|
-
* plain `import()` of the server into THIS process: no extra spawn, no extra
|
|
12
|
-
* startup, byte-identical to invoking dist/index.js directly. Discovery is
|
|
13
|
-
* stat-only -- never a subprocess -- so the miss case stays sub-millisecond.
|
|
14
|
-
*
|
|
15
|
-
* WHAT THE OAM PATH COSTS
|
|
16
|
-
* Reaching oam through an npm `bin` means Node boots first and oam boots
|
|
17
|
-
* second, so the launcher is slower than either runtime alone. Measured on
|
|
18
|
-
* npmjs-mcp (windows-arm64, n=12 medians, spawn to first MCP initialize):
|
|
19
|
-
* oam 116ms, node 172ms, launcher 243ms. oam is the fastest runtime and the
|
|
20
|
-
* launcher is the slowest path -- it exists for `npx` convenience.
|
|
21
|
-
*
|
|
22
|
-
* For an MCP host config, point straight at oam and skip this file:
|
|
23
|
-
* { "command": "oam", "args": ["run", "<abs>/dist/index.js"] }
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Runtime launcher for @yawlabs/lemonsqueezy-mcp.
|
|
4
|
+
*
|
|
5
|
+
* Prefers the oam runtime (https://oamjs.org) and falls back to the Node
|
|
6
|
+
* process already running this file.
|
|
7
|
+
*
|
|
8
|
+
*
|
|
9
|
+
* WHY THE FALLBACK COSTS NOTHING
|
|
10
|
+
* npm has already started Node to run this launcher, so falling back is a
|
|
11
|
+
* plain `import()` of the server into THIS process: no extra spawn, no extra
|
|
12
|
+
* startup, byte-identical to invoking dist/index.js directly. Discovery is
|
|
13
|
+
* stat-only -- never a subprocess -- so the miss case stays sub-millisecond.
|
|
14
|
+
*
|
|
15
|
+
* WHAT THE OAM PATH COSTS
|
|
16
|
+
* Reaching oam through an npm `bin` means Node boots first and oam boots
|
|
17
|
+
* second, so the launcher is slower than either runtime alone. Measured on
|
|
18
|
+
* npmjs-mcp (windows-arm64, n=12 medians, spawn to first MCP initialize):
|
|
19
|
+
* oam 116ms, node 172ms, launcher 243ms. oam is the fastest runtime and the
|
|
20
|
+
* launcher is the slowest path -- it exists for `npx` convenience.
|
|
21
|
+
*
|
|
22
|
+
* For an MCP host config, point straight at oam and skip this file:
|
|
23
|
+
* { "command": "oam", "args": ["run", "<abs>/dist/index.js"] }
|
|
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 server 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
|
+
* Two cases still take the discovery path instead, deliberately.
|
|
37
|
+
* LEMONSQUEEZY_MCP_SANDBOX=1, because `--permission` is a process-level flag
|
|
38
|
+
* that only a FRESH oam can apply -- skipping straight to in-process there
|
|
39
|
+
* would drop the sandbox without a word, a security downgrade dressed up as an
|
|
40
|
+
* optimisation. And a host oam below the floor, which takes the discovery path
|
|
41
|
+
* exactly as it always did.
|
|
42
|
+
*
|
|
43
|
+
* The discovery path PREFERS a fresh oam; it does not guarantee one. If it
|
|
44
|
+
* fails -- no binary found, one below the floor or unreadable, or a spawn that
|
|
45
|
+
* errors -- the fallback below still serves in-process WITHOUT `--permission`
|
|
46
|
+
* under LEMONSQUEEZY_MCP_RUNTIME=auto, with a stderr note only when a binary
|
|
47
|
+
* was found too old or unreadable, or a .cmd/.bat shim was skipped. That is how
|
|
48
|
+
* a requested sandbox behaved before this path existed, and only
|
|
49
|
+
* LEMONSQUEEZY_MCP_RUNTIME=oam turns it into a failure.
|
|
50
|
+
*
|
|
51
|
+
* THE `--permission` SANDBOX (oam 0.9.0+, opt-in)
|
|
52
|
+
* `LEMONSQUEEZY_MCP_SANDBOX=1` runs the server under oam's permission model:
|
|
53
|
+
* network limited to api.lemonsqueezy.com, filesystem denied outright.
|
|
54
|
+
*
|
|
55
|
+
* LEMONSQUEEZY_SINK_URL is operator-configured, so its host is parsed out and
|
|
56
|
+
* added to the grant when set rather than assumed. Child-process stays denied
|
|
57
|
+
* UNLESS LEMONSQUEEZY_API_KEY_COMMAND is configured -- that feature shells out
|
|
58
|
+
* to fetch the key, so the grant is tied to the feature instead of handed over
|
|
59
|
+
* unconditionally.
|
|
60
|
+
*
|
|
61
|
+
* Opt-in, not default, because a wrong grant does not fail loudly. oam denies a
|
|
62
|
+
* non-granted environment variable by making it ABSENT from process.env rather
|
|
63
|
+
* than throwing, so an under-granted LEMONSQUEEZY_API_KEY reads as
|
|
64
|
+
* "unauthenticated" rather than "denied". The env list is derived from what the
|
|
65
|
+
* shipped bundle actually reads -- keep it in step with the bundle.
|
|
66
|
+
*
|
|
67
|
+
* MINIMUM OAM VERSION
|
|
68
|
+
* 0.9.0. Below it `child_process.execFile` ran its arguments through a SHELL,
|
|
69
|
+
* `exec` accepted `timeout` and ignored it, `spawnSync` truncated at
|
|
70
|
+
* `maxBuffer` while reporting success, and `stdio: 'inherit'`/`'ignore'` both
|
|
71
|
+
* behaved as `'pipe'`. This server shells out only when
|
|
72
|
+
* LEMONSQUEEZY_API_KEY_COMMAND is configured, so the execFile bug was reachable
|
|
73
|
+
* on exactly that path -- the command's arguments were re-split by a shell.
|
|
74
|
+
* An older oam is not an error: the launcher falls back to Node and says so on
|
|
75
|
+
* stderr. Pinning the floor here is what makes that fallback automatic.
|
|
76
|
+
*
|
|
77
|
+
* SELECTION
|
|
78
|
+
* LEMONSQUEEZY_MCP_RUNTIME=oam require oam; fail loudly if it is missing
|
|
79
|
+
* (already running on oam satisfies it)
|
|
80
|
+
* LEMONSQUEEZY_MCP_RUNTIME=node never use oam
|
|
81
|
+
* LEMONSQUEEZY_MCP_RUNTIME=auto prefer oam, silently fall back (default)
|
|
82
|
+
* LEMONSQUEEZY_MCP_SANDBOX=1 run oam under --permission (oam 0.9.0+)
|
|
83
|
+
* OAM_BIN=/path/to/oam explicit binary, checked before any discovery
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
87
|
+
import { existsSync } from "node:fs";
|
|
88
|
+
import { constants, homedir } from "node:os";
|
|
89
|
+
import { delimiter, join } from "node:path";
|
|
90
|
+
import { fileURLToPath } from "node:url";
|
|
91
|
+
|
|
92
|
+
/** Oldest oam whose `child_process` matches Node. See MINIMUM OAM VERSION above. */
|
|
93
|
+
const OAM_MIN = [0, 9, 0];
|
|
94
|
+
|
|
95
|
+
// Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
|
|
96
|
+
// with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
|
|
97
|
+
// in-process fallback must use the file:// URL. spawn() needs a real path.
|
|
98
|
+
const SERVER_URL = new URL("../dist/index.js", import.meta.url);
|
|
99
|
+
const SERVER_ENTRY = fileURLToPath(SERVER_URL);
|
|
100
|
+
const isWin = process.platform === "win32";
|
|
101
|
+
const exe = isWin ? "oam.exe" : "oam";
|
|
102
|
+
|
|
103
|
+
/** Locate an oam binary, or null. Every branch is a stat, never a subprocess. */
|
|
104
|
+
function findOam() {
|
|
105
|
+
// 1. Explicit override wins and is never second-guessed.
|
|
106
|
+
const override = process.env.OAM_BIN;
|
|
107
|
+
if (override) return existsSync(override) ? override : null;
|
|
108
|
+
|
|
109
|
+
// 2. Installed locations, BEFORE PATH. Someone who develops oam itself
|
|
110
|
+
// usually has oam/target/release on PATH, and a build directory is the
|
|
111
|
+
// wrong thing for a user-facing launcher to bind to: cargo replaces the
|
|
112
|
+
// binary underneath running processes, and the dev build is not the
|
|
113
|
+
// release the user installed. Preferring the installed copy makes the
|
|
114
|
+
// default path "what a normal user has", and OAM_BIN remains the way to
|
|
115
|
+
// point deliberately at a dev build.
|
|
116
|
+
//
|
|
117
|
+
// Both forms are checked on Windows: the installer defaults to
|
|
118
|
+
// %LOCALAPPDATA%\oam\bin there, but oam's docs name ~/.oam/bin first and
|
|
119
|
+
// OAM_INSTALL_DIR can pick either, so checking one silently misses a real
|
|
120
|
+
// install.
|
|
121
|
+
const installed = [join(homedir(), ".oam", "bin", exe)];
|
|
122
|
+
if (isWin) {
|
|
123
|
+
installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
|
|
124
|
+
}
|
|
125
|
+
for (const candidate of installed) {
|
|
126
|
+
if (existsSync(candidate)) return candidate;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 3. PATH, resolved manually rather than by spawning `which`/`where`, which
|
|
130
|
+
// would cost a subprocess on every launch just to decide whether to spawn.
|
|
131
|
+
// Windows: `.exe` ONLY -- deliberately narrower than PATHEXT. Node refuses to
|
|
132
|
+
// run a .cmd/.bat through execFile/spawn without `shell: true` (EINVAL, and
|
|
133
|
+
// for spawn it throws SYNCHRONOUSLY rather than emitting 'error'), so walking
|
|
134
|
+
// the full PATHEXT list would hand back a path this launcher cannot execute.
|
|
135
|
+
// Discovery has to agree with execution. A skipped shim is still reported --
|
|
136
|
+
// see findOamShim.
|
|
137
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
138
|
+
if (!dir) continue;
|
|
139
|
+
const candidate = join(dir, exe);
|
|
140
|
+
if (existsSync(candidate)) return candidate;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Version text -> [major, minor, patch], or null when it holds no version.
|
|
148
|
+
* A pre-release suffix (0.9.0-rc.1) truncates to its base version.
|
|
149
|
+
*
|
|
150
|
+
* Shared by the two places a version is read -- a discovered binary's
|
|
151
|
+
* `oam --version` output ("oam 0.15.1") and the host's own
|
|
152
|
+
* `process.versions.oam` ("0.15.1") -- so they cannot disagree about what a
|
|
153
|
+
* version string means, or which floor it has to clear.
|
|
154
|
+
*/
|
|
155
|
+
function parseVersion(text) {
|
|
156
|
+
const m = /(\d+)\.(\d+)\.(\d+)/.exec(text);
|
|
157
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** `oam --version` -> [major, minor, patch], or null when it cannot be read. */
|
|
161
|
+
function oamVersion(cmd) {
|
|
162
|
+
try {
|
|
163
|
+
const out = execFileSync(cmd, ["--version"], {
|
|
164
|
+
encoding: "utf-8",
|
|
165
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
166
|
+
});
|
|
167
|
+
return parseVersion(out);
|
|
168
|
+
} catch {
|
|
169
|
+
// Not executable, wrong arch, or deleted since the stat. Caller degrades.
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** True when `v` is at least `min`, comparing major/minor/patch in order. */
|
|
175
|
+
function atLeast(v, min) {
|
|
176
|
+
if (!v) return false;
|
|
177
|
+
for (let i = 0; i < min.length; i++) {
|
|
178
|
+
if (v[i] > min[i]) return true;
|
|
179
|
+
if (v[i] < min[i]) return false;
|
|
180
|
+
}
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Where the server runs, decided BEFORE any discovery:
|
|
186
|
+
* "in-process" import it into THIS process
|
|
187
|
+
* "discover" find an oam binary, gate its version, spawn it -- or fall
|
|
188
|
+
* back to Node in-process when that fails
|
|
189
|
+
*
|
|
190
|
+
* `hostOam` is `process.versions.oam`: oam's own key, absent on Node, so on
|
|
191
|
+
* Node every mode but `node` is the discovery path it always was. `sandbox`
|
|
192
|
+
* is whether a spawn would carry flags only a fresh oam can apply; see ALREADY
|
|
193
|
+
* RUNNING ON OAM above for why that alone forces the discovery path, and why
|
|
194
|
+
* that path can still end in-process without them. The floor is
|
|
195
|
+
* OAM_MIN itself, not a parameter, so a host oam and a discovered one can never
|
|
196
|
+
* be held to different minimums.
|
|
197
|
+
*
|
|
198
|
+
* Pure on purpose: every input is passed in, so the whole decision is testable
|
|
199
|
+
* without booting a runtime.
|
|
200
|
+
*/
|
|
201
|
+
function runtimePlan({ mode, hostOam, sandbox }) {
|
|
202
|
+
if (mode === "node") return "in-process";
|
|
203
|
+
if (sandbox) return "discover";
|
|
204
|
+
return atLeast(parseVersion(hostOam ?? ""), OAM_MIN) ? "in-process" : "discover";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The `--permission` grant list, or [] when the sandbox is not requested.
|
|
209
|
+
*
|
|
210
|
+
* These are oam's PROCESS-level flags: they belong before the `run` subcommand,
|
|
211
|
+
* not after it. `oam run --permission file.js` is rejected outright, which is a
|
|
212
|
+
* good failure but only because it is loud -- ordering here is load-bearing.
|
|
213
|
+
*
|
|
214
|
+
* Net grants prefix-match `host` for fetch and `host:port` for sockets.
|
|
215
|
+
* A denied environment variable is ABSENT from process.env rather than throwing,
|
|
216
|
+
* so the env list below is derived from what the bundle actually reads; trimming
|
|
217
|
+
* it produces silent misbehaviour, not a clear denial.
|
|
218
|
+
*/
|
|
219
|
+
function sandboxFlags() {
|
|
220
|
+
if (process.env.LEMONSQUEEZY_MCP_SANDBOX !== "1") return [];
|
|
221
|
+
|
|
222
|
+
const hosts = ["api.lemonsqueezy.com"];
|
|
223
|
+
// LEMONSQUEEZY_SINK_URL is operator-configured, so its host has to be learned, not assumed.
|
|
224
|
+
if (process.env.LEMONSQUEEZY_SINK_URL) {
|
|
225
|
+
try {
|
|
226
|
+
const { hostname } = new URL(process.env.LEMONSQUEEZY_SINK_URL);
|
|
227
|
+
if (hostname && !hosts.includes(hostname)) hosts.push(hostname);
|
|
228
|
+
} catch {
|
|
229
|
+
// Malformed URL: the feature is already broken on its own terms; adding
|
|
230
|
+
// nothing here keeps the grant honest rather than guessing a host.
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const netFlag = `--allow-net=${hosts.join(",")}`;
|
|
234
|
+
|
|
235
|
+
const env = [
|
|
236
|
+
"LEMONSQUEEZY_ALLOWED_STORE_IDS",
|
|
237
|
+
"LEMONSQUEEZY_API_KEY",
|
|
238
|
+
"LEMONSQUEEZY_API_KEY_COMMAND",
|
|
239
|
+
"LEMONSQUEEZY_DESTRUCTIVE_RATE_LIMIT",
|
|
240
|
+
"LEMONSQUEEZY_DISABLE_CLASSES",
|
|
241
|
+
"LEMONSQUEEZY_LOG",
|
|
242
|
+
"LEMONSQUEEZY_MAX_REFUND_AMOUNT_CENTS",
|
|
243
|
+
"LEMONSQUEEZY_RATE_LIMIT_PER_CLASS",
|
|
244
|
+
"LEMONSQUEEZY_SINK_ADMIN_TOKEN",
|
|
245
|
+
"LEMONSQUEEZY_SINK_URL",
|
|
246
|
+
"LEMONSQUEEZY_TEST_API_KEY",
|
|
247
|
+
"PATH",
|
|
248
|
+
];
|
|
249
|
+
|
|
250
|
+
const flags = ["--permission", netFlag, `--allow-env=${env.join(",")}`];
|
|
251
|
+
// Tied to the feature that needs it, not granted unconditionally.
|
|
252
|
+
if (process.env.LEMONSQUEEZY_API_KEY_COMMAND) flags.push("--allow-child-process");
|
|
253
|
+
return flags;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Write a diagnostic to stderr synchronously, so a following process.exit
|
|
258
|
+
* cannot truncate it.
|
|
259
|
+
*
|
|
260
|
+
* Not a bare writeSync: that call can short-write (it returns a byte count) and
|
|
261
|
+
* on macOS it can throw EAGAIN, because Node makes a piped stderr non-blocking
|
|
262
|
+
* there rather than blocking the write. Loop over the remaining bytes, and if
|
|
263
|
+
* stderr turns out to be unusable give up quietly -- failing to print a
|
|
264
|
+
* diagnostic is not worth crashing a stdio server over.
|
|
265
|
+
*/
|
|
266
|
+
async function errSync(message) {
|
|
267
|
+
const { writeSync } = await import("node:fs");
|
|
268
|
+
const buf = Buffer.from(message);
|
|
269
|
+
let off = 0;
|
|
270
|
+
for (let attempts = 0; off < buf.length && attempts < 1000; attempts++) {
|
|
271
|
+
try {
|
|
272
|
+
off += writeSync(2, buf, off, buf.length - off);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
if (err?.code !== "EAGAIN") return;
|
|
275
|
+
// Pipe is full and the reader has not drained yet -- retry.
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* An oam-named .cmd/.bat on PATH: a real install in a shape this launcher
|
|
282
|
+
* cannot spawn. Reported rather than ignored, because "no oam binary was found"
|
|
283
|
+
* reads as "install oam" -- the one thing that will not help. Windows only;
|
|
284
|
+
* there is no such shim concept on POSIX.
|
|
285
|
+
*/
|
|
286
|
+
function findOamShim() {
|
|
287
|
+
if (!isWin) return null;
|
|
288
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
289
|
+
if (!dir) continue;
|
|
290
|
+
for (const ext of [".cmd", ".bat"]) {
|
|
291
|
+
const candidate = join(dir, `oam${ext}`);
|
|
292
|
+
if (existsSync(candidate)) return candidate;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Run the server in THIS process. The zero-overhead fallback. */
|
|
299
|
+
async function runInProcess() {
|
|
300
|
+
// A server may gate its bootstrap on being the process ENTRY POINT --
|
|
301
|
+
// `import.meta.url === pathToFileURL(process.argv[1]).href` -- so that its own
|
|
302
|
+
// test file can import the module for unit tests without connecting a stdio
|
|
303
|
+
// transport. aws-mcp does exactly this. Importing the server here would leave
|
|
304
|
+
// argv[1] pointing at THIS launcher, the guard would read false, and the
|
|
305
|
+
// server would load but never serve: the MCP handshake just hangs.
|
|
306
|
+
//
|
|
307
|
+
// Point argv[1] at the server first, so the in-process path is
|
|
308
|
+
// indistinguishable from having executed the file directly. The spawn path
|
|
309
|
+
// needs no equivalent -- there argv[1] is already the server.
|
|
310
|
+
process.argv[1] = SERVER_ENTRY;
|
|
311
|
+
await import(SERVER_URL.href);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const mode = (process.env.LEMONSQUEEZY_MCP_RUNTIME ?? "auto").toLowerCase();
|
|
315
|
+
|
|
316
|
+
// The sandbox is read off the grant list rather than LEMONSQUEEZY_MCP_SANDBOX,
|
|
317
|
+
// so "would the spawn carry --permission" cannot drift from what the spawn
|
|
318
|
+
// below actually passes.
|
|
319
|
+
const plan = runtimePlan({ mode, hostOam: process.versions.oam, sandbox: sandboxFlags().length > 0 });
|
|
320
|
+
|
|
321
|
+
if (plan === "in-process") {
|
|
322
|
+
await runInProcess();
|
|
323
|
+
} else {
|
|
324
|
+
const oam = findOam();
|
|
325
|
+
// Read the version ONCE, and only when discovery found something: the gate
|
|
326
|
+
// below has to tell "too old" apart from "could not be read at all", and
|
|
327
|
+
// re-probing inside the branch would cost a second subprocess.
|
|
328
|
+
//
|
|
329
|
+
// This is the first subprocess the launcher runs -- discovery itself is
|
|
330
|
+
// stat-only. Paid on every launch that finds an oam, including the ones
|
|
331
|
+
// that go on to fall back to Node.
|
|
332
|
+
const found = oam ? oamVersion(oam) : null;
|
|
333
|
+
|
|
334
|
+
if (!oam) {
|
|
335
|
+
// An oam-named .cmd/.bat on PATH is a real install in a shape this
|
|
336
|
+
// launcher cannot spawn. Naming it turns "no oam binary was found" --
|
|
337
|
+
// which reads as "install oam", the one thing that will not help --
|
|
338
|
+
// into something the user can act on.
|
|
339
|
+
const oamShim = findOamShim();
|
|
340
|
+
const shimNote = oamShim
|
|
341
|
+
? `Found ${oamShim}, but Node cannot execute a .cmd/.bat directly.\n` +
|
|
342
|
+
"Install the native oam binary, or point OAM_BIN at one.\n"
|
|
343
|
+
: "";
|
|
344
|
+
if (mode === "oam") {
|
|
345
|
+
// Explicitly demanded, so this is a real misconfiguration. writeSync
|
|
346
|
+
// because stderr is async for TTYs/pipes on Windows and process.exit
|
|
347
|
+
// truncates pending writes.
|
|
348
|
+
const { writeSync } = await import("node:fs");
|
|
349
|
+
writeSync(
|
|
350
|
+
2,
|
|
351
|
+
"lemonsqueezy-mcp: LEMONSQUEEZY_MCP_RUNTIME=oam but no runnable oam binary was found.\n" +
|
|
352
|
+
shimNote +
|
|
353
|
+
"Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use LEMONSQUEEZY_MCP_RUNTIME=node.\n",
|
|
354
|
+
);
|
|
355
|
+
process.exit(1);
|
|
356
|
+
}
|
|
357
|
+
// auto: falling back is correct, but silence is how someone never learns
|
|
358
|
+
// their oam install is a shape this launcher skips.
|
|
359
|
+
if (oamShim) await errSync(`lemonsqueezy-mcp: ${shimNote}Using Node instead.\n`);
|
|
360
|
+
await runInProcess();
|
|
361
|
+
} else if (!atLeast(found, OAM_MIN)) {
|
|
362
|
+
const min = OAM_MIN.join(".");
|
|
363
|
+
// Two different causes reach this branch and they need different
|
|
364
|
+
// remedies. `found === null` is NOT "old": oamVersion returns null when
|
|
365
|
+
// the binary could not be run at all (not executable, wrong arch, a
|
|
366
|
+
// .cmd/.bat Node refuses, deleted between the stat and the probe) or
|
|
367
|
+
// when its --version output did not parse. Telling that user to
|
|
368
|
+
// `oam self-update` sends them after the one cause it definitely is not.
|
|
369
|
+
const detail = found
|
|
370
|
+
? `${oam} is oam ${found.join(".")}, older than ${min}`
|
|
371
|
+
: `${oam} could not be run, or did not report a version this launcher understands`;
|
|
372
|
+
const remedy = found
|
|
373
|
+
? "Run `oam self-update`, or use LEMONSQUEEZY_MCP_RUNTIME=node.\n"
|
|
374
|
+
: "Check that it is an executable oam binary for this platform, or use LEMONSQUEEZY_MCP_RUNTIME=node.\n";
|
|
375
|
+
if (mode === "oam") {
|
|
376
|
+
await errSync(`lemonsqueezy-mcp: LEMONSQUEEZY_MCP_RUNTIME=oam but ${detail}.\n${remedy}`);
|
|
377
|
+
process.exit(1);
|
|
378
|
+
}
|
|
379
|
+
// auto: neither cause is worth failing over -- prefer Node. Say so,
|
|
380
|
+
// because a silent downgrade is how someone keeps running an oam they
|
|
381
|
+
// meant to update, or never learns their oam is unexecutable.
|
|
382
|
+
await errSync(`lemonsqueezy-mcp: ${detail}; using Node instead.\n`);
|
|
383
|
+
await runInProcess();
|
|
384
|
+
} else {
|
|
385
|
+
// `--` separates oam's own flags from the script's argv, so `lemonsqueezy-mcp
|
|
386
|
+
// --version` and any host-supplied flags survive the hop unchanged.
|
|
387
|
+
// Every "oam could not be executed" outcome lands here: the synchronous
|
|
388
|
+
// throw from spawn() and the async 'error' event mean the same thing and
|
|
389
|
+
// must degrade the same way, so the handling lives in one place.
|
|
390
|
+
// errSync rather than process.stderr.write because stderr is async for
|
|
391
|
+
// TTYs and pipes on Windows and the process.exit below truncates pending
|
|
392
|
+
// writes.
|
|
393
|
+
const launchFailed = async (err) => {
|
|
394
|
+
if (mode === "oam") {
|
|
395
|
+
await errSync(`lemonsqueezy-mcp: failed to launch oam (${err?.message ?? err})\n`);
|
|
396
|
+
process.exit(1);
|
|
397
|
+
}
|
|
398
|
+
await runInProcess();
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
// ONE reporter shared by both launchFailed call sites, so the sync-throw
|
|
402
|
+
// path and the 'error'-event path cannot drift apart. Either can reject:
|
|
403
|
+
// runInProcess() is a bare import() that rejects when dist/index.js is
|
|
404
|
+
// missing, and at ESM top level an unhandled rejection is an uncaught
|
|
405
|
+
// exception -- the exact failure this handling exists to prevent.
|
|
406
|
+
const fallbackFailed = (e) => {
|
|
407
|
+
process.stderr.write(`lemonsqueezy-mcp: fallback to Node failed (${e?.message ?? e})\n`);
|
|
408
|
+
process.exitCode = 1;
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
let child = null;
|
|
412
|
+
try {
|
|
413
|
+
child = spawn(oam, [...sandboxFlags(), "run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
|
|
414
|
+
// inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
|
|
415
|
+
// stdin/stdout is untouched and the host's stdin-close still reaches the
|
|
416
|
+
// server's shutdown path.
|
|
417
|
+
stdio: "inherit",
|
|
418
|
+
env: process.env,
|
|
419
|
+
windowsHide: true,
|
|
420
|
+
});
|
|
421
|
+
} catch (err) {
|
|
422
|
+
// spawn() THROWS for some failures instead of emitting 'error', and the
|
|
423
|
+
// 'error' listener is registered AFTER this call, so it can never observe
|
|
424
|
+
// one -- an uncaught throw here kills the launcher with a raw stack trace
|
|
425
|
+
// instead of falling back to Node.
|
|
426
|
+
await launchFailed(err).catch(fallbackFailed);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (child) {
|
|
430
|
+
// If oam cannot be executed at all (deleted between the stat and the spawn,
|
|
431
|
+
// wrong arch, permission), fall back rather than failing the whole server.
|
|
432
|
+
// `spawned` prevents falling back AFTER the child started, which would
|
|
433
|
+
// double-start the server on the same stdio.
|
|
434
|
+
let spawned = false;
|
|
435
|
+
child.on("spawn", () => {
|
|
436
|
+
spawned = true;
|
|
437
|
+
});
|
|
438
|
+
child.on("error", (err) => {
|
|
439
|
+
if (spawned) return;
|
|
440
|
+
// Handle the rejection instead of discarding it: a failing in-process
|
|
441
|
+
// fallback would otherwise escape as an unhandled rejection, replacing
|
|
442
|
+
// this launcher's diagnostic with a raw stack trace.
|
|
443
|
+
launchFailed(err).catch(fallbackFailed);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// Forward termination so the server's own shutdown path runs in the child
|
|
447
|
+
// rather than the child being orphaned.
|
|
448
|
+
//
|
|
449
|
+
// Registering ANY handler for these suppresses Node's default
|
|
450
|
+
// terminate-on-signal, so the parent's exit has to be arranged explicitly.
|
|
451
|
+
// `child.killed` only records that kill() was CALLED, never that the child
|
|
452
|
+
// is gone, so gating on it swallows every signal after the first and wedges
|
|
453
|
+
// the launcher with no escape hatch.
|
|
454
|
+
//
|
|
455
|
+
// Escalation is driven by a TIMER, not by counting signals. Counting is
|
|
456
|
+
// ambiguous: a supervisor routinely sends SIGINT then SIGTERM milliseconds
|
|
457
|
+
// apart, and a terminal Ctrl-C reaches the whole process group, so reading
|
|
458
|
+
// "a second signal" as impatience hard-kills a child that is already
|
|
459
|
+
// shutting down cleanly. A timer makes the count irrelevant -- ONE press is
|
|
460
|
+
// enough, and a wedged child dies on schedule. setTimeout is monotonic, so
|
|
461
|
+
// a wall-clock step cannot mis-gate the window either.
|
|
462
|
+
//
|
|
463
|
+
// POSIX vs Windows, and why we do NOT forward on Windows.
|
|
464
|
+
// On POSIX child.kill(sig) delivers a real, catchable signal, so forwarding
|
|
465
|
+
// is what lets the child run its shutdown. On Windows there are no POSIX
|
|
466
|
+
// signals: child.kill IGNORES the name and calls TerminateProcess -- an
|
|
467
|
+
// immediate hard kill (verified: a child with a SIGTERM handler never runs
|
|
468
|
+
// it and dies with code=null, signal=SIGTERM). Forwarding there ABORTS the
|
|
469
|
+
// graceful shutdown the console's own Ctrl-C just started, skipping the
|
|
470
|
+
// child's process.on("exit") cleanup. The console has already notified the
|
|
471
|
+
// child, so on Windows the timer below is the only kill we issue.
|
|
472
|
+
const ESCALATE_AFTER_MS = 2000;
|
|
473
|
+
let escalation = null;
|
|
474
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
475
|
+
process.on(sig, () => {
|
|
476
|
+
// No try/catch: kill() on an already-exited child returns false, it does
|
|
477
|
+
// not throw. It throws only for a signal the platform does not know,
|
|
478
|
+
// which SIGINT/SIGTERM/SIGKILL never are.
|
|
479
|
+
if (!isWin) child.kill(sig);
|
|
480
|
+
if (escalation) return; // already counting down; further signals are noise
|
|
481
|
+
escalation = setTimeout(() => {
|
|
482
|
+
// Still here after its grace window. Stop waiting on it.
|
|
483
|
+
child.kill("SIGKILL");
|
|
484
|
+
process.exit(128 + (constants.signals[sig] ?? 15));
|
|
485
|
+
}, ESCALATE_AFTER_MS);
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
child.on("exit", (code, signal) => {
|
|
490
|
+
if (escalation) clearTimeout(escalation);
|
|
491
|
+
// Mirror the child's fate: a signal death becomes 128+n so callers see a
|
|
492
|
+
// conventional shell exit status rather than a bare 0.
|
|
493
|
+
if (signal) {
|
|
494
|
+
process.exit(128 + (constants.signals[signal] ?? 15));
|
|
495
|
+
}
|
|
496
|
+
process.exit(code ?? 0);
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|