@yawlabs/lemonsqueezy-mcp 0.14.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -1
- package/LICENSE +21 -21
- package/README.md +429 -410
- package/bin/lemonsqueezy-mcp.mjs +667 -665
- package/dist/index.js +156 -98
- package/package.json +77 -77
package/bin/lemonsqueezy-mcp.mjs
CHANGED
|
@@ -1,665 +1,667 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Runtime launcher for @yawlabs/lemonsqueezy-mcp.
|
|
4
|
-
*
|
|
5
|
-
* Prefers the newest usable oam runtime (https://oamjs.org) and falls back to
|
|
6
|
-
* Node. It never serves on an oam older than the floor below.
|
|
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. Finding the
|
|
13
|
-
* candidates is stat-only, so a machine without oam never pays for a
|
|
14
|
-
* subprocess.
|
|
15
|
-
*
|
|
16
|
-
* WHAT THE OAM PATH COSTS
|
|
17
|
-
* Reaching oam through an npm `bin` means Node boots first, every oam binary
|
|
18
|
-
* found is asked for its version, and then oam boots to serve -- so the
|
|
19
|
-
* launcher is slower than pointing a host at oam directly. Measured on
|
|
20
|
-
* npmjs-mcp (windows-arm64, n=12 medians, spawn to first MCP initialize):
|
|
21
|
-
* oam 116ms, node 172ms, launcher 243ms. It exists for `npx` convenience.
|
|
22
|
-
*
|
|
23
|
-
* For an MCP host config, point straight at oam and skip this file:
|
|
24
|
-
* { "command": "oam", "args": ["run", "<abs>/dist/index.js"] }
|
|
25
|
-
*
|
|
26
|
-
* WHICH OAM
|
|
27
|
-
* OAM_BIN, when set and usable, is used as given. Otherwise every oam binary
|
|
28
|
-
* discovery can see -- the installed locations, then PATH -- is asked for its
|
|
29
|
-
* version, and the NEWEST one at or above the floor wins; a tie keeps search
|
|
30
|
-
* order. Taking the first binary found instead let a stale copy early in the
|
|
31
|
-
* search order hide a current one later: with oam 0.9.0 installed in ~/.oam/bin
|
|
32
|
-
* and 0.15.2 on PATH, the launcher bound to 0.9.0 because installed locations
|
|
33
|
-
* are searched first.
|
|
34
|
-
*
|
|
35
|
-
* An OAM_BIN that does not exist, is below the floor, or will not run is named
|
|
36
|
-
* on stderr and discovery carries on. It used to stop everything: a typo in
|
|
37
|
-
* OAM_BIN meant Node, with no hint why.
|
|
38
|
-
*
|
|
39
|
-
* ALREADY RUNNING ON OAM
|
|
40
|
-
* A host can resolve this package's `bin` and launch `oam run <this file>`
|
|
41
|
-
* instead of `node <this file>` -- Yaw MCP does, and so does oam's sidecar
|
|
42
|
-
* regression matrix. When `process.versions.oam` clears the floor, the server
|
|
43
|
-
* is imported into THIS process exactly as the Node fallback is -- no
|
|
44
|
-
* discovery, no `oam --version` probe, no second oam. OAM_BIN is a discovery
|
|
45
|
-
* input, so it is not consulted on that path: the host has already chosen
|
|
46
|
-
* which oam runs.
|
|
47
|
-
*
|
|
48
|
-
* LEMONSQUEEZY_MCP_SANDBOX=1 is the exception, deliberately. `--permission` is
|
|
49
|
-
* a process-level flag that only a FRESH oam can apply, so a supported oam host
|
|
50
|
-
* still takes the discovery path and spawns one -- skipping straight to
|
|
51
|
-
* in-process there would drop the sandbox without a word, a security downgrade
|
|
52
|
-
* dressed up as an optimisation.
|
|
53
|
-
*
|
|
54
|
-
* A host oam BELOW the floor never serves. It hands the server off to the
|
|
55
|
-
* newest usable oam, or to Node found on PATH, or exits with an error when
|
|
56
|
-
* there is neither.
|
|
57
|
-
*
|
|
58
|
-
* Any handoff from an oam host PIPES stdio rather than inheriting it. Before
|
|
59
|
-
* 0.9.0 oam treated `stdio: 'inherit'` as `'pipe'`, so an inherited handoff
|
|
60
|
-
* from such a host connected the child to pipes nobody reads: measured with a
|
|
61
|
-
* real oam 0.8.2 host, the MCP handshake never answered. Piping the streams
|
|
62
|
-
* explicitly completes it, to both oam and Node. A Node host keeps `inherit`,
|
|
63
|
-
* which hands over the same fds untouched.
|
|
64
|
-
*
|
|
65
|
-
* The discovery path PREFERS a fresh oam; it does not guarantee one. If it
|
|
66
|
-
* fails -- no usable binary found, or a spawn that errors -- the server still
|
|
67
|
-
* runs WITHOUT `--permission` under LEMONSQUEEZY_MCP_RUNTIME=auto: in-process
|
|
68
|
-
* on Node or on an oam host at the floor, handed off to Node from an oam host
|
|
69
|
-
* below it. That is how a requested sandbox has always degraded, and only
|
|
70
|
-
* LEMONSQUEEZY_MCP_RUNTIME=oam turns it into a failure.
|
|
71
|
-
*
|
|
72
|
-
* WHAT REACHES STDERR
|
|
73
|
-
* An unusable OAM_BIN is always named when discovery runs, whatever runs
|
|
74
|
-
* instead. The discovered binaries that were passed over, and any .cmd/.bat
|
|
75
|
-
* shim, are named only when NO usable oam is found -- a stale copy skipped for
|
|
76
|
-
* a newer one is silent. A chosen oam that fails to spawn is always reported,
|
|
77
|
-
* and so is a handoff TO NODE from an oam host below the floor. A below-floor
|
|
78
|
-
* host that hands off to a newer oam prints nothing: that is an ordinary spawn
|
|
79
|
-
* of the chosen oam. A dropped sandbox gets no note of its own: with nothing
|
|
80
|
-
* else to report, the fallback is silent.
|
|
81
|
-
*
|
|
82
|
-
* THE `--permission` SANDBOX (opt-in)
|
|
83
|
-
* `LEMONSQUEEZY_MCP_SANDBOX=1` runs the server under oam's permission model:
|
|
84
|
-
* network limited to api.lemonsqueezy.com, filesystem denied outright.
|
|
85
|
-
*
|
|
86
|
-
* LEMONSQUEEZY_SINK_URL is operator-configured, so its host is parsed out and
|
|
87
|
-
* added to the grant when set rather than assumed. Child-process stays denied
|
|
88
|
-
* UNLESS LEMONSQUEEZY_API_KEY_COMMAND is configured -- that feature
|
|
89
|
-
*
|
|
90
|
-
* unconditionally.
|
|
91
|
-
*
|
|
92
|
-
* Opt-in, not default, because a wrong grant does not fail loudly. oam denies a
|
|
93
|
-
* non-granted environment variable by making it ABSENT from process.env rather
|
|
94
|
-
* than throwing, so an under-granted LEMONSQUEEZY_API_KEY reads as
|
|
95
|
-
* "unauthenticated" rather than "denied". The env list is derived from what the
|
|
96
|
-
* shipped bundle actually reads -- keep it in step with the bundle.
|
|
97
|
-
*
|
|
98
|
-
* MINIMUM OAM VERSION
|
|
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
|
-
import {
|
|
125
|
-
import {
|
|
126
|
-
import {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
const
|
|
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
|
-
if (
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
* version
|
|
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
|
-
*
|
|
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
|
-
const
|
|
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
|
-
if (
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
};
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
child
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
//
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
//
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
//
|
|
550
|
-
// '
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
*
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
*
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
//
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
...(
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Runtime launcher for @yawlabs/lemonsqueezy-mcp.
|
|
4
|
+
*
|
|
5
|
+
* Prefers the newest usable oam runtime (https://oamjs.org) and falls back to
|
|
6
|
+
* Node. It never serves on an oam older than the floor below.
|
|
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. Finding the
|
|
13
|
+
* candidates is stat-only, so a machine without oam never pays for a
|
|
14
|
+
* subprocess.
|
|
15
|
+
*
|
|
16
|
+
* WHAT THE OAM PATH COSTS
|
|
17
|
+
* Reaching oam through an npm `bin` means Node boots first, every oam binary
|
|
18
|
+
* found is asked for its version, and then oam boots to serve -- so the
|
|
19
|
+
* launcher is slower than pointing a host at oam directly. Measured on
|
|
20
|
+
* npmjs-mcp (windows-arm64, n=12 medians, spawn to first MCP initialize):
|
|
21
|
+
* oam 116ms, node 172ms, launcher 243ms. It exists for `npx` convenience.
|
|
22
|
+
*
|
|
23
|
+
* For an MCP host config, point straight at oam and skip this file:
|
|
24
|
+
* { "command": "oam", "args": ["run", "<abs>/dist/index.js"] }
|
|
25
|
+
*
|
|
26
|
+
* WHICH OAM
|
|
27
|
+
* OAM_BIN, when set and usable, is used as given. Otherwise every oam binary
|
|
28
|
+
* discovery can see -- the installed locations, then PATH -- is asked for its
|
|
29
|
+
* version, and the NEWEST one at or above the floor wins; a tie keeps search
|
|
30
|
+
* order. Taking the first binary found instead let a stale copy early in the
|
|
31
|
+
* search order hide a current one later: with oam 0.9.0 installed in ~/.oam/bin
|
|
32
|
+
* and 0.15.2 on PATH, the launcher bound to 0.9.0 because installed locations
|
|
33
|
+
* are searched first.
|
|
34
|
+
*
|
|
35
|
+
* An OAM_BIN that does not exist, is below the floor, or will not run is named
|
|
36
|
+
* on stderr and discovery carries on. It used to stop everything: a typo in
|
|
37
|
+
* OAM_BIN meant Node, with no hint why.
|
|
38
|
+
*
|
|
39
|
+
* ALREADY RUNNING ON OAM
|
|
40
|
+
* A host can resolve this package's `bin` and launch `oam run <this file>`
|
|
41
|
+
* instead of `node <this file>` -- Yaw MCP does, and so does oam's sidecar
|
|
42
|
+
* regression matrix. When `process.versions.oam` clears the floor, the server
|
|
43
|
+
* is imported into THIS process exactly as the Node fallback is -- no
|
|
44
|
+
* discovery, no `oam --version` probe, no second oam. OAM_BIN is a discovery
|
|
45
|
+
* input, so it is not consulted on that path: the host has already chosen
|
|
46
|
+
* which oam runs.
|
|
47
|
+
*
|
|
48
|
+
* LEMONSQUEEZY_MCP_SANDBOX=1 is the exception, deliberately. `--permission` is
|
|
49
|
+
* a process-level flag that only a FRESH oam can apply, so a supported oam host
|
|
50
|
+
* still takes the discovery path and spawns one -- skipping straight to
|
|
51
|
+
* in-process there would drop the sandbox without a word, a security downgrade
|
|
52
|
+
* dressed up as an optimisation.
|
|
53
|
+
*
|
|
54
|
+
* A host oam BELOW the floor never serves. It hands the server off to the
|
|
55
|
+
* newest usable oam, or to Node found on PATH, or exits with an error when
|
|
56
|
+
* there is neither.
|
|
57
|
+
*
|
|
58
|
+
* Any handoff from an oam host PIPES stdio rather than inheriting it. Before
|
|
59
|
+
* 0.9.0 oam treated `stdio: 'inherit'` as `'pipe'`, so an inherited handoff
|
|
60
|
+
* from such a host connected the child to pipes nobody reads: measured with a
|
|
61
|
+
* real oam 0.8.2 host, the MCP handshake never answered. Piping the streams
|
|
62
|
+
* explicitly completes it, to both oam and Node. A Node host keeps `inherit`,
|
|
63
|
+
* which hands over the same fds untouched.
|
|
64
|
+
*
|
|
65
|
+
* The discovery path PREFERS a fresh oam; it does not guarantee one. If it
|
|
66
|
+
* fails -- no usable binary found, or a spawn that errors -- the server still
|
|
67
|
+
* runs WITHOUT `--permission` under LEMONSQUEEZY_MCP_RUNTIME=auto: in-process
|
|
68
|
+
* on Node or on an oam host at the floor, handed off to Node from an oam host
|
|
69
|
+
* below it. That is how a requested sandbox has always degraded, and only
|
|
70
|
+
* LEMONSQUEEZY_MCP_RUNTIME=oam turns it into a failure.
|
|
71
|
+
*
|
|
72
|
+
* WHAT REACHES STDERR
|
|
73
|
+
* An unusable OAM_BIN is always named when discovery runs, whatever runs
|
|
74
|
+
* instead. The discovered binaries that were passed over, and any .cmd/.bat
|
|
75
|
+
* shim, are named only when NO usable oam is found -- a stale copy skipped for
|
|
76
|
+
* a newer one is silent. A chosen oam that fails to spawn is always reported,
|
|
77
|
+
* and so is a handoff TO NODE from an oam host below the floor. A below-floor
|
|
78
|
+
* host that hands off to a newer oam prints nothing: that is an ordinary spawn
|
|
79
|
+
* of the chosen oam. A dropped sandbox gets no note of its own: with nothing
|
|
80
|
+
* else to report, the fallback is silent.
|
|
81
|
+
*
|
|
82
|
+
* THE `--permission` SANDBOX (opt-in)
|
|
83
|
+
* `LEMONSQUEEZY_MCP_SANDBOX=1` runs the server under oam's permission model:
|
|
84
|
+
* network limited to api.lemonsqueezy.com, filesystem denied outright.
|
|
85
|
+
*
|
|
86
|
+
* LEMONSQUEEZY_SINK_URL is operator-configured, so its host is parsed out and
|
|
87
|
+
* added to the grant when set rather than assumed. Child-process stays denied
|
|
88
|
+
* UNLESS LEMONSQUEEZY_API_KEY_COMMAND is configured -- that feature runs the
|
|
89
|
+
* key command as a child process (execFile, no shell), so the grant is tied to
|
|
90
|
+
* the feature instead of handed over unconditionally.
|
|
91
|
+
*
|
|
92
|
+
* Opt-in, not default, because a wrong grant does not fail loudly. oam denies a
|
|
93
|
+
* non-granted environment variable by making it ABSENT from process.env rather
|
|
94
|
+
* than throwing, so an under-granted LEMONSQUEEZY_API_KEY reads as
|
|
95
|
+
* "unauthenticated" rather than "denied". The env list is derived from what the
|
|
96
|
+
* shipped bundle actually reads -- keep it in step with the bundle.
|
|
97
|
+
*
|
|
98
|
+
* MINIMUM OAM VERSION
|
|
99
|
+
* OAM_MIN, 0.15.2, is a floor: the oam this server was last verified on, not
|
|
100
|
+
* a claim that it is the newest oam. Raise it once a newer oam has been
|
|
101
|
+
* re-verified here (handshake and all 64 tools through this launcher). The
|
|
102
|
+
* server only runs on an oam at or above the floor; an older one is never
|
|
103
|
+
* served on. The floor is not cosmetic: before 0.9.0 `child_process.execFile`
|
|
104
|
+
* ran its arguments through a SHELL, `exec` accepted `timeout` and ignored it,
|
|
105
|
+
* `spawnSync` truncated at `maxBuffer` while reporting success, and
|
|
106
|
+
* `stdio: 'inherit'`/`'ignore'` both behaved as `'pipe'`. This server starts a
|
|
107
|
+
* child process only when LEMONSQUEEZY_API_KEY_COMMAND is configured (execFile,
|
|
108
|
+
* deliberately without a shell), so the execFile bug was reachable on exactly
|
|
109
|
+
* that path -- the command's arguments were re-split by a shell anyway.
|
|
110
|
+
*
|
|
111
|
+
* SELECTION
|
|
112
|
+
* LEMONSQUEEZY_MCP_RUNTIME=auto newest usable oam, else Node (default)
|
|
113
|
+
* LEMONSQUEEZY_MCP_RUNTIME=oam newest usable oam, else exit with an error
|
|
114
|
+
* (already running on oam at the floor
|
|
115
|
+
* satisfies it, unless the sandbox is on)
|
|
116
|
+
* LEMONSQUEEZY_MCP_RUNTIME=node Node: in THIS process on Node, handed off
|
|
117
|
+
* to Node on PATH when THIS process is oam
|
|
118
|
+
* LEMONSQUEEZY_MCP_SANDBOX=1 spawn a fresh oam under --permission
|
|
119
|
+
* OAM_BIN=/path/to/oam use this oam when it is usable, before
|
|
120
|
+
* discovery
|
|
121
|
+
* The runtime value is case-insensitive; anything else behaves like `auto`.
|
|
122
|
+
*/
|
|
123
|
+
|
|
124
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
125
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
126
|
+
import { constants, homedir } from "node:os";
|
|
127
|
+
import { delimiter, join } from "node:path";
|
|
128
|
+
import { fileURLToPath } from "node:url";
|
|
129
|
+
|
|
130
|
+
/** Oldest oam this server is served on. See MINIMUM OAM VERSION above. */
|
|
131
|
+
const OAM_MIN = [0, 15, 2];
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Bound on each `oam --version` probe. A healthy oam answers in milliseconds;
|
|
135
|
+
* the bound only exists so a wedged binary on PATH cannot hang the launch.
|
|
136
|
+
*/
|
|
137
|
+
const VERSION_PROBE_TIMEOUT_MS = 5_000;
|
|
138
|
+
|
|
139
|
+
// Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
|
|
140
|
+
// with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
|
|
141
|
+
// in-process fallback must use the file:// URL. spawn() needs a real path.
|
|
142
|
+
const SERVER_URL = new URL("../dist/index.js", import.meta.url);
|
|
143
|
+
const SERVER_ENTRY = fileURLToPath(SERVER_URL);
|
|
144
|
+
const isWin = process.platform === "win32";
|
|
145
|
+
const exe = isWin ? "oam.exe" : "oam";
|
|
146
|
+
|
|
147
|
+
/** Identity for de-duplicating paths: resolved, and case-folded on Windows. */
|
|
148
|
+
function pathKey(p) {
|
|
149
|
+
let key = p;
|
|
150
|
+
try {
|
|
151
|
+
key = realpathSync(p);
|
|
152
|
+
} catch {
|
|
153
|
+
// Unresolvable: fall back to the literal path.
|
|
154
|
+
}
|
|
155
|
+
return isWin ? key.toLowerCase() : key;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Every oam binary discovery can see, in search order, de-duplicated. Stat-only,
|
|
160
|
+
* never a subprocess.
|
|
161
|
+
*
|
|
162
|
+
* Installed locations come BEFORE PATH, so when two binaries report the same
|
|
163
|
+
* version the installed copy wins the tie -- a dev build on PATH is replaced
|
|
164
|
+
* underneath running processes by cargo. Both forms are checked on Windows: the
|
|
165
|
+
* installer defaults to %LOCALAPPDATA%\oam\bin there, but oam's docs name
|
|
166
|
+
* ~/.oam/bin first and OAM_INSTALL_DIR can pick either.
|
|
167
|
+
*
|
|
168
|
+
* PATH is resolved manually rather than by spawning `which`/`where`, which
|
|
169
|
+
* would cost a subprocess on every launch just to decide whether to spawn.
|
|
170
|
+
*
|
|
171
|
+
* Windows: `.exe` ONLY -- deliberately narrower than PATHEXT. Node refuses to
|
|
172
|
+
* run a .cmd/.bat through execFile/spawn without `shell: true` (EINVAL, and for
|
|
173
|
+
* spawn it throws SYNCHRONOUSLY rather than emitting 'error'), so walking the
|
|
174
|
+
* full PATHEXT list would hand back a path this launcher cannot execute. A
|
|
175
|
+
* skipped shim is still reported when no usable oam is found -- see findOamShim.
|
|
176
|
+
*/
|
|
177
|
+
function discoverOamPaths() {
|
|
178
|
+
const installed = [join(homedir(), ".oam", "bin", exe)];
|
|
179
|
+
if (isWin) {
|
|
180
|
+
installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
|
|
181
|
+
}
|
|
182
|
+
const onPath = (process.env.PATH ?? "")
|
|
183
|
+
.split(delimiter)
|
|
184
|
+
.filter(Boolean)
|
|
185
|
+
.map((dir) => join(dir, exe));
|
|
186
|
+
const seen = new Set();
|
|
187
|
+
const found = [];
|
|
188
|
+
for (const candidate of [...installed, ...onPath]) {
|
|
189
|
+
if (!existsSync(candidate)) continue;
|
|
190
|
+
const key = pathKey(candidate);
|
|
191
|
+
if (seen.has(key)) continue;
|
|
192
|
+
seen.add(key);
|
|
193
|
+
found.push(candidate);
|
|
194
|
+
}
|
|
195
|
+
return found;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Version text -> [major, minor, patch], or null when it holds no version.
|
|
200
|
+
* A pre-release suffix (0.9.0-rc.1) truncates to its base version.
|
|
201
|
+
*
|
|
202
|
+
* Shared by the two places a version is read -- a discovered binary's
|
|
203
|
+
* `oam --version` output ("oam 0.15.1") and the host's own
|
|
204
|
+
* `process.versions.oam` ("0.15.1") -- so they cannot disagree about what a
|
|
205
|
+
* version string means, or which floor it has to clear.
|
|
206
|
+
*/
|
|
207
|
+
function parseVersion(text) {
|
|
208
|
+
const m = /(\d+)\.(\d+)\.(\d+)/.exec(text);
|
|
209
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** `oam --version` -> [major, minor, patch], or null when it cannot be read. */
|
|
213
|
+
function oamVersion(cmd) {
|
|
214
|
+
try {
|
|
215
|
+
const out = execFileSync(cmd, ["--version"], {
|
|
216
|
+
encoding: "utf-8",
|
|
217
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
218
|
+
timeout: VERSION_PROBE_TIMEOUT_MS,
|
|
219
|
+
windowsHide: true,
|
|
220
|
+
});
|
|
221
|
+
return parseVersion(out);
|
|
222
|
+
} catch {
|
|
223
|
+
// Not executable, wrong arch, wedged, or deleted since the stat. Caller degrades.
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** True when `v` is at least `min`, comparing major/minor/patch in order. */
|
|
229
|
+
function atLeast(v, min) {
|
|
230
|
+
if (!v) return false;
|
|
231
|
+
for (let i = 0; i < min.length; i++) {
|
|
232
|
+
if (v[i] > min[i]) return true;
|
|
233
|
+
if (v[i] < min[i]) return false;
|
|
234
|
+
}
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The newest candidate at or above the floor, or null. `candidates` is
|
|
240
|
+
* `{ path, version }[]` in search order, `version` null when unreadable.
|
|
241
|
+
* Strictly-greater replaces, so a tie keeps the earlier candidate.
|
|
242
|
+
*
|
|
243
|
+
* Pure on purpose, like runtimePlan: the choice is testable without binaries.
|
|
244
|
+
*/
|
|
245
|
+
function pickNewest(candidates) {
|
|
246
|
+
let best = null;
|
|
247
|
+
for (const candidate of candidates) {
|
|
248
|
+
if (!atLeast(candidate.version, OAM_MIN)) continue;
|
|
249
|
+
if (!best || !atLeast(best.version, candidate.version)) best = candidate;
|
|
250
|
+
}
|
|
251
|
+
return best;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Where the server runs, decided BEFORE any discovery:
|
|
256
|
+
* "in-process" import it into THIS process
|
|
257
|
+
* "discover" choose an oam and spawn it, or fall back
|
|
258
|
+
* "handoff-node" hand it off to Node on PATH: THIS process is an oam and
|
|
259
|
+
* Node was asked for
|
|
260
|
+
*
|
|
261
|
+
* `hostOam` is `process.versions.oam`: oam's own key, absent on Node. An oam
|
|
262
|
+
* host whose version cannot be read is treated as below the floor -- it never
|
|
263
|
+
* proved it is a supported oam. `sandbox` is whether a spawn would carry flags
|
|
264
|
+
* only a fresh oam can apply; see ALREADY RUNNING ON OAM above for why that
|
|
265
|
+
* alone forces the discovery path, and why that path can still end without
|
|
266
|
+
* them. The floor is OAM_MIN itself, not a parameter, so a host oam and a
|
|
267
|
+
* discovered one can never be held to different minimums.
|
|
268
|
+
*
|
|
269
|
+
* Pure on purpose: every input is passed in, so the whole decision is testable
|
|
270
|
+
* without booting a runtime.
|
|
271
|
+
*/
|
|
272
|
+
function runtimePlan({ mode, hostOam, sandbox }) {
|
|
273
|
+
const onOam = hostOam !== undefined;
|
|
274
|
+
if (mode === "node") return onOam ? "handoff-node" : "in-process";
|
|
275
|
+
if (sandbox) return "discover";
|
|
276
|
+
return atLeast(parseVersion(hostOam ?? ""), OAM_MIN) ? "in-process" : "discover";
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The `--permission` grant list, or [] when the sandbox is not requested.
|
|
281
|
+
*
|
|
282
|
+
* These are oam's PROCESS-level flags: they belong before the `run` subcommand,
|
|
283
|
+
* not after it. `oam run --permission file.js` is rejected outright, which is a
|
|
284
|
+
* good failure but only because it is loud -- ordering here is load-bearing.
|
|
285
|
+
*
|
|
286
|
+
* Net grants prefix-match `host` for fetch and `host:port` for sockets.
|
|
287
|
+
* A denied environment variable is ABSENT from process.env rather than throwing,
|
|
288
|
+
* so the env list below is derived from what the bundle actually reads; trimming
|
|
289
|
+
* it produces silent misbehaviour, not a clear denial.
|
|
290
|
+
*/
|
|
291
|
+
function sandboxFlags() {
|
|
292
|
+
if (process.env.LEMONSQUEEZY_MCP_SANDBOX !== "1") return [];
|
|
293
|
+
|
|
294
|
+
const hosts = ["api.lemonsqueezy.com"];
|
|
295
|
+
// LEMONSQUEEZY_SINK_URL is operator-configured, so its host has to be learned, not assumed.
|
|
296
|
+
if (process.env.LEMONSQUEEZY_SINK_URL) {
|
|
297
|
+
try {
|
|
298
|
+
const { hostname } = new URL(process.env.LEMONSQUEEZY_SINK_URL);
|
|
299
|
+
if (hostname && !hosts.includes(hostname)) hosts.push(hostname);
|
|
300
|
+
} catch {
|
|
301
|
+
// Malformed URL: the feature is already broken on its own terms; adding
|
|
302
|
+
// nothing here keeps the grant honest rather than guessing a host.
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const netFlag = `--allow-net=${hosts.join(",")}`;
|
|
306
|
+
|
|
307
|
+
const env = [
|
|
308
|
+
"LEMONSQUEEZY_ALLOWED_STORE_IDS",
|
|
309
|
+
"LEMONSQUEEZY_API_KEY",
|
|
310
|
+
"LEMONSQUEEZY_API_KEY_COMMAND",
|
|
311
|
+
"LEMONSQUEEZY_DESTRUCTIVE_RATE_LIMIT",
|
|
312
|
+
"LEMONSQUEEZY_DISABLE_CLASSES",
|
|
313
|
+
"LEMONSQUEEZY_LOG",
|
|
314
|
+
"LEMONSQUEEZY_MAX_REFUND_AMOUNT_CENTS",
|
|
315
|
+
"LEMONSQUEEZY_RATE_LIMIT_PER_CLASS",
|
|
316
|
+
"LEMONSQUEEZY_SINK_ADMIN_TOKEN",
|
|
317
|
+
"LEMONSQUEEZY_SINK_URL",
|
|
318
|
+
"LEMONSQUEEZY_TEST_API_KEY",
|
|
319
|
+
"PATH",
|
|
320
|
+
];
|
|
321
|
+
|
|
322
|
+
const flags = ["--permission", netFlag, `--allow-env=${env.join(",")}`];
|
|
323
|
+
// Tied to the feature that needs it, not granted unconditionally.
|
|
324
|
+
if (process.env.LEMONSQUEEZY_API_KEY_COMMAND) flags.push("--allow-child-process");
|
|
325
|
+
return flags;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Write a diagnostic to stderr synchronously, so a following process.exit
|
|
330
|
+
* cannot truncate it.
|
|
331
|
+
*
|
|
332
|
+
* Not a bare writeSync: that call can short-write (it returns a byte count) and
|
|
333
|
+
* on macOS it can throw EAGAIN, because Node makes a piped stderr non-blocking
|
|
334
|
+
* there rather than blocking the write. Loop over the remaining bytes, and if
|
|
335
|
+
* stderr turns out to be unusable give up quietly -- failing to print a
|
|
336
|
+
* diagnostic is not worth crashing a stdio server over.
|
|
337
|
+
*/
|
|
338
|
+
async function errSync(message) {
|
|
339
|
+
const { writeSync } = await import("node:fs");
|
|
340
|
+
const buf = Buffer.from(message);
|
|
341
|
+
let off = 0;
|
|
342
|
+
for (let attempts = 0; off < buf.length && attempts < 1000; attempts++) {
|
|
343
|
+
try {
|
|
344
|
+
off += writeSync(2, buf, off, buf.length - off);
|
|
345
|
+
} catch (err) {
|
|
346
|
+
if (err?.code !== "EAGAIN") return;
|
|
347
|
+
// Pipe is full and the reader has not drained yet -- retry.
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* An oam-named .cmd/.bat on PATH: a real install in a shape this launcher
|
|
354
|
+
* cannot spawn. Reported rather than ignored, because "no oam binary was found"
|
|
355
|
+
* reads as "install oam" -- the one thing that will not help. Only looked for
|
|
356
|
+
* when no usable oam was found, so a shim beside a working oam.exe is never
|
|
357
|
+
* mentioned. Windows only; there is no such shim concept on POSIX.
|
|
358
|
+
*/
|
|
359
|
+
function findOamShim() {
|
|
360
|
+
if (!isWin) return null;
|
|
361
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
362
|
+
if (!dir) continue;
|
|
363
|
+
for (const ext of [".cmd", ".bat"]) {
|
|
364
|
+
const candidate = join(dir, `oam${ext}`);
|
|
365
|
+
if (existsSync(candidate)) return candidate;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** A Node binary on PATH, or null. Stat-only; used only when THIS process is oam. */
|
|
372
|
+
function findNodeOnPath() {
|
|
373
|
+
const name = isWin ? "node.exe" : "node";
|
|
374
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
375
|
+
if (!dir) continue;
|
|
376
|
+
const candidate = join(dir, name);
|
|
377
|
+
if (existsSync(candidate)) return candidate;
|
|
378
|
+
}
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Why a candidate was passed over, for stderr. */
|
|
383
|
+
function unusableReason(path, version, label = path) {
|
|
384
|
+
const min = OAM_MIN.join(".");
|
|
385
|
+
return version
|
|
386
|
+
? `${label} is oam ${version.join(".")}, older than ${min}`
|
|
387
|
+
: `${label} could not be run, or did not report a version this launcher understands`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Choose the oam to spawn: a usable OAM_BIN, else the newest usable discovered
|
|
392
|
+
* binary. Returns the choice (or null) plus stderr notes: `overrideNote` about
|
|
393
|
+
* an unusable OAM_BIN, and `skipped` describing what was found and rejected
|
|
394
|
+
* when nothing was usable.
|
|
395
|
+
*/
|
|
396
|
+
function chooseOam() {
|
|
397
|
+
const override = process.env.OAM_BIN;
|
|
398
|
+
let overrideNote = null;
|
|
399
|
+
if (override) {
|
|
400
|
+
if (!existsSync(override)) {
|
|
401
|
+
overrideNote = `OAM_BIN=${override} does not exist`;
|
|
402
|
+
} else {
|
|
403
|
+
const version = oamVersion(override);
|
|
404
|
+
if (atLeast(version, OAM_MIN)) return { chosen: { path: override, version }, overrideNote, skipped: [] };
|
|
405
|
+
overrideNote = unusableReason(override, version, `OAM_BIN=${override}`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
const overrideKey = override ? pathKey(override) : null;
|
|
409
|
+
const candidates = discoverOamPaths()
|
|
410
|
+
.filter((path) => pathKey(path) !== overrideKey)
|
|
411
|
+
.map((path) => ({ path, version: oamVersion(path) }));
|
|
412
|
+
const chosen = pickNewest(candidates);
|
|
413
|
+
const skipped = chosen ? [] : candidates.map((c) => unusableReason(c.path, c.version));
|
|
414
|
+
return { chosen, overrideNote, skipped };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Run the server in THIS process. The zero-overhead fallback. */
|
|
418
|
+
async function runInProcess() {
|
|
419
|
+
// A server may gate its bootstrap on being the process ENTRY POINT --
|
|
420
|
+
// `import.meta.url === pathToFileURL(process.argv[1]).href` -- so that its own
|
|
421
|
+
// test file can import the module for unit tests without connecting a stdio
|
|
422
|
+
// transport. aws-mcp does exactly this. Importing the server here would leave
|
|
423
|
+
// argv[1] pointing at THIS launcher, the guard would read false, and the
|
|
424
|
+
// server would load but never serve: the MCP handshake just hangs.
|
|
425
|
+
//
|
|
426
|
+
// Point argv[1] at the server first, so the in-process path is
|
|
427
|
+
// indistinguishable from having executed the file directly. The spawn path
|
|
428
|
+
// needs no equivalent -- there argv[1] is already the server.
|
|
429
|
+
process.argv[1] = SERVER_ENTRY;
|
|
430
|
+
await import(SERVER_URL.href);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ONE reporter for every failed in-process fallback. runInProcess() is a bare
|
|
434
|
+
// import() that rejects when dist/index.js is missing, and at ESM top level an
|
|
435
|
+
// unhandled rejection is an uncaught exception -- replacing this launcher's
|
|
436
|
+
// diagnostic with a raw stack trace.
|
|
437
|
+
const fallbackFailed = (e) => {
|
|
438
|
+
process.stderr.write(`lemonsqueezy-mcp: in-process fallback failed (${e?.message ?? e})\n`);
|
|
439
|
+
process.exitCode = 1;
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Spawn the server in a child runtime and mirror its lifetime.
|
|
444
|
+
*
|
|
445
|
+
* `onLaunchFailed(err)` runs when the child could not be started at all; it is
|
|
446
|
+
* never called once the child is running, which would double-start the server
|
|
447
|
+
* on the same stdio.
|
|
448
|
+
*/
|
|
449
|
+
async function launchChild(cmd, args, onLaunchFailed) {
|
|
450
|
+
// Any spawn from an oam host pipes. Below the floor its `stdio: 'inherit'`
|
|
451
|
+
// does not hand over the fds. A supported host only gets here in two cases:
|
|
452
|
+
// the sandbox asked for a fresh oam, or LEMONSQUEEZY_MCP_RUNTIME=node handed
|
|
453
|
+
// the server off to Node (handOffToNode). See ALREADY RUNNING ON OAM.
|
|
454
|
+
const piped = process.versions.oam !== undefined;
|
|
455
|
+
let child = null;
|
|
456
|
+
try {
|
|
457
|
+
child = spawn(cmd, args, {
|
|
458
|
+
// inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
|
|
459
|
+
// stdin/stdout is untouched and the host's stdin-close still reaches the
|
|
460
|
+
// server's shutdown path. Piping preserves both as well: bytes are copied
|
|
461
|
+
// unchanged, and stdin's end propagates to the child.
|
|
462
|
+
stdio: piped ? ["pipe", "pipe", "pipe"] : "inherit",
|
|
463
|
+
env: process.env,
|
|
464
|
+
windowsHide: true,
|
|
465
|
+
});
|
|
466
|
+
} catch (err) {
|
|
467
|
+
// spawn() THROWS for some failures instead of emitting 'error', and the
|
|
468
|
+
// 'error' listener is registered AFTER this call, so it can never observe
|
|
469
|
+
// one -- an uncaught throw here kills the launcher with a raw stack trace
|
|
470
|
+
// instead of falling back.
|
|
471
|
+
await onLaunchFailed(err).catch(fallbackFailed);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// If the runtime cannot be executed at all (deleted between the version probe
|
|
476
|
+
// and the spawn, wrong arch, permission), fall back rather than failing the
|
|
477
|
+
// whole server. `spawned` prevents falling back AFTER the child started.
|
|
478
|
+
//
|
|
479
|
+
// Everything that assumes a live child waits for 'spawn'. A failed spawn
|
|
480
|
+
// still emits 'close' (after 'error', with the negative errno as its code), so
|
|
481
|
+
// an unguarded close handler would process.exit() out from under the fallback
|
|
482
|
+
// onLaunchFailed has just started. And stdin piped into a child that never ran
|
|
483
|
+
// takes input away from that fallback: measured with the in-process fallback,
|
|
484
|
+
// the host's first request was answered and the next one never arrived. Until
|
|
485
|
+
// 'spawn', process.stdin has no reader and simply stays paused.
|
|
486
|
+
let spawned = false;
|
|
487
|
+
child.on("spawn", () => {
|
|
488
|
+
spawned = true;
|
|
489
|
+
if (piped) {
|
|
490
|
+
process.stdin.pipe(child.stdin);
|
|
491
|
+
child.stdout.pipe(process.stdout);
|
|
492
|
+
child.stderr.pipe(process.stderr);
|
|
493
|
+
}
|
|
494
|
+
forwardSignals();
|
|
495
|
+
});
|
|
496
|
+
child.on("error", (err) => {
|
|
497
|
+
if (spawned) return;
|
|
498
|
+
onLaunchFailed(err).catch(fallbackFailed);
|
|
499
|
+
});
|
|
500
|
+
// A child that exits before reading everything closes its stdin; the
|
|
501
|
+
// resulting EPIPE is not worth crashing over.
|
|
502
|
+
child.stdin?.on("error", () => {});
|
|
503
|
+
|
|
504
|
+
// Forward termination so the server's own shutdown path runs in the child
|
|
505
|
+
// rather than the child being orphaned.
|
|
506
|
+
//
|
|
507
|
+
// Registering ANY handler for these suppresses Node's default
|
|
508
|
+
// terminate-on-signal, so the parent's exit has to be arranged explicitly.
|
|
509
|
+
// `child.killed` only records that kill() was CALLED, never that the child
|
|
510
|
+
// is gone, so gating on it swallows every signal after the first and wedges
|
|
511
|
+
// the launcher with no escape hatch.
|
|
512
|
+
//
|
|
513
|
+
// Escalation is driven by a TIMER, not by counting signals. Counting is
|
|
514
|
+
// ambiguous: a supervisor routinely sends SIGINT then SIGTERM milliseconds
|
|
515
|
+
// apart, and a terminal Ctrl-C reaches the whole process group, so reading
|
|
516
|
+
// "a second signal" as impatience hard-kills a child that is already
|
|
517
|
+
// shutting down cleanly. A timer makes the count irrelevant -- ONE press is
|
|
518
|
+
// enough, and a wedged child dies on schedule. setTimeout is monotonic, so
|
|
519
|
+
// a wall-clock step cannot mis-gate the window either.
|
|
520
|
+
//
|
|
521
|
+
// POSIX vs Windows, and why we do NOT forward on Windows.
|
|
522
|
+
// On POSIX child.kill(sig) delivers a real, catchable signal, so forwarding
|
|
523
|
+
// is what lets the child run its shutdown. On Windows there are no POSIX
|
|
524
|
+
// signals: child.kill IGNORES the name and calls TerminateProcess -- an
|
|
525
|
+
// immediate hard kill (verified: a child with a SIGTERM handler never runs
|
|
526
|
+
// it and dies with code=null, signal=SIGTERM). Forwarding there ABORTS the
|
|
527
|
+
// graceful shutdown the console's own Ctrl-C just started, skipping the
|
|
528
|
+
// child's process.on("exit") cleanup. The console has already notified the
|
|
529
|
+
// child, so on Windows the timer below is the only kill we issue.
|
|
530
|
+
const ESCALATE_AFTER_MS = 2000;
|
|
531
|
+
let escalation = null;
|
|
532
|
+
function forwardSignals() {
|
|
533
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
534
|
+
process.on(sig, () => {
|
|
535
|
+
// No try/catch: kill() on an already-exited child returns false, it does
|
|
536
|
+
// not throw. It throws only for a signal the platform does not know,
|
|
537
|
+
// which SIGINT/SIGTERM/SIGKILL never are.
|
|
538
|
+
if (!isWin) child.kill(sig);
|
|
539
|
+
if (escalation) return; // already counting down; further signals are noise
|
|
540
|
+
escalation = setTimeout(() => {
|
|
541
|
+
// Still here after its grace window. Stop waiting on it.
|
|
542
|
+
child.kill("SIGKILL");
|
|
543
|
+
process.exit(128 + (constants.signals[sig] ?? 15));
|
|
544
|
+
}, ESCALATE_AFTER_MS);
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Piped: wait for 'close', so the child's last stdout bytes are copied out
|
|
550
|
+
// before this process exits. Inherited: 'exit' is enough, the fds were never
|
|
551
|
+
// ours to drain. Either way, only for a child that actually ran -- see the
|
|
552
|
+
// 'spawn' handler above.
|
|
553
|
+
child.on(piped ? "close" : "exit", (code, signal) => {
|
|
554
|
+
if (!spawned) return;
|
|
555
|
+
if (escalation) clearTimeout(escalation);
|
|
556
|
+
// Mirror the child's fate: a signal death becomes 128+n so callers see a
|
|
557
|
+
// conventional shell exit status rather than a bare 0.
|
|
558
|
+
if (signal) {
|
|
559
|
+
process.exit(128 + (constants.signals[signal] ?? 15));
|
|
560
|
+
}
|
|
561
|
+
process.exit(code ?? 0);
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Hand the server to Node on PATH. Only reachable when THIS process is oam --
|
|
567
|
+
* one below the floor, or any oam under LEMONSQUEEZY_MCP_RUNTIME=node -- so
|
|
568
|
+
* there is no in-process option left.
|
|
569
|
+
*/
|
|
570
|
+
async function handOffToNode(reason) {
|
|
571
|
+
const node = findNodeOnPath();
|
|
572
|
+
if (!node) {
|
|
573
|
+
await errSync(
|
|
574
|
+
`lemonsqueezy-mcp: ${reason}, and no Node was found on PATH to run the server instead.\n` +
|
|
575
|
+
`Run \`oam self-update\` to get oam ${OAM_MIN.join(".")} or newer, or launch this command with node.\n`,
|
|
576
|
+
);
|
|
577
|
+
process.exit(1);
|
|
578
|
+
}
|
|
579
|
+
if (reason) await errSync(`lemonsqueezy-mcp: ${reason}; running on ${node} instead.\n`);
|
|
580
|
+
await launchChild(node, [SERVER_ENTRY, ...process.argv.slice(2)], async (err) => {
|
|
581
|
+
await errSync(`lemonsqueezy-mcp: failed to launch Node at ${node} (${err?.message ?? err})\n`);
|
|
582
|
+
process.exit(1);
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* No oam was spawned, under a mode that allows falling back. `note` is what was
|
|
588
|
+
* passed over, or "" when there is nothing to say. `newerFound` is true when a
|
|
589
|
+
* usable oam WAS chosen and only its launch failed, so the below-floor handoff
|
|
590
|
+
* note does not claim that no newer oam exists.
|
|
591
|
+
*
|
|
592
|
+
* Node, and an oam host at the floor, serve in-process. The latter is reachable
|
|
593
|
+
* only with the sandbox requested -- a supported host otherwise never reaches
|
|
594
|
+
* discovery -- so it serves WITHOUT `--permission`, as a requested sandbox
|
|
595
|
+
* always has when no fresh oam could be started. An oam host below the floor
|
|
596
|
+
* never serves and hands off to Node.
|
|
597
|
+
*/
|
|
598
|
+
async function fallBack(hostOam, note, newerFound = false) {
|
|
599
|
+
if (hostOam === undefined) {
|
|
600
|
+
if (note) await errSync(`lemonsqueezy-mcp: ${note}; using Node instead.\n`);
|
|
601
|
+
await runInProcess();
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (atLeast(parseVersion(hostOam), OAM_MIN)) {
|
|
605
|
+
if (note) await errSync(`lemonsqueezy-mcp: ${note}; serving on this oam ${hostOam} without --permission.\n`);
|
|
606
|
+
await runInProcess();
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
const why = `this process is oam ${hostOam}, older than ${OAM_MIN.join(".")}${newerFound ? "" : ", and no newer oam was found"}`;
|
|
610
|
+
await handOffToNode(`${note ? `${note}; ` : ""}${why}`);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const mode = (process.env.LEMONSQUEEZY_MCP_RUNTIME ?? "auto").toLowerCase();
|
|
614
|
+
const hostOam = process.versions.oam;
|
|
615
|
+
|
|
616
|
+
// Computed ONCE and reused by the spawn below, so "would the spawn carry
|
|
617
|
+
// --permission" cannot drift from what the spawn actually passes.
|
|
618
|
+
const sandbox = sandboxFlags();
|
|
619
|
+
const plan = runtimePlan({ mode, hostOam, sandbox: sandbox.length > 0 });
|
|
620
|
+
|
|
621
|
+
if (plan === "in-process") {
|
|
622
|
+
await runInProcess();
|
|
623
|
+
} else if (plan === "handoff-node") {
|
|
624
|
+
const belowFloor = !atLeast(parseVersion(hostOam), OAM_MIN);
|
|
625
|
+
await handOffToNode(belowFloor ? `this process is oam ${hostOam}, older than ${OAM_MIN.join(".")}` : "");
|
|
626
|
+
} else {
|
|
627
|
+
const { chosen, overrideNote, skipped } = chooseOam();
|
|
628
|
+
|
|
629
|
+
if (chosen) {
|
|
630
|
+
if (overrideNote) {
|
|
631
|
+
await errSync(`lemonsqueezy-mcp: ${overrideNote}; using ${chosen.path} (oam ${chosen.version.join(".")}).\n`);
|
|
632
|
+
}
|
|
633
|
+
// `--` separates oam's own flags from the script's argv, so `lemonsqueezy-mcp
|
|
634
|
+
// --version` and any host-supplied flags survive the hop unchanged. The
|
|
635
|
+
// sandbox flags are process-level, so they go BEFORE `run`.
|
|
636
|
+
await launchChild(chosen.path, [...sandbox, "run", SERVER_ENTRY, "--", ...process.argv.slice(2)], async (err) => {
|
|
637
|
+
const failed = `failed to launch oam at ${chosen.path} (${err?.message ?? err})`;
|
|
638
|
+
if (mode === "oam") {
|
|
639
|
+
await errSync(`lemonsqueezy-mcp: ${failed}\n`);
|
|
640
|
+
process.exit(1);
|
|
641
|
+
}
|
|
642
|
+
await fallBack(hostOam, failed, true);
|
|
643
|
+
});
|
|
644
|
+
} else {
|
|
645
|
+
const shim = findOamShim();
|
|
646
|
+
const notes = [
|
|
647
|
+
...(overrideNote ? [overrideNote] : []),
|
|
648
|
+
...skipped,
|
|
649
|
+
...(shim
|
|
650
|
+
? [
|
|
651
|
+
`found ${shim}, but Node cannot execute a .cmd/.bat directly -- install the native oam binary, or point OAM_BIN at one`,
|
|
652
|
+
]
|
|
653
|
+
: []),
|
|
654
|
+
];
|
|
655
|
+
if (mode === "oam") {
|
|
656
|
+
await errSync(
|
|
657
|
+
`lemonsqueezy-mcp: LEMONSQUEEZY_MCP_RUNTIME=oam but no usable oam (${OAM_MIN.join(".")} or newer) was found.\n` +
|
|
658
|
+
notes.map((note) => ` ${note}\n`).join("") +
|
|
659
|
+
"Install or update from https://oamjs.org, set OAM_BIN=/path/to/oam, or use LEMONSQUEEZY_MCP_RUNTIME=node.\n",
|
|
660
|
+
);
|
|
661
|
+
process.exit(1);
|
|
662
|
+
}
|
|
663
|
+
// auto: falling back is correct, but silence is how someone never learns
|
|
664
|
+
// their OAM_BIN is wrong or their oam is too old to use.
|
|
665
|
+
await fallBack(hostOam, notes.join("; ")).catch(fallbackFailed);
|
|
666
|
+
}
|
|
667
|
+
}
|