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 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
- // Detached child is its own process-group leader negative pid kills the whole tree.
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
- rmFile(pidFile);
336
- // Escalate: if a lens gateway STILL holds :443 (a differently-parented tree), kill that too, so
337
- // `stop` genuinely frees the port instead of leaving a wedged listener behind.
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
- out(`gateway stopped (pid ${pid}).`);
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
- 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');
348
424
  out(` workspace → ${cfg.key}`);
349
425
  out(` dashboard → ${dashUrl}`);
350
- if (alive)
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
- function killGatewayTree(pid) {
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, 'SIGTERM');
602
+ process.kill(-pid, signal);
526
603
  }
527
604
  catch {
528
605
  try {
529
- process.kill(pid, 'SIGTERM');
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
- 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'})`);
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.18",
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.18",
5
+ "version": "1.16.20",
6
6
  "author": {
7
7
  "name": "David Antoon",
8
8
  "email": "davidmantoon@gmail.com"