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.
@@ -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
- if (url2.pathname === "/api/cluster/stream") {
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 (url2.pathname === "/api/cluster") {
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 (url2.pathname === "/api/snapshot") {
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 (url2.pathname === "/api/flow") {
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 (url2.pathname === "/api/logs") {
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 = url2.pathname.match(/^\/assets\/((?:cluster|flow)-app\.(?:js|css))$/);
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="#/flows" data-view="flows">Flows</a>
20718
- <a href="#/cluster" data-view="cluster">Cluster</a>
20719
- <a href="#/logs" data-view="logs">Logs</a>
20720
- <a href="#/resources" data-view="resources">Resources</a>
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; location.hash = '#/flows'; }); }
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 (hash router: #/flows | #/cluster | #/logs | #/resources) ----
21215
+ // ---- navigation (history/path router: <base>/flows | /cluster | /logs | /resources) ----
21204
21216
  var VIEWS = ['flows', 'cluster', 'logs', 'resources'];
21205
21217
  function currentView() {
21206
- var h = (location.hash || '').replace('#/', '');
21207
- return VIEWS.indexOf(h) >= 0 ? h : 'flows';
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('hashchange', applyView);
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":"AAKA,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;AA0CD,wBAAsB,MAAM,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CAkChE"}
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) on --port (default 4321). Reads the
18
- same .lensmcp/events.jsonl your agent-dev producers write.
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'] ?? '3000';
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 port = stringFlag(opts.flags['--port']) ?? ctx.env?.['LENSMCP_DASHBOARD_PORT'] ?? '4321';
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']) ?? ctx.env?.['LENSMCP_WS_PORT'] ?? process.env['LENSMCP_WS_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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lensmcp",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "module": "./index.js",