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 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
- out(`gateway: ${alive ? `running (pid ${pid})${healed ? ' — recovered a stale pid file' : ''}` : 'stopped'}`);
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
- if (alive)
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
- out(`> ${nxBin} run ${target.project}:${target.target} (sudo prompts inline for the CA + /etc/hosts)`);
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.19",
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.1",
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.19",
5
+ "version": "1.16.20",
6
6
  "author": {
7
7
  "name": "David Antoon",
8
8
  "email": "davidmantoon@gmail.com"