lensmcp 1.16.19 → 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 +110 -6
- 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');
|
|
@@ -366,12 +367,63 @@ function runGateway(ctx, args, out, err) {
|
|
|
366
367
|
const status = () => {
|
|
367
368
|
const { pid, healed } = reconcileGateway(pidFile, cwd);
|
|
368
369
|
const alive = pid !== undefined && isAlive(pid);
|
|
369
|
-
|
|
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');
|
|
370
424
|
out(` workspace → ${cfg.key}`);
|
|
371
425
|
out(` dashboard → ${dashUrl}`);
|
|
372
|
-
|
|
373
|
-
out(` logs → ${logFile}`);
|
|
374
|
-
return { exitCode: alive ? 0 : 1 };
|
|
426
|
+
return { exitCode: 1 };
|
|
375
427
|
};
|
|
376
428
|
switch (sub) {
|
|
377
429
|
case 'start':
|
|
@@ -572,6 +624,33 @@ function waitForPortFree(timeoutMs) {
|
|
|
572
624
|
spawnSync('sleep', ['0.15']);
|
|
573
625
|
}
|
|
574
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
|
+
}
|
|
575
654
|
function rmFile(path) {
|
|
576
655
|
try {
|
|
577
656
|
if (existsSync(path))
|
|
@@ -674,15 +753,40 @@ function runTrust(ctx, args, out, err) {
|
|
|
674
753
|
return { exitCode: 1 };
|
|
675
754
|
}
|
|
676
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;
|
|
677
762
|
if (!isInteractive()) {
|
|
678
763
|
out(' note: trust runs `sudo` for the CA + /etc/hosts — run it in a real terminal if a step is needed.');
|
|
679
764
|
}
|
|
680
|
-
|
|
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'})`);
|
|
681
770
|
const result = spawnSync(nxBin, ['run', `${target.project}:${target.target}`], {
|
|
682
771
|
cwd,
|
|
683
772
|
stdio: 'inherit',
|
|
684
|
-
env: { ...process.env, ...(ctx.env ?? {}) },
|
|
773
|
+
env: { ...process.env, ...(ctx.env ?? {}), ...(guestOfDaemon ? { LENSMCP_TRUST_HOSTS_ONLY: '1' } : {}) },
|
|
685
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
|
+
}
|
|
686
790
|
return { exitCode: result.status ?? 1 };
|
|
687
791
|
}
|
|
688
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"
|