lensmcp 1.16.18 → 1.16.20
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/lib/cli.js +160 -17
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
package/lib/cli.js
CHANGED
|
@@ -236,6 +236,7 @@ function buildProjectsMap(cwd) {
|
|
|
236
236
|
function runGateway(ctx, args, out, err) {
|
|
237
237
|
const opts = parseFlags(args, { string: ['--cwd', '--project', '--target'] });
|
|
238
238
|
const sub = (opts.positional[0] ?? 'status').toLowerCase();
|
|
239
|
+
const asJson = args.includes('--json'); // `gateway status --json` → machine-readable (the IDE plugin/scripts)
|
|
239
240
|
const cwd = resolve(stringFlag(opts.flags['--cwd']) ?? ctx.cwd);
|
|
240
241
|
const cfg = ensureLensConfig(cwd);
|
|
241
242
|
const pidFile = join(cwd, '.lensmcp', 'gateway.pid');
|
|
@@ -330,26 +331,99 @@ function runGateway(ctx, args, out, err) {
|
|
|
330
331
|
}
|
|
331
332
|
if (healed)
|
|
332
333
|
out(`healed: found an orphaned gateway (pid ${pid}) the pid file had lost — stopping it.`);
|
|
333
|
-
//
|
|
334
|
+
// GRACEFUL first: SIGTERM the whole process group (the daemon's handler reaps its children — pods +
|
|
335
|
+
// lens vites/dashboard/mcp — and exits, so `nx run` deregisters and the next `start` won't hang on the
|
|
336
|
+
// nx run-lock). Then WAIT for the port to actually free.
|
|
334
337
|
killGatewayTree(pid);
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
+
let hardKilled = false;
|
|
339
|
+
if (!waitForPortFree(6000)) {
|
|
340
|
+
// A child wedged (vite ignores SIGTERM) and is still holding :443. Escalate to a GROUP SIGKILL — that
|
|
341
|
+
// reaps the wedged vite AND its forks — then SIGKILL any straggler still on the port.
|
|
342
|
+
hardKilled = true;
|
|
343
|
+
killGatewayTree(pid, 'SIGKILL');
|
|
344
|
+
for (const p of pidsOnPort443()) {
|
|
345
|
+
try {
|
|
346
|
+
process.kill(p, 'SIGKILL');
|
|
347
|
+
}
|
|
348
|
+
catch { /* already gone */ }
|
|
349
|
+
}
|
|
350
|
+
waitForPortFree(3000);
|
|
351
|
+
}
|
|
352
|
+
// A differently-parented lens gateway may still hold :443 (a desync tree) — force it too.
|
|
338
353
|
const still = gatewayOnPort443(cwd);
|
|
339
|
-
if (still !== undefined && still !== pid)
|
|
340
|
-
killGatewayTree(still);
|
|
341
|
-
|
|
354
|
+
if (still !== undefined && still !== pid) {
|
|
355
|
+
killGatewayTree(still, 'SIGKILL');
|
|
356
|
+
hardKilled = true;
|
|
357
|
+
}
|
|
358
|
+
rmFile(pidFile);
|
|
359
|
+
// A hard SIGKILL means the `nx run gateway:serve` process died WITHOUT deregistering from the nx daemon,
|
|
360
|
+
// so the next `start` would block on "Waiting for gateway:serve in another nx process". Clear the stale
|
|
361
|
+
// nx daemon state (best-effort) so a subsequent start is clean. The graceful path skips this (fast).
|
|
362
|
+
if (hardKilled)
|
|
363
|
+
refreshNxGraph(cwd, ctx.env, () => { });
|
|
364
|
+
out(`gateway stopped (pid ${pid})${hardKilled ? ' — forced (a child ignored SIGTERM)' : ''}.`);
|
|
342
365
|
return { exitCode: 0 };
|
|
343
366
|
};
|
|
344
367
|
const status = () => {
|
|
345
368
|
const { pid, healed } = reconcileGateway(pidFile, cwd);
|
|
346
369
|
const alive = pid !== undefined && isAlive(pid);
|
|
347
|
-
|
|
370
|
+
// Machine-readable status for the IDE plugin / scripts: the daemon's rich /status (identity + workspaces
|
|
371
|
+
// + per-service live state) plus THIS workspace's role + the dashboard URL. Degrades to a minimal object
|
|
372
|
+
// when no daemon is reachable, so a consumer always gets valid JSON.
|
|
373
|
+
if (asJson) {
|
|
374
|
+
const s = daemonRequest('GET', '/status');
|
|
375
|
+
const daemonStatus = s?.status === 200 && s.json && typeof s.json === 'object' ? s.json : null;
|
|
376
|
+
const registered = existsSync(registeredMarker);
|
|
377
|
+
const role = alive ? 'daemon' : daemonStatus && registered ? 'guest' : daemonStatus ? 'other' : 'stopped';
|
|
378
|
+
out(JSON.stringify({
|
|
379
|
+
thisWorkspace: cfg.key,
|
|
380
|
+
role,
|
|
381
|
+
dashboardUrl: dashUrl,
|
|
382
|
+
chooserUrl: 'https://lensmcp.local/',
|
|
383
|
+
logFile,
|
|
384
|
+
daemon: daemonStatus?.['daemon'] ?? null,
|
|
385
|
+
workspaces: daemonStatus?.['workspaces'] ?? [],
|
|
386
|
+
services: daemonStatus?.['services'] ?? [],
|
|
387
|
+
}));
|
|
388
|
+
return { exitCode: alive || (daemonStatus && registered) ? 0 : 1 };
|
|
389
|
+
}
|
|
390
|
+
// If a shared daemon is up, GET /list shows the FULL multi-workspace picture (this one may be a guest).
|
|
391
|
+
const listRes = daemonRequest('GET', '/list');
|
|
392
|
+
const workspaces = listRes?.status === 200 && listRes.json && typeof listRes.json === 'object'
|
|
393
|
+
? (listRes.json.workspaces ?? [])
|
|
394
|
+
: [];
|
|
395
|
+
const printWorkspaces = () => {
|
|
396
|
+
if (workspaces.length === 0)
|
|
397
|
+
return;
|
|
398
|
+
out(` workspaces (${workspaces.length} registered):`);
|
|
399
|
+
for (const w of workspaces) {
|
|
400
|
+
out(` • ${w.key} (${w.routes ?? 0} routes, ${w.services ?? 0} services)${w.key === cfg.key ? ' ← this workspace' : ''}`);
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
if (alive) {
|
|
404
|
+
// This workspace's own pid owns :443 → it IS the daemon (it may be hosting other workspaces too).
|
|
405
|
+
out(`gateway: running (pid ${pid}) — DAEMON on :443${healed ? ' — recovered a stale pid file' : ''}`);
|
|
406
|
+
out(` workspace → ${cfg.key}`);
|
|
407
|
+
out(` dashboard → ${dashUrl}`);
|
|
408
|
+
out(` chooser → https://lensmcp.local/`);
|
|
409
|
+
out(` logs → ${logFile}`);
|
|
410
|
+
printWorkspaces();
|
|
411
|
+
return { exitCode: 0 };
|
|
412
|
+
}
|
|
413
|
+
// Not our own gateway — but are we a GUEST registered into a shared daemon someone else owns?
|
|
414
|
+
if (existsSync(registeredMarker) && listRes?.status === 200) {
|
|
415
|
+
const host = pidsOnPort443()[0];
|
|
416
|
+
out(`gateway: registered into a shared daemon${host !== undefined ? ` (pid ${host})` : ''} — this workspace is a guest`);
|
|
417
|
+
out(` workspace → ${cfg.key}`);
|
|
418
|
+
out(` dashboard → ${dashUrl}`);
|
|
419
|
+
out(' stop → `lensmcp gateway stop` (unregisters; the daemon keeps running)');
|
|
420
|
+
printWorkspaces();
|
|
421
|
+
return { exitCode: 0 };
|
|
422
|
+
}
|
|
423
|
+
out('gateway: stopped');
|
|
348
424
|
out(` workspace → ${cfg.key}`);
|
|
349
425
|
out(` dashboard → ${dashUrl}`);
|
|
350
|
-
|
|
351
|
-
out(` logs → ${logFile}`);
|
|
352
|
-
return { exitCode: alive ? 0 : 1 };
|
|
426
|
+
return { exitCode: 1 };
|
|
353
427
|
};
|
|
354
428
|
switch (sub) {
|
|
355
429
|
case 'start':
|
|
@@ -519,20 +593,64 @@ function reconcileGateway(pidFile, cwd) {
|
|
|
519
593
|
}
|
|
520
594
|
return { healed: false };
|
|
521
595
|
}
|
|
522
|
-
/** Best-effort kill of a detached gateway process GROUP (the leader is its own group via `detached`).
|
|
523
|
-
|
|
596
|
+
/** Best-effort kill of a detached gateway process GROUP (the leader is its own group via `detached`).
|
|
597
|
+
* `signal` defaults to SIGTERM (graceful — the daemon's handler reaps its children + exits, so `nx run`
|
|
598
|
+
* deregisters cleanly); a caller escalates to SIGKILL when a wedged child (vite ignores SIGTERM) keeps the
|
|
599
|
+
* port held. Killing the GROUP (`-pid`) reaps the daemon + `nx run` + every child + their forks at once. */
|
|
600
|
+
function killGatewayTree(pid, signal = 'SIGTERM') {
|
|
524
601
|
try {
|
|
525
|
-
process.kill(-pid,
|
|
602
|
+
process.kill(-pid, signal);
|
|
526
603
|
}
|
|
527
604
|
catch {
|
|
528
605
|
try {
|
|
529
|
-
process.kill(pid,
|
|
606
|
+
process.kill(pid, signal);
|
|
530
607
|
}
|
|
531
608
|
catch {
|
|
532
609
|
/* already gone */
|
|
533
610
|
}
|
|
534
611
|
}
|
|
535
612
|
}
|
|
613
|
+
/** Poll (via lsof) until :443 is free or the timeout elapses. Returns true once free. Unix-only; on a
|
|
614
|
+
* platform without lsof `pidsOnPort443()` returns [] so this resolves free immediately (unchanged path). */
|
|
615
|
+
function waitForPortFree(timeoutMs) {
|
|
616
|
+
const deadline = Date.now() + timeoutMs;
|
|
617
|
+
for (;;) {
|
|
618
|
+
if (pidsOnPort443().length === 0)
|
|
619
|
+
return true;
|
|
620
|
+
if (Date.now() >= deadline)
|
|
621
|
+
return false;
|
|
622
|
+
// Busy-wait in ~150ms slices (a stop/restart command, not a hot path) — spawnSync('sleep') keeps it
|
|
623
|
+
// synchronous so the CLI result reflects the real port state before returning.
|
|
624
|
+
spawnSync('sleep', ['0.15']);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
/** Probe whether the cert served for `host` is trusted by the SYSTEM store — the ACCURATE check for a
|
|
628
|
+
* daemon-served workspace (whose OWN local CA is unused, so `trust`'s file-based verify falsely says "NOT
|
|
629
|
+
* TRUSTED"). Sync via curl: `ssl_verify_result` 0 + a completed handshake = trusted; a non-0 verify = the
|
|
630
|
+
* cert was rejected; otherwise the host was unreachable. */
|
|
631
|
+
function probeHostTrust(host) {
|
|
632
|
+
const r = spawnSync('curl', ['-s', '-o', '/dev/null', '-m', '8', '-w', '%{ssl_verify_result}', `https://${host}/`], { encoding: 'utf8' });
|
|
633
|
+
const verify = (r.stdout ?? '').trim();
|
|
634
|
+
if (r.status === 0 && verify === '0')
|
|
635
|
+
return 'trusted';
|
|
636
|
+
if (verify && verify !== '0')
|
|
637
|
+
return 'untrusted';
|
|
638
|
+
return 'unknown';
|
|
639
|
+
}
|
|
640
|
+
/** The first non-wildcard `cluster.host` declared in the workspace — a workspace-specific host to
|
|
641
|
+
* trust-probe (better than the shared `lensmcp.local`, which both workspaces route). */
|
|
642
|
+
function firstClusterHost(cwd) {
|
|
643
|
+
for (const file of walkProjectFiles(cwd, (n) => n === 'project.json')) {
|
|
644
|
+
try {
|
|
645
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
646
|
+
const h = (raw.cluster ?? raw.davnx)?.host;
|
|
647
|
+
if (h && !h.includes('*'))
|
|
648
|
+
return h;
|
|
649
|
+
}
|
|
650
|
+
catch { /* skip unreadable project.json */ }
|
|
651
|
+
}
|
|
652
|
+
return undefined;
|
|
653
|
+
}
|
|
536
654
|
function rmFile(path) {
|
|
537
655
|
try {
|
|
538
656
|
if (existsSync(path))
|
|
@@ -635,15 +753,40 @@ function runTrust(ctx, args, out, err) {
|
|
|
635
753
|
return { exitCode: 1 };
|
|
636
754
|
}
|
|
637
755
|
const target = findTrustTarget(cwd, stringFlag(opts.flags['--project']), stringFlag(opts.flags['--target']));
|
|
756
|
+
// Daemon-awareness: if a shared daemon owns :443 and this workspace is a GUEST (its gateway isn't the one
|
|
757
|
+
// on :443), the daemon serves every host with ITS (already-trusted) CA — this workspace's own CA is unused.
|
|
758
|
+
// The full trust here would mint a pointless second CA and, worse, report "NOT TRUSTED YET" from verifying
|
|
759
|
+
// that unused CA file (the papercut). So run HOSTS-ONLY (/etc/hosts + DNS) and report the REAL served-cert
|
|
760
|
+
// trust instead.
|
|
761
|
+
const guestOfDaemon = daemonRequest('GET', '/list')?.status === 200 && gatewayOnPort443(cwd) === undefined;
|
|
638
762
|
if (!isInteractive()) {
|
|
639
763
|
out(' note: trust runs `sudo` for the CA + /etc/hosts — run it in a real terminal if a step is needed.');
|
|
640
764
|
}
|
|
641
|
-
|
|
765
|
+
if (guestOfDaemon) {
|
|
766
|
+
out(' a shared gateway daemon serves this workspace — TLS rides ITS (already-trusted) CA, not a separate');
|
|
767
|
+
out(" one here. Setting up /etc/hosts only (trusting the CA is the DAEMON workspace's job).");
|
|
768
|
+
}
|
|
769
|
+
out(`> ${nxBin} run ${target.project}:${target.target} (sudo prompts inline for ${guestOfDaemon ? '/etc/hosts' : 'the CA + /etc/hosts'})`);
|
|
642
770
|
const result = spawnSync(nxBin, ['run', `${target.project}:${target.target}`], {
|
|
643
771
|
cwd,
|
|
644
772
|
stdio: 'inherit',
|
|
645
|
-
env: { ...process.env, ...(ctx.env ?? {}) },
|
|
773
|
+
env: { ...process.env, ...(ctx.env ?? {}), ...(guestOfDaemon ? { LENSMCP_TRUST_HOSTS_ONLY: '1' } : {}) },
|
|
646
774
|
});
|
|
775
|
+
// For a guest the executor's own (file-based, misleading) CA check is suppressed — report the ACCURATE
|
|
776
|
+
// trust of the daemon's SERVED cert via a real system-store TLS probe, so the verdict is truthful.
|
|
777
|
+
if (guestOfDaemon && result.status === 0) {
|
|
778
|
+
const host = firstClusterHost(cwd);
|
|
779
|
+
if (host) {
|
|
780
|
+
const verdict = probeHostTrust(host);
|
|
781
|
+
out('');
|
|
782
|
+
if (verdict === 'trusted')
|
|
783
|
+
out(` ✓ ${host} → served by the daemon with a TRUSTED cert — you're set (restart the browser once if it cached an error).`);
|
|
784
|
+
else if (verdict === 'untrusted')
|
|
785
|
+
out(` ✗ ${host} → the daemon's cert is NOT trusted yet. Run \`lensmcp trust\` in the DAEMON workspace (it owns the CA), then restart your browser.`);
|
|
786
|
+
else
|
|
787
|
+
out(` ? ${host} → couldn't reach it to verify (is the daemon running?). /etc/hosts is set; TLS rides the daemon's CA.`);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
647
790
|
return { exitCode: result.status ?? 1 };
|
|
648
791
|
}
|
|
649
792
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lensmcp",
|
|
3
|
-
"version": "1.16.
|
|
3
|
+
"version": "1.16.20",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./index.js",
|
|
6
6
|
"module": "./index.js",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
}
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@frontmcp/sdk": "^1.4
|
|
20
|
+
"@frontmcp/sdk": "^1.5.4",
|
|
21
21
|
"reflect-metadata": "^0.2.2",
|
|
22
22
|
"tslib": "^2.3.0",
|
|
23
23
|
"vectoriadb": "^2.2.0"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "lensmcp",
|
|
3
3
|
"displayName": "LensMCP",
|
|
4
4
|
"description": "The observability lens for coding agents. One command brings up the dev cluster gateway (every project.json `cluster` decl → its host on :443), the per-project lens dashboard at https://lensmcp.local/<project>/, and the MCP server your agent connects to — scoped automatically to whatever project you opened Claude Code in.",
|
|
5
|
-
"version": "1.16.
|
|
5
|
+
"version": "1.16.20",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "David Antoon",
|
|
8
8
|
"email": "davidmantoon@gmail.com"
|