lensmcp 1.16.28 → 1.16.30

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.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/lib/cli.ts"],"names":[],"mappings":"AAQA,MAAM,WAAW,SAAS;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,UAAU,UAAU;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,4EAA4E;IAC5E,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,qDAAqD;IACrD,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,gEAAgE;IAChE,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7B,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAC9B;AAqFD,wBAAsB,MAAM,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CA4ChE;AA0lBD,MAAM,MAAM,mBAAmB,GAAG,SAAS,GAAG,SAAS,GAAG,QAAQ,CAAC;AAEnE;;;;;;;;iFAQiF;AACjF,wBAAgB,oBAAoB,CAAC,CAAC,EAAE;IACtC,SAAS,EAAE,OAAO,CAAC;IACnB,WAAW,EAAE,OAAO,CAAC;IACrB,YAAY,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,GAAG,mBAAmB,CAItB"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/lib/cli.ts"],"names":[],"mappings":"AAQA,MAAM,WAAW,SAAS;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,UAAU,UAAU;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,4EAA4E;IAC5E,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,qDAAqD;IACrD,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,gEAAgE;IAChE,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7B,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAC9B;AA6FD,wBAAsB,MAAM,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CA4ChE;AAynBD,MAAM,MAAM,mBAAmB,GAAG,SAAS,GAAG,SAAS,GAAG,QAAQ,CAAC;AAEnE;;;;;;;;iFAQiF;AACjF,wBAAgB,oBAAoB,CAAC,CAAC,EAAE;IACtC,SAAS,EAAE,OAAO,CAAC;IACnB,WAAW,EAAE,OAAO,CAAC;IACrB,YAAY,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,GAAG,mBAAmB,CAItB"}
package/lib/cli.js CHANGED
@@ -2,7 +2,7 @@ import { spawn, spawnSync } from 'node:child_process';
2
2
  import { existsSync, mkdirSync, openSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { request as httpRequest } from 'node:http';
4
4
  import { homedir, tmpdir } from 'node:os';
5
- import { basename, dirname, join, relative, resolve } from 'node:path';
5
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { ensureLensConfig } from './workspace-scope.js';
8
8
  const HELP = `lensmcp — CLI for LensMCP (FrontMCP-based observability for coding agents)
@@ -12,10 +12,16 @@ Usage:
12
12
 
13
13
  Commands:
14
14
  setup [--cwd <dir>] [--skip-format] [--register-host-config <mode>]
15
- First-time bootstrap — runs \`install\` then \`trust\` in
15
+ First-time bootstrap — \`install\` \`trust\` → \`pods\` in
16
16
  one idempotent pass: plugin + .mcp.json + nx config +
17
17
  vite/nest wiring, then the dev CA (System keychain),
18
- /etc/hosts (both families) + DNS flush. sudo self-prompts
18
+ /etc/hosts (both families) + DNS flush, then a recycle of
19
+ THIS workspace's devserver pods (they would otherwise keep
20
+ serving the wiring setup just replaced) plus a gateway
21
+ reconcile — nx graph refresh, adopt an orphaned :443,
22
+ register this workspace into a running daemon. A running
23
+ gateway is NEVER restarted (the :443 door is shared) and
24
+ other workspaces' pods are never touched. sudo self-prompts
19
25
  only for the steps that need it — run in a real terminal.
20
26
  Then \`lensmcp gateway start\`.
21
27
  mcp [--cwd <dir>] [--transport stdio|http] [--port <n>]
@@ -68,14 +74,16 @@ Commands:
68
74
  promote, abort, add-rule, remove-rule, status. Validated +
69
75
  atomic write; the prod gateway picks it up live. Run
70
76
  \`lensmcp rollout\` (no args) for the op list.
71
- logs [service] [--tail <n>] [--no-follow] [--plain] [--cwd <dir>]
77
+ logs [service] [--tail <n>] [--no-follow] [--plain] [--color] [--cwd <dir>]
72
78
  Attach this terminal to a running service's live log
73
79
  stream (docker-logs style — for humans and agents alike).
74
80
  No <service> lists every attachable service across
75
81
  workspaces. --tail <n> replays that many history lines
76
82
  first (default 100); --no-follow prints them and exits
77
83
  (the agent-friendly snapshot); --plain strips ANSI color
78
- (automatic when output is piped). Needs no ports — it
84
+ (automatic when output is piped); --color FORCES color
85
+ through a pipe — for consumers that RENDER ANSI, like
86
+ the IDE plugin's Logs console. Needs no ports — it
79
87
  finds the devserver's control socket under $TMPDIR.
80
88
  debug <service> [--port <base>] [--cwd <dir>]
81
89
  Open every pod's Node inspector of a RUNNING service —
@@ -239,6 +247,15 @@ function daemonRequest(method, urlPath, body) {
239
247
  catch { /* non-JSON body */ }
240
248
  return Number.isFinite(status) ? { status, json } : null;
241
249
  }
250
+ /** The workspace key that OWNS the running daemon (undefined when no daemon answers) — used to name the
251
+ * workspace a guest must run privileged/workspace-scoped work (`trust`) in. */
252
+ function daemonWsKey() {
253
+ const s = daemonRequest('GET', '/status');
254
+ if (s?.status !== 200 || !s.json || typeof s.json !== 'object')
255
+ return undefined;
256
+ const d = s.json.daemon;
257
+ return typeof d?.wsKey === 'string' && d.wsKey.length > 0 ? d.wsKey : undefined;
258
+ }
242
259
  /** The nx projects map (name → workspace-relative root) for cluster discovery — built by scanning
243
260
  * project.json files, the same walk `findGatewayTarget` uses. Sent to the daemon so it can `discoverRoutes`
244
261
  * this workspace when it registers. */
@@ -336,8 +353,17 @@ function runGateway(ctx, args, out, err) {
336
353
  }
337
354
  const target = findGatewayTarget(cwd, stringFlag(opts.flags['--project']), stringFlag(opts.flags['--target']));
338
355
  if (!target) {
339
- err('Could not find a gateway target (an executor `@lensmcp/cluster:gateway`) in this workspace.\n' +
340
- 'Pass --project <p> --target <t>, or add a gateway project (see @lensmcp/cluster).');
356
+ // This workspace hosts no gateway of its own. That is a NORMAL state for a guest workspace — it just
357
+ // means there is nothing on :443 to join right now. Say what to actually do, in order of likelihood,
358
+ // instead of only naming the two flags (the papercut the IDE plugin surfaced verbatim).
359
+ err(`This workspace ('${cfg.key}') has no gateway target — no project declares the`);
360
+ err('`@lensmcp/cluster:gateway` executor, so there is nothing here to launch on :443.');
361
+ err('');
362
+ err(' • Joining a shared gateway? Start it in the workspace that OWNS it, then run');
363
+ err(' `lensmcp gateway start` here — this workspace registers into it (no local target needed).');
364
+ err(' • Should this workspace host its own gateway? Scaffold it: `lensmcp install`');
365
+ err(' (or `lensmcp setup` for the full install + trust bootstrap).');
366
+ err(' • Already have a differently-named target? `lensmcp gateway start --project <p> --target <t>`.');
341
367
  return { exitCode: 1 };
342
368
  }
343
369
  const nxBin = findNxBinary(cwd);
@@ -586,14 +612,34 @@ function findGatewayTarget(cwd, project, target) {
586
612
  // Sensible fallback used by most workspaces (tetros: tools/gateway → `gateway:serve`).
587
613
  return project || target ? { project: project ?? 'gateway', target: target ?? 'serve' } : undefined;
588
614
  }
589
- /** Find a project + target whose executor is `@lensmcp/cluster:trust`. Falls back
590
- * unconditionally to `gateway:trust` — the conventional home the init generator
591
- * scaffolds — since trust is idempotent and safe to attempt anywhere. */
615
+ /**
616
+ * Find a project + target whose executor is `@lensmcp/cluster:trust`. Falls back to `gateway:trust` — the
617
+ * conventional home the init generator scaffolds — but ONLY when a project named `gateway` actually exists
618
+ * (an explicit `--project` is always taken at face value). The old unconditional fallback made `trust` in a
619
+ * workspace that never ran `lensmcp install` shell out to `nx run gateway:trust` and die on Nx's opaque
620
+ * "Cannot find project 'gateway'"; returning undefined lets the caller say what to do instead.
621
+ */
592
622
  function findTrustTarget(cwd, project, target) {
593
- return (scanExecutorTarget(cwd, '@lensmcp/cluster:trust', project, target) ?? {
594
- project: project ?? 'gateway',
595
- target: target ?? 'trust',
596
- });
623
+ const found = scanExecutorTarget(cwd, '@lensmcp/cluster:trust', project, target);
624
+ if (found)
625
+ return found;
626
+ if (project)
627
+ return { project, target: target ?? 'trust' };
628
+ return projectExists(cwd, 'gateway') ? { project: 'gateway', target: target ?? 'trust' } : undefined;
629
+ }
630
+ /** Does a project of this name exist in the workspace? (project.json-declared, like every other scan here.) */
631
+ function projectExists(cwd, name) {
632
+ for (const file of walkProjectFiles(cwd, (n) => n === 'project.json')) {
633
+ try {
634
+ const parsed = JSON.parse(safeRead(file));
635
+ if ((parsed.name ?? basename(dirname(file))) === name)
636
+ return true;
637
+ }
638
+ catch {
639
+ /* unreadable project.json — not a match */
640
+ }
641
+ }
642
+ return false;
597
643
  }
598
644
  function readPid(pidFile) {
599
645
  try {
@@ -867,6 +913,26 @@ function runTrust(ctx, args, out, err) {
867
913
  // that unused CA file (the papercut). So run HOSTS-ONLY (/etc/hosts + DNS) and report the REAL served-cert
868
914
  // trust instead.
869
915
  const guestOfDaemon = daemonRequest('GET', '/list')?.status === 200 && gatewayOnPort443(cwd) === undefined;
916
+ // No trust target anywhere in this workspace (never ran `lensmcp install`). Trust is workspace-scoped work
917
+ // — minting the CA and writing /etc/hosts for THIS workspace's cluster hosts — so there is nothing to run
918
+ // here. Say which workspace owns the CA instead of failing inside Nx with "Cannot find project 'gateway'".
919
+ if (!target) {
920
+ err(`This workspace has no trust target — no project declares the \`@lensmcp/cluster:trust\` executor.`);
921
+ err('');
922
+ if (guestOfDaemon) {
923
+ const owner = daemonWsKey();
924
+ err(` A shared gateway daemon${owner ? ` (owned by '${owner}')` : ''} already serves this workspace, and`);
925
+ err(' TLS rides ITS certificate authority. Run `lensmcp trust` in THAT workspace — it owns the CA —');
926
+ err(' and this workspace is trusted too (restart the browser once if it cached a cert error).');
927
+ }
928
+ else {
929
+ err(' • Should this workspace own its gateway (and its CA)? Scaffold it first: `lensmcp install`,');
930
+ err(' then re-run `lensmcp trust`.');
931
+ err(' • Joining a shared gateway? Run `lensmcp trust` in the workspace that owns the daemon.');
932
+ }
933
+ err(' • Already have a differently-named target? `lensmcp trust --project <p> --target <t>`.');
934
+ return { exitCode: 1 };
935
+ }
870
936
  if (!isInteractive()) {
871
937
  out(' note: trust runs `sudo` for the CA + /etc/hosts — run it in a real terminal if a step is needed.');
872
938
  }
@@ -920,8 +986,10 @@ function writeSetupState(cwd) {
920
986
  }
921
987
  /**
922
988
  * `lensmcp setup` — first-time bootstrap. Runs `install` (plugin + .mcp.json +
923
- * nx config + vite/nest wiring, no sudo) then `trust` (dev CA → keychain,
924
- * /etc/hosts, DNS flush, sudo). Both steps are idempotent, so re-running is safe.
989
+ * nx config + vite/nest wiring, no sudo), then `trust` (dev CA → keychain,
990
+ * /etc/hosts, DNS flush, sudo), then `pods` which recycles this workspace's
991
+ * devservers and reconciles the gateway, so nothing keeps serving the wiring
992
+ * that setup just replaced. Every step is idempotent, so re-running is safe.
925
993
  * Flags are forwarded to whichever sub-command understands them (`--skip-format`,
926
994
  * `--register-host-config` → install; `--project`/`--target` → trust).
927
995
  */
@@ -929,9 +997,9 @@ function runSetup(ctx, args, out, err) {
929
997
  const opts = parseFlags(args, { string: ['--cwd'] });
930
998
  const cwd = resolve(stringFlag(opts.flags['--cwd']) ?? ctx.cwd);
931
999
  const subCtx = { ...ctx, cwd };
932
- out('lensmcp setup — first-time bootstrap (install → trust)');
1000
+ out('lensmcp setup — first-time bootstrap (install → trust → pods)');
933
1001
  out('');
934
- out('[1/2] install — plugin, .mcp.json, nx config, vite/nest wiring');
1002
+ out('[1/3] install — plugin, .mcp.json, nx config, vite/nest wiring');
935
1003
  const installRes = runInstall(subCtx, args, out, err);
936
1004
  if (installRes.exitCode !== 0) {
937
1005
  err('');
@@ -939,17 +1007,31 @@ function runSetup(ctx, args, out, err) {
939
1007
  return installRes;
940
1008
  }
941
1009
  out('');
942
- out('[2/2] trust — dev CA → System keychain, /etc/hosts, DNS flush (sudo)');
1010
+ out('[2/3] trust — dev CA → System keychain, /etc/hosts, DNS flush (sudo)');
943
1011
  const trustRes = runTrust(subCtx, args, out, err);
944
1012
  if (trustRes.exitCode !== 0) {
945
1013
  err('');
946
1014
  err('setup: the trust step failed — run `lensmcp setup` directly in a terminal so sudo can prompt.');
947
1015
  return trustRes;
948
1016
  }
949
- // Both steps succeeded mark the workspace set up for this version (opens the MCP gate).
1017
+ // Install + trust rewrote this workspace's wiring, but a devserver that has been up since before it
1018
+ // changed goes on serving the OLD bundle from its pooled pods — the "setup said ✓ but the app is
1019
+ // unchanged" trap. Recycle them, then put the gateway back in line with reality WITHOUT restarting it
1020
+ // (the :443 door is shared: bouncing it would take every other workspace down with this one).
1021
+ out('');
1022
+ out("[3/3] pods — recycle this workspace's devservers, reconcile the gateway");
1023
+ const key = ensureLensConfig(cwd).key;
1024
+ const reaped = reapWorkspacePods(cwd, key, out);
1025
+ reconcileGatewayAfterSetup(subCtx, cwd, key, out);
1026
+ // All steps succeeded → mark the workspace set up for this version (opens the MCP gate).
950
1027
  writeSetupState(cwd);
951
1028
  out('');
952
1029
  out('✓ setup complete.');
1030
+ if (reaped.killed > 0 || reaped.stale > 0) {
1031
+ out(` recycled ${reaped.killed} pod${reaped.killed === 1 ? '' : 's'}` +
1032
+ (reaped.stale > 0 ? ` (+${reaped.stale} stale socket${reaped.stale === 1 ? '' : 's'} cleared)` : '') +
1033
+ ' — they come back on the new wiring.');
1034
+ }
953
1035
  out(' next: lensmcp gateway start (the :443 front door — needs sudo)');
954
1036
  out(' verify: lensmcp doctor');
955
1037
  return { exitCode: 0 };
@@ -1309,6 +1391,179 @@ function discoverDevserverSocks() {
1309
1391
  return found.sort((a, b) => `${a.wsKey}/${a.service}`.localeCompare(`${b.wsKey}/${b.service}`));
1310
1392
  }
1311
1393
  const fullServiceName = (d) => (d.wsKey ? `${d.wsKey}/${d.service}` : d.service);
1394
+ /* ───────── pod recycling (the `setup` reap) ─────────
1395
+ * A devserver is a PARENT process holding `parent.sock` plus N forked child pods. Killing the parent is
1396
+ * enough for the pods — they exit on the IPC `disconnect` (main.devserver.ts's ORPHAN GUARD) and unlink
1397
+ * their own sockets. But the gateway spawns each service `detached: true` with `NX_DAEMON=false`, so the
1398
+ * whole service — the `nx run` shim, the devserver, the pods — sits in ONE process group of its own; group-
1399
+ * killing that reaps the shim too, which a lone parent kill would leave behind (the documented "nx wrapper
1400
+ * outlives its child" leak). Hence: kill the group when we can prove it's the service's own, else the pid. */
1401
+ /** The pid holding a unix socket (best-effort via lsof; undefined = the socket is stale litter). */
1402
+ function pidOnSocket(sock) {
1403
+ try {
1404
+ const r = spawnSync('lsof', ['-t', sock], { encoding: 'utf8' });
1405
+ const pid = Number((r.stdout ?? '').split('\n')[0]?.trim());
1406
+ return Number.isInteger(pid) && pid > 0 ? pid : undefined;
1407
+ }
1408
+ catch {
1409
+ return undefined;
1410
+ }
1411
+ }
1412
+ /** A process's group id (undefined when it's gone or `ps` is unavailable). */
1413
+ function pgidOf(pid) {
1414
+ try {
1415
+ const r = spawnSync('ps', ['-o', 'pgid=', '-p', String(pid)], { encoding: 'utf8' });
1416
+ const pgid = Number((r.stdout ?? '').trim());
1417
+ return Number.isInteger(pgid) && pgid > 1 ? pgid : undefined;
1418
+ }
1419
+ catch {
1420
+ return undefined;
1421
+ }
1422
+ }
1423
+ /** A process's working directory — how an UN-NAMESPACED devserver (a plain `nx serve`, no LENSMCP_WS_KEY)
1424
+ * gets attributed to a workspace. Undefined when it can't be read, and an unattributable pod is left
1425
+ * alone: it belongs to somebody else until proven otherwise. */
1426
+ function cwdOfPid(pid) {
1427
+ try {
1428
+ const r = spawnSync('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { encoding: 'utf8' });
1429
+ const line = (r.stdout ?? '').split('\n').find((l) => l.startsWith('n'));
1430
+ return line ? line.slice(1).trim() || undefined : undefined;
1431
+ }
1432
+ catch {
1433
+ return undefined;
1434
+ }
1435
+ }
1436
+ /** Is `child` the same path as, or inside, `parent`? */
1437
+ function isInside(parent, child) {
1438
+ const rel = relative(resolve(parent), resolve(child));
1439
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
1440
+ }
1441
+ /** Every devserver under $TMPDIR that belongs to the workspace rooted at `cwd`. A namespaced sock dir
1442
+ * (`$TMPDIR/<wsKey>/…`) is attributed by its key; a legacy un-namespaced one by its owner's cwd. */
1443
+ function workspaceDevservers(cwd, key) {
1444
+ return discoverDevserverSocks().filter((d) => {
1445
+ if (d.wsKey)
1446
+ return d.wsKey === key;
1447
+ const pid = pidOnSocket(d.sock);
1448
+ const owner = pid !== undefined ? cwdOfPid(pid) : undefined;
1449
+ return owner !== undefined && isInside(cwd, owner);
1450
+ });
1451
+ }
1452
+ /**
1453
+ * Stop every devserver of THIS workspace and clear its socket dir, so the next boot comes up on the
1454
+ * freshly installed wiring instead of a pod that has been running since before it changed.
1455
+ *
1456
+ * Safe next to a live gateway: it marks a vanished service `down` and either self-heals it or scales it
1457
+ * from zero on the next request, so the front door keeps serving while the pods come back new. NEVER
1458
+ * group-kills a group it can't prove is the service's own — a mis-derived group would take down the
1459
+ * gateway (or this CLI) with it.
1460
+ */
1461
+ function reapWorkspacePods(cwd, key, out) {
1462
+ const mine = workspaceDevservers(cwd, key);
1463
+ if (mine.length === 0) {
1464
+ out(' no devserver pods running for this workspace — nothing to recycle.');
1465
+ return { killed: 0, stale: 0 };
1466
+ }
1467
+ // Groups we must never signal: our own, and whatever holds :443 (the gateway must survive this).
1468
+ const forbidden = new Set();
1469
+ for (const p of [process.pid, ...pidsOnPort443()]) {
1470
+ const g = pgidOf(p);
1471
+ if (g !== undefined)
1472
+ forbidden.add(g);
1473
+ }
1474
+ let killed = 0;
1475
+ let stale = 0;
1476
+ for (const d of mine) {
1477
+ const name = fullServiceName(d);
1478
+ const pid = pidOnSocket(d.sock);
1479
+ if (pid === undefined) {
1480
+ rmFile(d.sock);
1481
+ out(` ✓ ${name} — stale socket removed (no process held it)`);
1482
+ stale++;
1483
+ continue;
1484
+ }
1485
+ const group = pgidOf(pid);
1486
+ const groupKillable = group !== undefined && !forbidden.has(group);
1487
+ const signal = (sig) => {
1488
+ try {
1489
+ if (groupKillable)
1490
+ process.kill(-group, sig);
1491
+ else
1492
+ process.kill(pid, sig); // pods follow the parent out via the IPC disconnect guard
1493
+ }
1494
+ catch {
1495
+ /* already gone */
1496
+ }
1497
+ };
1498
+ signal('SIGTERM');
1499
+ // Give the tree a moment to unwind gracefully (the devserver closes its pods' IPC, they unlink their
1500
+ // own sockets), then escalate — a wedged child must not survive the recycle.
1501
+ if (!waitForPidGone(pid, 5000)) {
1502
+ signal('SIGKILL');
1503
+ waitForPidGone(pid, 2000);
1504
+ }
1505
+ rmFile(d.sock);
1506
+ out(` ✓ ${name} — stopped (pid ${pid}${groupKillable ? `, group ${group}` : ', parent only'})`);
1507
+ killed++;
1508
+ }
1509
+ return { killed, stale };
1510
+ }
1511
+ /** Poll until `pid` is gone or the timeout elapses. Synchronous — a setup step, not a hot path. */
1512
+ function waitForPidGone(pid, timeoutMs) {
1513
+ const deadline = Date.now() + timeoutMs;
1514
+ for (;;) {
1515
+ if (!isAlive(pid))
1516
+ return true;
1517
+ if (Date.now() >= deadline)
1518
+ return false;
1519
+ spawnSync('sleep', ['0.15']);
1520
+ }
1521
+ }
1522
+ /**
1523
+ * Bring the gateway back in line with reality WITHOUT restarting a running one — a `setup` must never take
1524
+ * the shared `:443` front door (and every other workspace on it) down. It:
1525
+ * • adopts an orphaned gateway the pid file lost track of (the desync self-heal);
1526
+ * • busts the nx project graph, so the projects `install` just wired resolve instead of 404ing out of a
1527
+ * daemon whose graph predates them;
1528
+ * • registers this workspace into a daemon someone else owns, when it isn't in there yet.
1529
+ * An already-registered workspace is left alone: re-registering makes the daemon tear down and respawn its
1530
+ * services and dashboard for nothing.
1531
+ */
1532
+ function reconcileGatewayAfterSetup(ctx, cwd, key, out) {
1533
+ const pidFile = join(cwd, '.lensmcp', 'gateway.pid');
1534
+ const registeredMarker = join(cwd, '.lensmcp', 'registered.json');
1535
+ const { pid, healed } = reconcileGateway(pidFile, cwd);
1536
+ if (healed)
1537
+ out(` healed: adopted a running gateway (pid ${pid}) the pid file had lost.`);
1538
+ refreshNxGraph(cwd, ctx.env, (l) => out(` ${l}`));
1539
+ const owned = gatewayOnPort443(cwd) !== undefined;
1540
+ if (owned) {
1541
+ out(` gateway: this workspace owns the :443 daemon (pid ${pid ?? '?'}) — left running, pods respawn fresh.`);
1542
+ return;
1543
+ }
1544
+ const list = daemonRequest('GET', '/list');
1545
+ if (list?.status !== 200) {
1546
+ out(' gateway: not running. Start it when you are ready: lensmcp gateway start');
1547
+ return;
1548
+ }
1549
+ const registered = list.json && typeof list.json === 'object'
1550
+ ? (list.json.workspaces ?? []).some((w) => w.key === key)
1551
+ : false;
1552
+ if (registered) {
1553
+ mkdirSync(dirname(registeredMarker), { recursive: true });
1554
+ writeFileSync(registeredMarker, JSON.stringify({ wsKey: key }));
1555
+ out(` gateway: '${key}' is already registered into the shared daemon — left serving.`);
1556
+ return;
1557
+ }
1558
+ const res = daemonRequest('POST', '/register', { wsKey: key, root: cwd, projects: buildProjectsMap(cwd) });
1559
+ if (res && res.status === 200) {
1560
+ mkdirSync(dirname(registeredMarker), { recursive: true });
1561
+ writeFileSync(registeredMarker, JSON.stringify({ wsKey: key }));
1562
+ out(` gateway: registered '${key}' into the shared daemon — it now hosts this workspace.`);
1563
+ return;
1564
+ }
1565
+ out(' gateway: a shared daemon is up but registering this workspace failed — run `lensmcp gateway start`.');
1566
+ }
1312
1567
  /** Resolve a service argument (`name` or `wsKey/name`) to ONE control socket. A bare
1313
1568
  * name running in several workspaces is disambiguated by the current workspace's key.
1314
1569
  * Prints the helpful error itself and returns null when nothing (or too much) matches. */
@@ -1340,7 +1595,7 @@ function resolveServiceSock(service, all, cwd, err) {
1340
1595
  async function runLogs(ctx, args, out, err) {
1341
1596
  const opts = parseFlags(args, {
1342
1597
  string: ['--tail', '--cwd'],
1343
- boolean: ['--no-follow', '--plain'],
1598
+ boolean: ['--no-follow', '--plain', '--color'],
1344
1599
  });
1345
1600
  const service = opts.positional[0];
1346
1601
  const all = discoverDevserverSocks();
@@ -1362,7 +1617,11 @@ async function runLogs(ctx, args, out, err) {
1362
1617
  const follow = opts.flags['--no-follow'] !== true;
1363
1618
  const tailRaw = Number(stringFlag(opts.flags['--tail']) ?? NaN);
1364
1619
  const tail = Number.isInteger(tailRaw) && tailRaw >= 0 ? tailRaw : 100; // 0 = live-only, no history
1365
- const plain = opts.flags['--plain'] === true || !process.stdout.isTTY;
1620
+ // ANSI is stripped when the output is piped — EXCEPT for a consumer that renders it and says so with
1621
+ // `--color` (the IDE plugin's Logs console runs through a pipe, and plain-stripped output is exactly the
1622
+ // unreadable wall of text the colored stream avoids). Explicit `--plain` still wins.
1623
+ const plain = opts.flags['--plain'] === true ||
1624
+ (opts.flags['--color'] !== true && !process.stdout.isTTY);
1366
1625
  const reqPath = `/webpack/logs?tail=${tail}&follow=${follow ? 1 : 0}${plain ? '&plain=1' : ''}`;
1367
1626
  return new Promise((done) => {
1368
1627
  const req = httpRequest({ socketPath: target.sock, path: reqPath, method: 'GET' }, (res) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lensmcp",
3
- "version": "1.16.28",
3
+ "version": "1.16.30",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "module": "./index.js",
@@ -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.28",
5
+ "version": "1.16.30",
6
6
  "author": {
7
7
  "name": "David Antoon",
8
8
  "email": "davidmantoon@gmail.com"