lensmcp 1.7.0 → 1.8.0
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/bundled/cluster-app.js +6 -6
- package/bundled/dashboard.js +49 -24
- package/lib/cli.d.ts.map +1 -1
- package/lib/cli.js +185 -10
- package/lib/workspace-scope.d.ts +54 -0
- package/lib/workspace-scope.d.ts.map +1 -0
- package/lib/workspace-scope.js +94 -0
- package/package.json +1 -1
package/bundled/dashboard.js
CHANGED
|
@@ -20132,6 +20132,7 @@ function buildFlowDetail(rawEvents) {
|
|
|
20132
20132
|
// servers/lensmcp-mcp/dist/dashboard.js
|
|
20133
20133
|
var { session } = createLensSession();
|
|
20134
20134
|
var PORT = Number(process.env["LENSMCP_DASHBOARD_PORT"] ?? 4321);
|
|
20135
|
+
var DASH_BASE = (process.env["LENSMCP_DASHBOARD_BASE"] ?? "").replace(/\/+$/, "");
|
|
20135
20136
|
async function snapshot2() {
|
|
20136
20137
|
const uris = session.resources.list().sort();
|
|
20137
20138
|
const resources = {};
|
|
@@ -20437,19 +20438,29 @@ function clusterView() {
|
|
|
20437
20438
|
}
|
|
20438
20439
|
var server = createServer2((req, res) => {
|
|
20439
20440
|
const url2 = new URL(req.url ?? "/", "http://localhost");
|
|
20440
|
-
|
|
20441
|
+
let pathname = url2.pathname;
|
|
20442
|
+
if (DASH_BASE) {
|
|
20443
|
+
if (pathname === DASH_BASE) {
|
|
20444
|
+
res.writeHead(302, { location: DASH_BASE + "/" });
|
|
20445
|
+
res.end();
|
|
20446
|
+
return;
|
|
20447
|
+
}
|
|
20448
|
+
if (pathname.startsWith(DASH_BASE + "/"))
|
|
20449
|
+
pathname = pathname.slice(DASH_BASE.length);
|
|
20450
|
+
}
|
|
20451
|
+
if (pathname === "/api/cluster/stream") {
|
|
20441
20452
|
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
|
|
20442
20453
|
res.write("event: snapshot\ndata: " + JSON.stringify(clusterView()) + "\n\n");
|
|
20443
20454
|
sseClients.add(res);
|
|
20444
20455
|
req.on("close", () => sseClients.delete(res));
|
|
20445
20456
|
return;
|
|
20446
20457
|
}
|
|
20447
|
-
if (
|
|
20458
|
+
if (pathname === "/api/cluster") {
|
|
20448
20459
|
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
|
20449
20460
|
res.end(JSON.stringify(clusterView()));
|
|
20450
20461
|
return;
|
|
20451
20462
|
}
|
|
20452
|
-
if (
|
|
20463
|
+
if (pathname === "/api/snapshot") {
|
|
20453
20464
|
void snapshot2().then((data) => {
|
|
20454
20465
|
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
|
20455
20466
|
res.end(JSON.stringify(data));
|
|
@@ -20459,20 +20470,20 @@ var server = createServer2((req, res) => {
|
|
|
20459
20470
|
});
|
|
20460
20471
|
return;
|
|
20461
20472
|
}
|
|
20462
|
-
if (
|
|
20473
|
+
if (pathname === "/api/flow") {
|
|
20463
20474
|
const id = url2.searchParams.get("id") ?? "";
|
|
20464
20475
|
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
|
20465
20476
|
res.end(JSON.stringify(flowDetail(id)));
|
|
20466
20477
|
return;
|
|
20467
20478
|
}
|
|
20468
|
-
if (
|
|
20479
|
+
if (pathname === "/api/logs") {
|
|
20469
20480
|
const since = Number(url2.searchParams.get("since") ?? "0");
|
|
20470
20481
|
const events = since > 0 ? logBuffer.filter((l) => l.seq > since) : logBuffer.slice(-2e3);
|
|
20471
20482
|
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
|
20472
20483
|
res.end(JSON.stringify({ seq: logSeq, sources: [...logSources].sort(), events }));
|
|
20473
20484
|
return;
|
|
20474
20485
|
}
|
|
20475
|
-
const assetMatch =
|
|
20486
|
+
const assetMatch = pathname.match(/^\/assets\/((?:cluster|flow)-app\.(?:js|css))$/);
|
|
20476
20487
|
if (assetMatch) {
|
|
20477
20488
|
try {
|
|
20478
20489
|
const name = assetMatch[1];
|
|
@@ -20504,6 +20515,7 @@ process.on("SIGTERM", shutdown);
|
|
|
20504
20515
|
var PAGE = `<!doctype html>
|
|
20505
20516
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
20506
20517
|
<title>LensMCP lens</title>
|
|
20518
|
+
<script>window.__LENS_BASE__=${JSON.stringify(DASH_BASE)};function U(p){return (window.__LENS_BASE__||'')+p;}</script>
|
|
20507
20519
|
<style>
|
|
20508
20520
|
:root { color-scheme: dark; }
|
|
20509
20521
|
* { box-sizing: border-box; }
|
|
@@ -20706,18 +20718,18 @@ var PAGE = `<!doctype html>
|
|
|
20706
20718
|
.canvas .react-flow__edge-text { fill: #9fb8d8; font: 600 10px ui-monospace, Menlo, monospace; }
|
|
20707
20719
|
.grp-urls .url:hover { text-decoration: underline; }
|
|
20708
20720
|
</style>
|
|
20709
|
-
<link rel="stylesheet" href="/assets/cluster-app.css">
|
|
20710
|
-
<link rel="stylesheet" href="/assets/flow-app.css">
|
|
20721
|
+
<link rel="stylesheet" href="${DASH_BASE}/assets/cluster-app.css">
|
|
20722
|
+
<link rel="stylesheet" href="${DASH_BASE}/assets/flow-app.css">
|
|
20711
20723
|
</head>
|
|
20712
20724
|
<body>
|
|
20713
20725
|
<header>
|
|
20714
20726
|
<span class="dot off" id="dot"></span>
|
|
20715
20727
|
<strong>LensMCP</strong><span class="muted">lens</span>
|
|
20716
20728
|
<nav class="tabs" id="tabs">
|
|
20717
|
-
<a href="
|
|
20718
|
-
<a href="
|
|
20719
|
-
<a href="
|
|
20720
|
-
<a href="
|
|
20729
|
+
<a href="${DASH_BASE}/flows" data-view="flows">Flows</a>
|
|
20730
|
+
<a href="${DASH_BASE}/cluster" data-view="cluster">Cluster</a>
|
|
20731
|
+
<a href="${DASH_BASE}/logs" data-view="logs">Logs</a>
|
|
20732
|
+
<a href="${DASH_BASE}/resources" data-view="resources">Resources</a>
|
|
20721
20733
|
</nav>
|
|
20722
20734
|
<span class="count" id="count">connecting\u2026</span>
|
|
20723
20735
|
<span class="updated muted" id="updated"></span>
|
|
@@ -21061,7 +21073,7 @@ var PAGE = `<!doctype html>
|
|
|
21061
21073
|
|
|
21062
21074
|
function selectFlow(flowId) {
|
|
21063
21075
|
selectedFlow = flowId;
|
|
21064
|
-
fetch('/api/flow?id=' + encodeURIComponent(flowId))
|
|
21076
|
+
fetch(U('/api/flow?id=' + encodeURIComponent(flowId)))
|
|
21065
21077
|
.then(function (r) { return r.json(); })
|
|
21066
21078
|
.then(renderFlowChart)
|
|
21067
21079
|
.catch(function () { /* next poll retries */ });
|
|
@@ -21143,7 +21155,7 @@ var PAGE = `<!doctype html>
|
|
|
21143
21155
|
function fmtLogTime(ms) { var d = new Date(ms); return p2(d.getHours()) + ':' + p2(d.getMinutes()) + ':' + p2(d.getSeconds()) + '.' + p2(d.getMilliseconds(), 3); }
|
|
21144
21156
|
function logRowEl(r) {
|
|
21145
21157
|
var row = el('div', 'log-row sev-' + r.severity);
|
|
21146
|
-
if (r.flowId) { row.className += ' jump'; row.title = 'open flow ' + r.flowId; row.addEventListener('click', function () { selectedFlow = r.flowId;
|
|
21158
|
+
if (r.flowId) { row.className += ' jump'; row.title = 'open flow ' + r.flowId; row.addEventListener('click', function () { selectedFlow = r.flowId; navTo('flows'); }); }
|
|
21147
21159
|
row.appendChild(el('span', 'l-time', fmtLogTime(r.at)));
|
|
21148
21160
|
row.appendChild(el('span', 'l-src', r.source));
|
|
21149
21161
|
row.appendChild(el('span', 'l-sev', r.severity));
|
|
@@ -21175,7 +21187,7 @@ var PAGE = `<!doctype html>
|
|
|
21175
21187
|
sel.value = cur;
|
|
21176
21188
|
}
|
|
21177
21189
|
function pollLogs() {
|
|
21178
|
-
fetch('/api/logs?since=' + logSeqSeen).then(function (r) { return r.json(); })
|
|
21190
|
+
fetch(U('/api/logs?since=' + logSeqSeen)).then(function (r) { return r.json(); })
|
|
21179
21191
|
.then(function (d) {
|
|
21180
21192
|
refreshLogSources(d.sources || []);
|
|
21181
21193
|
var fresh = d.events || [];
|
|
@@ -21200,11 +21212,18 @@ var PAGE = `<!doctype html>
|
|
|
21200
21212
|
document.getElementById('logs-clear').addEventListener('click', function () { logRows = []; renderLogs(); });
|
|
21201
21213
|
})();
|
|
21202
21214
|
|
|
21203
|
-
// ---- navigation (
|
|
21215
|
+
// ---- navigation (history/path router: <base>/flows | /cluster | /logs | /resources) ----
|
|
21204
21216
|
var VIEWS = ['flows', 'cluster', 'logs', 'resources'];
|
|
21205
21217
|
function currentView() {
|
|
21206
|
-
var
|
|
21207
|
-
|
|
21218
|
+
var base = window.__LENS_BASE__ || '';
|
|
21219
|
+
var p = location.pathname;
|
|
21220
|
+
if (base && p.indexOf(base) === 0) p = p.slice(base.length);
|
|
21221
|
+
var seg = p.split('/').filter(Boolean)[0] || '';
|
|
21222
|
+
return VIEWS.indexOf(seg) >= 0 ? seg : 'flows';
|
|
21223
|
+
}
|
|
21224
|
+
function navTo(v) {
|
|
21225
|
+
history.pushState(null, '', U('/' + v));
|
|
21226
|
+
applyView();
|
|
21208
21227
|
}
|
|
21209
21228
|
function applyView() {
|
|
21210
21229
|
var v = currentView();
|
|
@@ -21216,25 +21235,31 @@ var PAGE = `<!doctype html>
|
|
|
21216
21235
|
});
|
|
21217
21236
|
poll();
|
|
21218
21237
|
}
|
|
21219
|
-
window.addEventListener('
|
|
21238
|
+
window.addEventListener('popstate', applyView);
|
|
21239
|
+
document.getElementById('tabs').addEventListener('click', function (e) {
|
|
21240
|
+
var a = e.target && e.target.closest ? e.target.closest('a[data-view]') : null;
|
|
21241
|
+
if (!a) return;
|
|
21242
|
+
e.preventDefault();
|
|
21243
|
+
navTo(a.getAttribute('data-view'));
|
|
21244
|
+
});
|
|
21220
21245
|
|
|
21221
21246
|
function poll() {
|
|
21222
21247
|
var v = currentView();
|
|
21223
21248
|
if (v === 'logs') { pollLogs(); return; }
|
|
21224
21249
|
if (v === 'cluster') {
|
|
21225
21250
|
// the React app renders the canvas; the shell still owns the header dot
|
|
21226
|
-
fetch('/api/cluster').then(function (r) { return r.json(); })
|
|
21251
|
+
fetch(U('/api/cluster')).then(function (r) { return r.json(); })
|
|
21227
21252
|
.then(function (c) { setStatus(true, c); document.getElementById('count').textContent = (c.decls ? c.decls.length : 0) + ' services \xB7 ' + (c.edges ? c.edges.length : 0) + ' edges'; })
|
|
21228
21253
|
.catch(function () { setStatus(false); });
|
|
21229
21254
|
return;
|
|
21230
21255
|
}
|
|
21231
|
-
fetch('/api/snapshot').then(function (r) { return r.json(); })
|
|
21256
|
+
fetch(U('/api/snapshot')).then(function (r) { return r.json(); })
|
|
21232
21257
|
.then(function (snap) {
|
|
21233
21258
|
renderFlowChips(snap);
|
|
21234
21259
|
if (v === 'resources') renderPanels(snap);
|
|
21235
21260
|
setStatus(true, snap);
|
|
21236
21261
|
if (v === 'flows' && selectedFlow) {
|
|
21237
|
-
fetch('/api/flow?id=' + encodeURIComponent(selectedFlow))
|
|
21262
|
+
fetch(U('/api/flow?id=' + encodeURIComponent(selectedFlow)))
|
|
21238
21263
|
.then(function (r) { return r.json(); })
|
|
21239
21264
|
.then(renderFlowChart)
|
|
21240
21265
|
.catch(function () {});
|
|
@@ -21244,6 +21269,6 @@ var PAGE = `<!doctype html>
|
|
|
21244
21269
|
}
|
|
21245
21270
|
setInterval(poll, POLL_MS); applyView();
|
|
21246
21271
|
</script>
|
|
21247
|
-
<script defer src="/assets/cluster-app.js"></script>
|
|
21248
|
-
<script defer src="/assets/flow-app.js"></script>
|
|
21272
|
+
<script defer src="${DASH_BASE}/assets/cluster-app.js"></script>
|
|
21273
|
+
<script defer src="${DASH_BASE}/assets/flow-app.js"></script>
|
|
21249
21274
|
</body></html>`;
|
package/lib/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/lib/cli.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/lib/cli.ts"],"names":[],"mappings":"AAMA,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;AAoDD,wBAAsB,MAAM,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CAoChE"}
|
package/lib/cli.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, openSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { ensureLensConfig } from './workspace-scope.js';
|
|
5
6
|
const HELP = `lensmcp — CLI for LensMCP (FrontMCP-based observability for coding agents)
|
|
6
7
|
|
|
7
8
|
Usage:
|
|
@@ -12,10 +13,20 @@ Commands:
|
|
|
12
13
|
Launch the LensMCP MCP server. Default transport is
|
|
13
14
|
stdio (set LENSMCP_TRANSPORT=http or pass --transport
|
|
14
15
|
for HTTP on --port, default 3000).
|
|
15
|
-
dashboard [--cwd <dir>] [--port <n>]
|
|
16
|
+
dashboard [--cwd <dir>] [--port <n>] [--base <path>]
|
|
16
17
|
Serve a live web view of the lens (flows, signals,
|
|
17
|
-
stories, status)
|
|
18
|
-
|
|
18
|
+
stories, status). Port defaults to the per-workspace
|
|
19
|
+
derived port (see \`.lensmcp/config.json\`); --base mounts
|
|
20
|
+
it under a path prefix (e.g. /tetros) for its history
|
|
21
|
+
router. Reads the same .lensmcp/events.jsonl your
|
|
22
|
+
agent-dev producers write.
|
|
23
|
+
gateway <start|stop|status|restart> [--cwd <dir>] [--project <p>] [--target <t>]
|
|
24
|
+
Run the dev cluster gateway (one HTTPS front door that
|
|
25
|
+
reads every project.json \`cluster\` decl and launches all
|
|
26
|
+
apps/services under their hosts) AND the per-workspace
|
|
27
|
+
lens dashboard at https://lensmcp.local/<key>/. \`start\`
|
|
28
|
+
spawns it detached (pid+log in .lensmcp/); \`stop\` ends it;
|
|
29
|
+
\`status\` reports liveness. Binding :443 needs privilege.
|
|
19
30
|
bridge [--cwd <dir>] [--host <h>] [--port <n>]
|
|
20
31
|
Run the standalone browser-event bridge for hosts with
|
|
21
32
|
no LensMCP build plugin (webpack, Next.js, no build).
|
|
@@ -58,6 +69,8 @@ export async function runCli(ctx) {
|
|
|
58
69
|
return runMcp(ctx, rest, out, err);
|
|
59
70
|
case 'dashboard':
|
|
60
71
|
return runDashboard(ctx, rest, out, err);
|
|
72
|
+
case 'gateway':
|
|
73
|
+
return runGateway(ctx, rest, out, err);
|
|
61
74
|
case 'bridge':
|
|
62
75
|
return runBridge(ctx, rest, out, err);
|
|
63
76
|
case 'install':
|
|
@@ -78,8 +91,9 @@ function runMcp(ctx, args, _out, err) {
|
|
|
78
91
|
string: ['--cwd', '--transport', '--port'],
|
|
79
92
|
});
|
|
80
93
|
const cwd = resolve(stringFlag(opts.flags['--cwd']) ?? ctx.cwd);
|
|
94
|
+
const cfg = ensureLensConfig(cwd);
|
|
81
95
|
const transport = stringFlag(opts.flags['--transport']) ?? ctx.env?.['LENSMCP_TRANSPORT'] ?? 'stdio';
|
|
82
|
-
const port = stringFlag(opts.flags['--port']) ?? ctx.env?.['LENSMCP_PORT'] ??
|
|
96
|
+
const port = stringFlag(opts.flags['--port']) ?? ctx.env?.['LENSMCP_PORT'] ?? String(cfg.ports.mcpHttp);
|
|
83
97
|
const binPath = findMcpBinary(cwd);
|
|
84
98
|
if (!binPath) {
|
|
85
99
|
err('Could not locate the lensmcp-mcp server bundle.\n' +
|
|
@@ -111,9 +125,15 @@ function runMcp(ctx, args, _out, err) {
|
|
|
111
125
|
return { exitCode: result.status ?? 1 };
|
|
112
126
|
}
|
|
113
127
|
function runDashboard(ctx, args, out, err) {
|
|
114
|
-
const opts = parseFlags(args, { string: ['--cwd', '--port'] });
|
|
128
|
+
const opts = parseFlags(args, { string: ['--cwd', '--port', '--base'] });
|
|
115
129
|
const cwd = resolve(stringFlag(opts.flags['--cwd']) ?? ctx.cwd);
|
|
116
|
-
const
|
|
130
|
+
const cfg = ensureLensConfig(cwd);
|
|
131
|
+
const port = stringFlag(opts.flags['--port']) ??
|
|
132
|
+
ctx.env?.['LENSMCP_DASHBOARD_PORT'] ??
|
|
133
|
+
String(cfg.ports.dashboard);
|
|
134
|
+
// Standalone defaults to root (''); the cluster gateway passes --base /<key> so the
|
|
135
|
+
// dashboard's history router + same-origin URLs resolve under lensmcp.local/<key>/.
|
|
136
|
+
const base = stringFlag(opts.flags['--base']) ?? ctx.env?.['LENSMCP_DASHBOARD_BASE'] ?? '';
|
|
117
137
|
const binPath = findServerBundle(cwd, 'dashboard.js');
|
|
118
138
|
if (!binPath) {
|
|
119
139
|
err('Could not locate the lensmcp dashboard bundle.\n' +
|
|
@@ -129,7 +149,7 @@ function runDashboard(ctx, args, out, err) {
|
|
|
129
149
|
const eventFile = ctx.env?.['LENSMCP_EVENT_FILE'] ??
|
|
130
150
|
process.env['LENSMCP_EVENT_FILE'] ??
|
|
131
151
|
join(cwd, '.lensmcp', 'events.jsonl');
|
|
132
|
-
out(`lensmcp dashboard → http://localhost:${port} (reading ${eventFile})`);
|
|
152
|
+
out(`lensmcp dashboard → http://localhost:${port}${base || '/'} (reading ${eventFile})`);
|
|
133
153
|
const result = spawnSync(process.execPath, [binPath], {
|
|
134
154
|
cwd,
|
|
135
155
|
stdio: 'inherit',
|
|
@@ -137,16 +157,171 @@ function runDashboard(ctx, args, out, err) {
|
|
|
137
157
|
...process.env,
|
|
138
158
|
...(ctx.env ?? {}),
|
|
139
159
|
LENSMCP_DASHBOARD_PORT: port,
|
|
160
|
+
LENSMCP_DASHBOARD_BASE: base,
|
|
140
161
|
LENSMCP_EVENT_FILE: eventFile,
|
|
141
162
|
},
|
|
142
163
|
});
|
|
143
164
|
return { exitCode: result.status ?? 1 };
|
|
144
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* `lensmcp gateway <start|stop|status|restart>` — drive the dev cluster gateway as a
|
|
168
|
+
* detached background process. The gateway (the `@lensmcp/cluster:gateway` executor)
|
|
169
|
+
* reads every project.json `cluster` decl, launches each app/service under its host on
|
|
170
|
+
* :443, and serves the per-workspace lens dashboard at https://lensmcp.local/<key>/.
|
|
171
|
+
* This wraps it so a single command (and the Claude Code plugin) can bring it up/down.
|
|
172
|
+
*/
|
|
173
|
+
function runGateway(ctx, args, out, err) {
|
|
174
|
+
const opts = parseFlags(args, { string: ['--cwd', '--project', '--target'] });
|
|
175
|
+
const sub = (opts.positional[0] ?? 'status').toLowerCase();
|
|
176
|
+
const cwd = resolve(stringFlag(opts.flags['--cwd']) ?? ctx.cwd);
|
|
177
|
+
const cfg = ensureLensConfig(cwd);
|
|
178
|
+
const pidFile = join(cwd, '.lensmcp', 'gateway.pid');
|
|
179
|
+
const logFile = join(cwd, '.lensmcp', 'gateway.log');
|
|
180
|
+
const dashUrl = `https://lensmcp.local${cfg.dashboardBasePath}/`;
|
|
181
|
+
const start = () => {
|
|
182
|
+
const running = readPid(pidFile);
|
|
183
|
+
if (running && isAlive(running)) {
|
|
184
|
+
out(`gateway already running (pid ${running}).`);
|
|
185
|
+
out(` dashboard → ${dashUrl}`);
|
|
186
|
+
return { exitCode: 0 };
|
|
187
|
+
}
|
|
188
|
+
const target = findGatewayTarget(cwd, stringFlag(opts.flags['--project']), stringFlag(opts.flags['--target']));
|
|
189
|
+
if (!target) {
|
|
190
|
+
err('Could not find a gateway target (an executor `@lensmcp/cluster:gateway`) in this workspace.\n' +
|
|
191
|
+
'Pass --project <p> --target <t>, or add a gateway project (see @lensmcp/cluster).');
|
|
192
|
+
return { exitCode: 1 };
|
|
193
|
+
}
|
|
194
|
+
const nxBin = findNxBinary(cwd);
|
|
195
|
+
if (!nxBin) {
|
|
196
|
+
err('Could not find the `nx` binary in the workspace. Install Nx first: `yarn add -D nx`.');
|
|
197
|
+
return { exitCode: 1 };
|
|
198
|
+
}
|
|
199
|
+
const fd = openSync(logFile, 'a');
|
|
200
|
+
const child = spawn(nxBin, ['run', `${target.project}:${target.target}`], {
|
|
201
|
+
cwd,
|
|
202
|
+
detached: true,
|
|
203
|
+
stdio: ['ignore', fd, fd],
|
|
204
|
+
env: { ...process.env, ...(ctx.env ?? {}) },
|
|
205
|
+
});
|
|
206
|
+
child.unref();
|
|
207
|
+
if (child.pid)
|
|
208
|
+
writeFileSync(pidFile, String(child.pid));
|
|
209
|
+
out(`gateway starting → ${target.project}:${target.target} (pid ${child.pid ?? '?'})`);
|
|
210
|
+
out(` dashboard → ${dashUrl}`);
|
|
211
|
+
out(` logs → ${logFile}`);
|
|
212
|
+
out(' note: binding :443 needs privilege — if it fails, run the gateway target with sudo,');
|
|
213
|
+
out(' and run `nx run gateway:trust` once for the *.local hosts + TLS.');
|
|
214
|
+
return { exitCode: 0 };
|
|
215
|
+
};
|
|
216
|
+
const stop = () => {
|
|
217
|
+
const pid = readPid(pidFile);
|
|
218
|
+
if (!pid || !isAlive(pid)) {
|
|
219
|
+
out('gateway not running.');
|
|
220
|
+
rmFile(pidFile);
|
|
221
|
+
return { exitCode: 0 };
|
|
222
|
+
}
|
|
223
|
+
// Detached child is its own process-group leader → negative pid kills the whole tree.
|
|
224
|
+
try {
|
|
225
|
+
process.kill(-pid, 'SIGTERM');
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
try {
|
|
229
|
+
process.kill(pid, 'SIGTERM');
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
/* already gone */
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
rmFile(pidFile);
|
|
236
|
+
out(`gateway stopped (pid ${pid}).`);
|
|
237
|
+
return { exitCode: 0 };
|
|
238
|
+
};
|
|
239
|
+
const status = () => {
|
|
240
|
+
const pid = readPid(pidFile);
|
|
241
|
+
const alive = pid !== undefined && isAlive(pid);
|
|
242
|
+
out(`gateway: ${alive ? `running (pid ${pid})` : 'stopped'}`);
|
|
243
|
+
out(` workspace → ${cfg.key}`);
|
|
244
|
+
out(` dashboard → ${dashUrl}`);
|
|
245
|
+
if (alive)
|
|
246
|
+
out(` logs → ${logFile}`);
|
|
247
|
+
return { exitCode: alive ? 0 : 1 };
|
|
248
|
+
};
|
|
249
|
+
switch (sub) {
|
|
250
|
+
case 'start':
|
|
251
|
+
return start();
|
|
252
|
+
case 'stop':
|
|
253
|
+
return stop();
|
|
254
|
+
case 'restart':
|
|
255
|
+
stop();
|
|
256
|
+
return start();
|
|
257
|
+
case 'status':
|
|
258
|
+
return status();
|
|
259
|
+
default:
|
|
260
|
+
err(`Unknown gateway subcommand: ${sub} (use start | stop | status | restart)`);
|
|
261
|
+
return { exitCode: 2 };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/** Find a project + target whose executor is `@lensmcp/cluster:gateway`. An explicit
|
|
265
|
+
* `project`/`target` short-circuits the scan; otherwise we read each project.json. */
|
|
266
|
+
function findGatewayTarget(cwd, project, target) {
|
|
267
|
+
if (project && target)
|
|
268
|
+
return { project, target };
|
|
269
|
+
const files = walkProjectFiles(cwd, (n) => n === 'project.json');
|
|
270
|
+
for (const f of files) {
|
|
271
|
+
let pj;
|
|
272
|
+
try {
|
|
273
|
+
pj = JSON.parse(safeRead(f));
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const name = pj.name ?? basename(dirname(f));
|
|
279
|
+
for (const [t, def] of Object.entries(pj.targets ?? {})) {
|
|
280
|
+
if (def.executor === '@lensmcp/cluster:gateway') {
|
|
281
|
+
return { project: project ?? name, target: target ?? t };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Sensible fallback used by most workspaces (tetros: tools/gateway → `gateway:serve`).
|
|
286
|
+
return project || target ? { project: project ?? 'gateway', target: target ?? 'serve' } : undefined;
|
|
287
|
+
}
|
|
288
|
+
function readPid(pidFile) {
|
|
289
|
+
try {
|
|
290
|
+
const n = Number(readFileSync(pidFile, 'utf8').trim());
|
|
291
|
+
return Number.isInteger(n) && n > 0 ? n : undefined;
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/** `kill(pid, 0)` probes liveness without signalling — throws ESRCH if the pid is gone. */
|
|
298
|
+
function isAlive(pid) {
|
|
299
|
+
try {
|
|
300
|
+
process.kill(pid, 0);
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
catch (e) {
|
|
304
|
+
return e.code === 'EPERM'; // exists but not ours
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
function rmFile(path) {
|
|
308
|
+
try {
|
|
309
|
+
if (existsSync(path))
|
|
310
|
+
writeFileSync(path, '');
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
/* ignore */
|
|
314
|
+
}
|
|
315
|
+
}
|
|
145
316
|
function runBridge(ctx, args, out, err) {
|
|
146
317
|
const opts = parseFlags(args, { string: ['--cwd', '--host', '--port'] });
|
|
147
318
|
const cwd = resolve(stringFlag(opts.flags['--cwd']) ?? ctx.cwd);
|
|
319
|
+
const cfg = ensureLensConfig(cwd);
|
|
148
320
|
const host = stringFlag(opts.flags['--host']) ?? ctx.env?.['LENSMCP_WS_HOST'] ?? process.env['LENSMCP_WS_HOST'];
|
|
149
|
-
const port = stringFlag(opts.flags['--port']) ??
|
|
321
|
+
const port = stringFlag(opts.flags['--port']) ??
|
|
322
|
+
ctx.env?.['LENSMCP_WS_PORT'] ??
|
|
323
|
+
process.env['LENSMCP_WS_PORT'] ??
|
|
324
|
+
String(cfg.ports.bridge);
|
|
150
325
|
const binPath = findBridgeBundle(cwd);
|
|
151
326
|
if (!binPath) {
|
|
152
327
|
err('Could not locate the lensmcp bridge bundle.\n' +
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-workspace lens scoping — the contract that lets LensMCP run across MANY
|
|
3
|
+
* projects without collisions.
|
|
4
|
+
*
|
|
5
|
+
* A "project" is identified by its workspace ROOT. Everything keys off a stable
|
|
6
|
+
* `key` (slug) derived from that root, so two checkouts (tetros, foo, …) get:
|
|
7
|
+
* • their OWN dashboard / mcp-http / bridge ports (deterministic offset), and
|
|
8
|
+
* • their OWN dashboard mount path (`/<key>`) when served behind the gateway,
|
|
9
|
+
* while the event bus is already isolated by path (`<root>/.lensmcp/events.jsonl`).
|
|
10
|
+
*
|
|
11
|
+
* This module is intentionally dependency-free (the published `lensmcp` CLI bundles
|
|
12
|
+
* no `@lensmcp/*`), so it is the canonical source the CLI reads; the cluster gateway
|
|
13
|
+
* mirrors the same derivation from `@lensmcp/core`. Keep the two in lock-step — the
|
|
14
|
+
* `workspace-scope.spec.ts` pins the derived values.
|
|
15
|
+
*/
|
|
16
|
+
export interface LensPorts {
|
|
17
|
+
/** The human dashboard server port (loopback; the gateway proxies a host→here). */
|
|
18
|
+
dashboard: number;
|
|
19
|
+
/** The MCP server HTTP port (only used when transport=http; stdio ignores it). */
|
|
20
|
+
mcpHttp: number;
|
|
21
|
+
/** The standalone browser-event bridge WebSocket port. */
|
|
22
|
+
bridge: number;
|
|
23
|
+
}
|
|
24
|
+
export interface LensConfig {
|
|
25
|
+
schemaVersion: 1;
|
|
26
|
+
/** Stable workspace slug, e.g. `tetros`. */
|
|
27
|
+
key: string;
|
|
28
|
+
ports: LensPorts;
|
|
29
|
+
/** Dashboard mount path behind the gateway, e.g. `/tetros`. */
|
|
30
|
+
dashboardBasePath: string;
|
|
31
|
+
}
|
|
32
|
+
/** Lowercase kebab slug; empty input → `workspace`. */
|
|
33
|
+
export declare function slugify(input: string): string;
|
|
34
|
+
/**
|
|
35
|
+
* The workspace key = the project's lens identity. Resolution order:
|
|
36
|
+
* 1. nx.json `lensmcp.key` (explicit override)
|
|
37
|
+
* 2. root package.json `name` (usually the repo name, e.g. "tetros")
|
|
38
|
+
* 3. nx.json `name`
|
|
39
|
+
* 4. basename(root)
|
|
40
|
+
*/
|
|
41
|
+
export declare function resolveWorkspaceKey(root: string): string;
|
|
42
|
+
export declare function derivePorts(key: string): LensPorts;
|
|
43
|
+
/** The fully-derived config for a workspace (before any on-disk override). */
|
|
44
|
+
export declare function deriveConfig(root: string): LensConfig;
|
|
45
|
+
/**
|
|
46
|
+
* Read `<root>/.lensmcp/config.json` if present, else the derived defaults. A present
|
|
47
|
+
* file OVERRIDES derived values field-by-field (so a user can pin a key/port without
|
|
48
|
+
* losing the rest), and is the seam the gateway + CLI both read so they agree.
|
|
49
|
+
*/
|
|
50
|
+
export declare function readLensConfig(root: string): LensConfig;
|
|
51
|
+
/** Persist the (derived/merged) config so it is visible + overridable. Idempotent;
|
|
52
|
+
* only writes when the on-disk content would change. */
|
|
53
|
+
export declare function ensureLensConfig(root: string): LensConfig;
|
|
54
|
+
//# sourceMappingURL=workspace-scope.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workspace-scope.d.ts","sourceRoot":"","sources":["../../src/lib/workspace-scope.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;GAcG;AAEH,MAAM,WAAW,SAAS;IACxB,mFAAmF;IACnF,SAAS,EAAE,MAAM,CAAC;IAClB,kFAAkF;IAClF,OAAO,EAAE,MAAM,CAAC;IAChB,0DAA0D;IAC1D,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,UAAU;IACzB,aAAa,EAAE,CAAC,CAAC;IACjB,4CAA4C;IAC5C,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,SAAS,CAAC;IACjB,+DAA+D;IAC/D,iBAAiB,EAAE,MAAM,CAAC;CAC3B;AAaD,uDAAuD;AACvD,wBAAgB,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQ7C;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAOxD;AAOD,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAOlD;AAED,8EAA8E;AAC9E,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAGrD;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAWvD;AAED;yDACyD;AACzD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAQzD"}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
/** Deterministic 0..(range-1) from a string (FNV-1a). Stable across runs/machines —
|
|
4
|
+
* the same workspace always derives the same ports, so restarts never drift. */
|
|
5
|
+
function hashToRange(s, range) {
|
|
6
|
+
let h = 0x811c9dc5;
|
|
7
|
+
for (let i = 0; i < s.length; i++) {
|
|
8
|
+
h ^= s.charCodeAt(i);
|
|
9
|
+
h = Math.imul(h, 0x01000193);
|
|
10
|
+
}
|
|
11
|
+
return Math.abs(h | 0) % range;
|
|
12
|
+
}
|
|
13
|
+
/** Lowercase kebab slug; empty input → `workspace`. */
|
|
14
|
+
export function slugify(input) {
|
|
15
|
+
return (input
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/^@[^/]+\//, '') // drop an npm scope (@acme/foo → foo)
|
|
18
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
19
|
+
.replace(/(^-|-$)/g, '') || 'workspace');
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The workspace key = the project's lens identity. Resolution order:
|
|
23
|
+
* 1. nx.json `lensmcp.key` (explicit override)
|
|
24
|
+
* 2. root package.json `name` (usually the repo name, e.g. "tetros")
|
|
25
|
+
* 3. nx.json `name`
|
|
26
|
+
* 4. basename(root)
|
|
27
|
+
*/
|
|
28
|
+
export function resolveWorkspaceKey(root) {
|
|
29
|
+
const nx = readJson(join(root, 'nx.json'));
|
|
30
|
+
if (nx?.lensmcp?.key)
|
|
31
|
+
return slugify(nx.lensmcp.key);
|
|
32
|
+
const pkg = readJson(join(root, 'package.json'));
|
|
33
|
+
if (pkg?.name)
|
|
34
|
+
return slugify(pkg.name);
|
|
35
|
+
if (nx?.name)
|
|
36
|
+
return slugify(nx.name);
|
|
37
|
+
return slugify(basename(root));
|
|
38
|
+
}
|
|
39
|
+
/** Lens-infra port bases. Chosen to avoid typical app/service ranges (3000-3999
|
|
40
|
+
* for services, 5173+ for Vite); the per-key offset keeps two projects' standalone
|
|
41
|
+
* lens processes from clashing. */
|
|
42
|
+
const BASE_PORTS = { dashboard: 4321, mcpHttp: 4500, bridge: 5747 };
|
|
43
|
+
export function derivePorts(key) {
|
|
44
|
+
const off = hashToRange(key, 200); // 0..199
|
|
45
|
+
return {
|
|
46
|
+
dashboard: BASE_PORTS.dashboard + off,
|
|
47
|
+
mcpHttp: BASE_PORTS.mcpHttp + off,
|
|
48
|
+
bridge: BASE_PORTS.bridge + off,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** The fully-derived config for a workspace (before any on-disk override). */
|
|
52
|
+
export function deriveConfig(root) {
|
|
53
|
+
const key = resolveWorkspaceKey(root);
|
|
54
|
+
return { schemaVersion: 1, key, ports: derivePorts(key), dashboardBasePath: '/' + key };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Read `<root>/.lensmcp/config.json` if present, else the derived defaults. A present
|
|
58
|
+
* file OVERRIDES derived values field-by-field (so a user can pin a key/port without
|
|
59
|
+
* losing the rest), and is the seam the gateway + CLI both read so they agree.
|
|
60
|
+
*/
|
|
61
|
+
export function readLensConfig(root) {
|
|
62
|
+
const derived = deriveConfig(root);
|
|
63
|
+
const file = readJson(join(root, '.lensmcp', 'config.json'));
|
|
64
|
+
if (!file)
|
|
65
|
+
return derived;
|
|
66
|
+
const key = file.key ? slugify(file.key) : derived.key;
|
|
67
|
+
return {
|
|
68
|
+
schemaVersion: 1,
|
|
69
|
+
key,
|
|
70
|
+
ports: { ...derived.ports, ...(file.ports ?? {}) },
|
|
71
|
+
dashboardBasePath: file.dashboardBasePath ?? '/' + key,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** Persist the (derived/merged) config so it is visible + overridable. Idempotent;
|
|
75
|
+
* only writes when the on-disk content would change. */
|
|
76
|
+
export function ensureLensConfig(root) {
|
|
77
|
+
const cfg = readLensConfig(root);
|
|
78
|
+
const dir = join(root, '.lensmcp');
|
|
79
|
+
if (!existsSync(dir))
|
|
80
|
+
mkdirSync(dir, { recursive: true });
|
|
81
|
+
const path = join(dir, 'config.json');
|
|
82
|
+
const next = JSON.stringify(cfg, null, 2) + '\n';
|
|
83
|
+
if (!existsSync(path) || readFileSync(path, 'utf8') !== next)
|
|
84
|
+
writeFileSync(path, next);
|
|
85
|
+
return cfg;
|
|
86
|
+
}
|
|
87
|
+
function readJson(path) {
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|