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 +1 -1
- package/lib/cli.js +281 -22
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
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;
|
|
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 —
|
|
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
|
|
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)
|
|
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
|
-
|
|
340
|
-
|
|
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
|
-
/**
|
|
590
|
-
*
|
|
591
|
-
*
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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)
|
|
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/
|
|
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/
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
@@ -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.30",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "David Antoon",
|
|
8
8
|
"email": "davidmantoon@gmail.com"
|