@singhak/nodeui-cli 0.0.0-stage → 0.5.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/README.md +79 -3
- package/dist/cli.cjs +858 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/index.cjs +761 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +85 -0
- package/dist/preload.cjs +84 -0
- package/dist/preload.cjs.map +1 -0
- package/package.json +61 -6
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var src_exports = {};
|
|
32
|
+
__export(src_exports, {
|
|
33
|
+
PanelError: () => PanelError,
|
|
34
|
+
attach: () => attach,
|
|
35
|
+
attachEnv: () => attachEnv,
|
|
36
|
+
buildDigest: () => buildDigest,
|
|
37
|
+
fetchPanel: () => fetchPanel,
|
|
38
|
+
parseTarget: () => parseTarget,
|
|
39
|
+
preloadPath: () => preloadPath,
|
|
40
|
+
serveMcp: () => serveMcp,
|
|
41
|
+
startDashboard: () => startDashboard
|
|
42
|
+
});
|
|
43
|
+
module.exports = __toCommonJS(src_exports);
|
|
44
|
+
|
|
45
|
+
// src/attach.ts
|
|
46
|
+
var import_node_child_process = require("child_process");
|
|
47
|
+
var import_node_path = require("path");
|
|
48
|
+
function preloadPath() {
|
|
49
|
+
return (0, import_node_path.join)(__dirname, "preload.cjs").replace(/\\/g, "/");
|
|
50
|
+
}
|
|
51
|
+
function attachEnv(base, options, preload = preloadPath()) {
|
|
52
|
+
const env = { ...base };
|
|
53
|
+
env.NODE_OPTIONS = [base.NODE_OPTIONS, `--require "${preload}"`].filter(Boolean).join(" ");
|
|
54
|
+
if (options.path) env.NODEUI_PATH = options.path;
|
|
55
|
+
if (options.token) env.NODEUI_TOKEN = options.token;
|
|
56
|
+
if (options.persist) env.NODEUI_PERSIST_FILE = options.persist;
|
|
57
|
+
if (options.otlp) env.NODEUI_OTLP_ENDPOINT = options.otlp;
|
|
58
|
+
if (options.bodies) env.NODEUI_CAPTURE_BODIES = "true";
|
|
59
|
+
if (options.force) env.NODEUI_ENABLED = "true";
|
|
60
|
+
return env;
|
|
61
|
+
}
|
|
62
|
+
function attach(command, options) {
|
|
63
|
+
const [cmd, ...args] = command;
|
|
64
|
+
if (!cmd) return Promise.reject(new Error("nothing to run; usage: nodeui attach -- node app.js"));
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const child = (0, import_node_child_process.spawn)(cmd, args, {
|
|
67
|
+
stdio: "inherit",
|
|
68
|
+
env: attachEnv(process.env, options),
|
|
69
|
+
shell: process.platform === "win32"
|
|
70
|
+
});
|
|
71
|
+
const onInt = () => {
|
|
72
|
+
child.kill("SIGINT");
|
|
73
|
+
};
|
|
74
|
+
const onTerm = () => {
|
|
75
|
+
child.kill("SIGTERM");
|
|
76
|
+
};
|
|
77
|
+
const cleanup = () => {
|
|
78
|
+
process.off("SIGINT", onInt);
|
|
79
|
+
process.off("SIGTERM", onTerm);
|
|
80
|
+
};
|
|
81
|
+
process.on("SIGINT", onInt);
|
|
82
|
+
process.on("SIGTERM", onTerm);
|
|
83
|
+
child.on("error", (err) => {
|
|
84
|
+
cleanup();
|
|
85
|
+
reject(err);
|
|
86
|
+
});
|
|
87
|
+
child.on("exit", (code, signal) => {
|
|
88
|
+
cleanup();
|
|
89
|
+
resolve(code ?? (signal ? 1 : 0));
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// src/dashboard.ts
|
|
95
|
+
var import_node_http = __toESM(require("http"));
|
|
96
|
+
var import_node_https = __toESM(require("https"));
|
|
97
|
+
var import_nodeui_core = require("@singhak/nodeui-core");
|
|
98
|
+
|
|
99
|
+
// src/targets.ts
|
|
100
|
+
var NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$/;
|
|
101
|
+
function parseTarget(spec) {
|
|
102
|
+
const eq = spec.indexOf("=");
|
|
103
|
+
const hasName = eq > 0 && !spec.slice(0, eq).includes("/") && !spec.slice(0, eq).includes(":");
|
|
104
|
+
const rawUrl = hasName ? spec.slice(eq + 1) : spec;
|
|
105
|
+
let url;
|
|
106
|
+
try {
|
|
107
|
+
url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(rawUrl) ? rawUrl : `http://${rawUrl}`);
|
|
108
|
+
} catch {
|
|
109
|
+
throw new Error(`invalid service URL "${rawUrl}"`);
|
|
110
|
+
}
|
|
111
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
112
|
+
throw new Error(`service URL must be http(s), got "${rawUrl}"`);
|
|
113
|
+
}
|
|
114
|
+
const name = hasName ? spec.slice(0, eq) : `${url.hostname}-${url.port || "80"}`;
|
|
115
|
+
if (!NAME_PATTERN.test(name)) {
|
|
116
|
+
throw new Error(`invalid service name "${name}" (letters, digits, ".", "-" and "_")`);
|
|
117
|
+
}
|
|
118
|
+
const token = url.searchParams.get("token") ?? void 0;
|
|
119
|
+
const path = url.pathname.replace(/\/+$/, "") || "/nodeui";
|
|
120
|
+
return { name, base: `${url.origin}${path}`, token };
|
|
121
|
+
}
|
|
122
|
+
var PanelError = class extends Error {
|
|
123
|
+
constructor(message, code) {
|
|
124
|
+
super(message);
|
|
125
|
+
this.code = code;
|
|
126
|
+
this.name = "PanelError";
|
|
127
|
+
}
|
|
128
|
+
code;
|
|
129
|
+
};
|
|
130
|
+
async function fetchPanel(target, panel, timeoutMs = 3e3) {
|
|
131
|
+
const headers = { accept: "application/json" };
|
|
132
|
+
if (target.token) headers.authorization = `Bearer ${target.token}`;
|
|
133
|
+
let res;
|
|
134
|
+
try {
|
|
135
|
+
res = await fetch(`${target.base}/api/${panel}`, {
|
|
136
|
+
headers,
|
|
137
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
138
|
+
});
|
|
139
|
+
} catch (err) {
|
|
140
|
+
throw new PanelError(
|
|
141
|
+
`cannot reach ${target.name} at ${target.base}: ${err instanceof Error ? err.message : String(err)}`,
|
|
142
|
+
"unreachable"
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
let body;
|
|
146
|
+
try {
|
|
147
|
+
body = await res.json();
|
|
148
|
+
} catch {
|
|
149
|
+
throw new PanelError(`${target.name}: invalid response (HTTP ${res.status})`, "invalid");
|
|
150
|
+
}
|
|
151
|
+
if (!body.ok) {
|
|
152
|
+
throw new PanelError(body.error?.message ?? `HTTP ${res.status}`, body.error?.code ?? "error");
|
|
153
|
+
}
|
|
154
|
+
return body.data;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// src/dashboard.ts
|
|
158
|
+
var HOP_BY_HOP = /* @__PURE__ */ new Set([
|
|
159
|
+
"connection",
|
|
160
|
+
"keep-alive",
|
|
161
|
+
"proxy-authenticate",
|
|
162
|
+
"proxy-authorization",
|
|
163
|
+
"te",
|
|
164
|
+
"trailer",
|
|
165
|
+
"transfer-encoding",
|
|
166
|
+
"upgrade"
|
|
167
|
+
]);
|
|
168
|
+
var PAGE = `<!doctype html>
|
|
169
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
170
|
+
<title>NodeUI services</title>
|
|
171
|
+
<style>
|
|
172
|
+
:root{--bg:#0f1115;--fg:#e6e8ee;--card:#181b22;--bd:#2a2f3a;--mut:#8b93a5;--ok:#3ecf8e;--warn:#f5a524;--bad:#f0616d}
|
|
173
|
+
@media (prefers-color-scheme:light){:root{--bg:#f6f7f9;--fg:#15171c;--card:#fff;--bd:#dfe3ea;--mut:#5d6578}}
|
|
174
|
+
body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 system-ui,sans-serif}
|
|
175
|
+
main{max-width:960px;margin:0 auto;padding:24px 16px}
|
|
176
|
+
h1{font-size:20px;margin:0 0 4px}p.sub{color:var(--mut);margin:0 0 20px}
|
|
177
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px}
|
|
178
|
+
a.card{display:block;background:var(--card);border:1px solid var(--bd);border-radius:10px;padding:14px;color:inherit;text-decoration:none}
|
|
179
|
+
a.card:hover{border-color:var(--mut)}
|
|
180
|
+
.name{font-weight:600;font-size:16px;display:flex;align-items:center;gap:8px}
|
|
181
|
+
.dot{width:9px;height:9px;border-radius:50%;background:var(--mut)}
|
|
182
|
+
.ok{background:var(--ok)}.degraded{background:var(--warn)}.critical,.down{background:var(--bad)}
|
|
183
|
+
.url{color:var(--mut);font-size:12px;word-break:break-all;margin:2px 0 10px}
|
|
184
|
+
dl{display:grid;grid-template-columns:auto 1fr;gap:2px 12px;margin:0}dt{color:var(--mut)}dd{margin:0;text-align:right}
|
|
185
|
+
.reason{color:var(--bad);font-size:13px}
|
|
186
|
+
</style></head><body><main>
|
|
187
|
+
<h1>NodeUI services</h1><p class="sub">Local services, refreshed every 3 seconds. Select one to open its console.</p>
|
|
188
|
+
<div class="grid" id="grid"></div></main>
|
|
189
|
+
<script src="/_dashboard.js"></script></body></html>`;
|
|
190
|
+
var SCRIPT = `
|
|
191
|
+
const grid = document.getElementById('grid');
|
|
192
|
+
function el(tag, cls, text) {
|
|
193
|
+
const e = document.createElement(tag);
|
|
194
|
+
if (cls) e.className = cls;
|
|
195
|
+
if (text !== undefined) e.textContent = text;
|
|
196
|
+
return e;
|
|
197
|
+
}
|
|
198
|
+
function row(dl, k, v) { dl.append(el('dt', '', k), el('dd', '', v)); }
|
|
199
|
+
async function refresh() {
|
|
200
|
+
let list;
|
|
201
|
+
try { list = (await (await fetch('api/services')).json()).data; } catch { return; }
|
|
202
|
+
grid.replaceChildren(...list.map((s) => {
|
|
203
|
+
const card = el('a', 'card');
|
|
204
|
+
card.href = 's/' + encodeURIComponent(s.name) + '/';
|
|
205
|
+
const title = el('div', 'name');
|
|
206
|
+
title.append(el('span', 'dot ' + (s.up ? (s.health || 'ok') : 'down')), el('span', '', s.name));
|
|
207
|
+
card.append(title, el('div', 'url', s.url));
|
|
208
|
+
if (!s.up) { card.append(el('div', 'reason', s.reason || 'unreachable')); return card; }
|
|
209
|
+
const dl = el('dl');
|
|
210
|
+
row(dl, 'Health', s.health || 'unknown');
|
|
211
|
+
row(dl, 'Requests', String(s.requests ?? 0));
|
|
212
|
+
row(dl, 'Error rate', ((s.errorRate || 0) * 100).toFixed(1) + '%');
|
|
213
|
+
row(dl, 'p95', (s.p95Ms || 0).toFixed(1) + ' ms');
|
|
214
|
+
row(dl, 'Error groups', String(s.errorGroups ?? 0));
|
|
215
|
+
card.append(dl);
|
|
216
|
+
return card;
|
|
217
|
+
}));
|
|
218
|
+
}
|
|
219
|
+
refresh(); setInterval(refresh, 3000);
|
|
220
|
+
`;
|
|
221
|
+
async function status(target) {
|
|
222
|
+
const out = { name: target.name, url: target.base, up: false };
|
|
223
|
+
try {
|
|
224
|
+
const config = await fetchPanel(
|
|
225
|
+
target,
|
|
226
|
+
"config"
|
|
227
|
+
);
|
|
228
|
+
if (!config.enabled) {
|
|
229
|
+
out.reason = config.activationReason;
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
const [health, requests, errors] = await Promise.all([
|
|
233
|
+
fetchPanel(target, "health").catch(() => null),
|
|
234
|
+
fetchPanel(
|
|
235
|
+
target,
|
|
236
|
+
"requests"
|
|
237
|
+
).catch(() => null),
|
|
238
|
+
fetchPanel(target, "errors").catch(() => null)
|
|
239
|
+
]);
|
|
240
|
+
out.up = true;
|
|
241
|
+
out.health = health?.status;
|
|
242
|
+
out.requests = requests?.total;
|
|
243
|
+
out.errorRate = requests?.summary.errorRate;
|
|
244
|
+
out.p95Ms = requests?.summary.p95Ms;
|
|
245
|
+
out.errorGroups = errors?.groups.length;
|
|
246
|
+
} catch (err) {
|
|
247
|
+
out.reason = err instanceof Error ? err.message : String(err);
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
function json(res, code, body) {
|
|
252
|
+
res.writeHead(code, {
|
|
253
|
+
"content-type": "application/json; charset=utf-8",
|
|
254
|
+
"cache-control": "no-store",
|
|
255
|
+
"x-content-type-options": "nosniff"
|
|
256
|
+
});
|
|
257
|
+
res.end(JSON.stringify(body));
|
|
258
|
+
}
|
|
259
|
+
function proxy(target, rest, search, req, res) {
|
|
260
|
+
const url = new URL(`${target.base}${rest}${search}`);
|
|
261
|
+
const headers = {};
|
|
262
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
263
|
+
if (v === void 0 || HOP_BY_HOP.has(k)) continue;
|
|
264
|
+
if (k === "host" || k === "origin" || k === "referer" || k === "cookie") continue;
|
|
265
|
+
headers[k] = v;
|
|
266
|
+
}
|
|
267
|
+
if (target.token) headers.authorization = `Bearer ${target.token}`;
|
|
268
|
+
const client = url.protocol === "https:" ? import_node_https.default : import_node_http.default;
|
|
269
|
+
const upstream = client.request(url, { method: req.method, headers }, (up) => {
|
|
270
|
+
const out = {};
|
|
271
|
+
for (const [k, v] of Object.entries(up.headers)) {
|
|
272
|
+
if (v === void 0 || HOP_BY_HOP.has(k) || k === "set-cookie") continue;
|
|
273
|
+
out[k] = v;
|
|
274
|
+
}
|
|
275
|
+
res.writeHead(up.statusCode ?? 502, out);
|
|
276
|
+
up.pipe(res);
|
|
277
|
+
});
|
|
278
|
+
upstream.on("error", (err) => {
|
|
279
|
+
if (res.headersSent) {
|
|
280
|
+
res.destroy();
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
json(res, 502, {
|
|
284
|
+
ok: false,
|
|
285
|
+
error: { code: "bad-gateway", message: `cannot reach ${target.name}: ${err.message}` }
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
res.on("close", () => upstream.destroy());
|
|
289
|
+
req.pipe(upstream);
|
|
290
|
+
}
|
|
291
|
+
async function startDashboard(options) {
|
|
292
|
+
const byName = new Map(options.services.map((s) => [s.name, s]));
|
|
293
|
+
if (byName.size !== options.services.length) throw new Error("service names must be unique");
|
|
294
|
+
const guard = (0, import_nodeui_core.createGuard)({
|
|
295
|
+
host: "127.0.0.1",
|
|
296
|
+
allowedHosts: [],
|
|
297
|
+
allowedOrigins: [],
|
|
298
|
+
allowedRemoteAddresses: [],
|
|
299
|
+
trustProxy: false
|
|
300
|
+
});
|
|
301
|
+
const assets = (0, import_nodeui_core.uiAssetsDir)();
|
|
302
|
+
const server = import_node_http.default.createServer((req, res) => {
|
|
303
|
+
const verdict = guard(req);
|
|
304
|
+
if (!verdict.ok) {
|
|
305
|
+
json(res, verdict.status, {
|
|
306
|
+
ok: false,
|
|
307
|
+
error: { code: verdict.code, message: verdict.message }
|
|
308
|
+
});
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
312
|
+
const path = url.pathname;
|
|
313
|
+
if (path === "/" && req.method === "GET") {
|
|
314
|
+
res.writeHead(200, {
|
|
315
|
+
"content-type": "text/html; charset=utf-8",
|
|
316
|
+
"content-security-policy": "default-src 'self'; style-src 'self' 'unsafe-inline'",
|
|
317
|
+
"x-content-type-options": "nosniff"
|
|
318
|
+
});
|
|
319
|
+
res.end(PAGE);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (path === "/_dashboard.js") {
|
|
323
|
+
res.writeHead(200, { "content-type": "text/javascript; charset=utf-8" });
|
|
324
|
+
res.end(SCRIPT);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (path === "/api/services") {
|
|
328
|
+
void Promise.all(options.services.map(status)).then(
|
|
329
|
+
(data) => json(res, 200, { ok: true, data })
|
|
330
|
+
);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const match = /^\/s\/([^/]+)(\/.*)?$/.exec(path);
|
|
334
|
+
let target;
|
|
335
|
+
try {
|
|
336
|
+
target = match ? byName.get(decodeURIComponent(match[1] ?? "")) : void 0;
|
|
337
|
+
} catch {
|
|
338
|
+
target = void 0;
|
|
339
|
+
}
|
|
340
|
+
if (!match || !target) {
|
|
341
|
+
json(res, 404, { ok: false, error: { code: "not-found", message: "unknown service" } });
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const rest = match[2];
|
|
345
|
+
if (rest === void 0) {
|
|
346
|
+
res.writeHead(308, { location: `${path}/` });
|
|
347
|
+
res.end();
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (rest.startsWith("/api/")) {
|
|
351
|
+
proxy(target, rest, url.search, req, res);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const asset = (0, import_nodeui_core.resolveStaticAsset)(assets, rest === "/" ? "" : rest);
|
|
355
|
+
if (!asset) {
|
|
356
|
+
json(res, 404, { ok: false, error: { code: "not-found", message: "console not built" } });
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
res.writeHead(200, {
|
|
360
|
+
"content-type": asset.contentType,
|
|
361
|
+
"content-length": asset.length,
|
|
362
|
+
"x-content-type-options": "nosniff"
|
|
363
|
+
});
|
|
364
|
+
asset.content.pipe(res);
|
|
365
|
+
});
|
|
366
|
+
await new Promise((resolve, reject) => {
|
|
367
|
+
server.once("error", reject);
|
|
368
|
+
server.listen(options.port ?? 4e3, "127.0.0.1", resolve);
|
|
369
|
+
});
|
|
370
|
+
return {
|
|
371
|
+
server,
|
|
372
|
+
port: server.address().port,
|
|
373
|
+
close: () => new Promise((resolve) => {
|
|
374
|
+
server.closeAllConnections?.();
|
|
375
|
+
server.close(() => resolve());
|
|
376
|
+
})
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// src/mcp.ts
|
|
381
|
+
var import_node_readline = require("readline");
|
|
382
|
+
|
|
383
|
+
// package.json
|
|
384
|
+
var package_default = {
|
|
385
|
+
name: "@singhak/nodeui-cli",
|
|
386
|
+
version: "0.5.0",
|
|
387
|
+
description: "NodeUI command line: attach the console to any Node app without code changes, a multi-service dashboard, and an MCP server for AI agents.",
|
|
388
|
+
license: "Apache-2.0",
|
|
389
|
+
bin: {
|
|
390
|
+
nodeui: "dist/cli.cjs"
|
|
391
|
+
},
|
|
392
|
+
main: "./dist/index.cjs",
|
|
393
|
+
types: "./dist/index.d.ts",
|
|
394
|
+
exports: {
|
|
395
|
+
".": {
|
|
396
|
+
types: "./dist/index.d.ts",
|
|
397
|
+
default: "./dist/index.cjs"
|
|
398
|
+
},
|
|
399
|
+
"./package.json": "./package.json"
|
|
400
|
+
},
|
|
401
|
+
files: [
|
|
402
|
+
"dist"
|
|
403
|
+
],
|
|
404
|
+
keywords: [
|
|
405
|
+
"cli",
|
|
406
|
+
"npx",
|
|
407
|
+
"developer-tools",
|
|
408
|
+
"observability",
|
|
409
|
+
"debugging",
|
|
410
|
+
"mcp",
|
|
411
|
+
"nodejs"
|
|
412
|
+
],
|
|
413
|
+
engines: {
|
|
414
|
+
node: ">=18.0.0"
|
|
415
|
+
},
|
|
416
|
+
publishConfig: {
|
|
417
|
+
access: "public"
|
|
418
|
+
},
|
|
419
|
+
repository: {
|
|
420
|
+
type: "git",
|
|
421
|
+
url: "https://github.com/Singhak/nodeui.git"
|
|
422
|
+
},
|
|
423
|
+
homepage: "https://github.com/Singhak/nodeui#readme",
|
|
424
|
+
bugs: {
|
|
425
|
+
url: "https://github.com/Singhak/nodeui/issues"
|
|
426
|
+
},
|
|
427
|
+
scripts: {
|
|
428
|
+
prepublishOnly: "npm run build",
|
|
429
|
+
build: "tsup",
|
|
430
|
+
typecheck: "tsc -p tsconfig.typecheck.json",
|
|
431
|
+
test: "vitest run"
|
|
432
|
+
},
|
|
433
|
+
dependencies: {
|
|
434
|
+
"@singhak/nodeui-core": "^0.5.0"
|
|
435
|
+
},
|
|
436
|
+
devDependencies: {
|
|
437
|
+
"@types/node": "^22.19.0",
|
|
438
|
+
tsup: "^8.5.0",
|
|
439
|
+
typescript: "^5.9.3",
|
|
440
|
+
vitest: "^3.2.4"
|
|
441
|
+
},
|
|
442
|
+
author: "Anil Kumar",
|
|
443
|
+
type: "commonjs"
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
// src/digest.ts
|
|
447
|
+
var ms = (n) => `${n.toFixed(n < 10 ? 1 : 0)} ms`;
|
|
448
|
+
var clip = (s, n) => s.length > n ? `${s.slice(0, n)}\u2026` : s;
|
|
449
|
+
var oneLine = (s) => s.replace(/\s+/g, " ").trim();
|
|
450
|
+
function routeOf(r) {
|
|
451
|
+
return `${r.method} ${r.route ?? r.path}`;
|
|
452
|
+
}
|
|
453
|
+
function buildDigest(input) {
|
|
454
|
+
const out = [`# ${input.service}`];
|
|
455
|
+
const { health, requests, errors, queries, outgoing } = input;
|
|
456
|
+
if (health) {
|
|
457
|
+
out.push(
|
|
458
|
+
`Health: **${health.status}**${health.statusReason ? ` (${oneLine(health.statusReason)})` : ""}; up ${Math.round(health.uptimeSeconds)}s; event-loop lag ${health.eventLoopLagMs ?? "?"} ms; memory ${health.memoryUsedPercent ?? "?"}%.`
|
|
459
|
+
);
|
|
460
|
+
const failing = health.checks.filter((c) => c.status === "down");
|
|
461
|
+
for (const c of failing) out.push(`- dependency check \`${c.name}\` is down`);
|
|
462
|
+
}
|
|
463
|
+
const problems = [];
|
|
464
|
+
if (errors && errors.groups.length > 0) {
|
|
465
|
+
const groups = [...errors.groups].sort((a, b) => b.lastSeenMs - a.lastSeenMs).slice(0, 5);
|
|
466
|
+
problems.push("## Errors (most recent first)");
|
|
467
|
+
for (const g of groups) {
|
|
468
|
+
problems.push(
|
|
469
|
+
`- **${g.name}: ${clip(oneLine(g.message), 160)}** x${g.count}${g.lastRoute ? ` on \`${g.lastRoute}\`` : ""}${g.lastRequestId !== void 0 ? `, request #${g.lastRequestId}` : ""} [${g.source}]; top frame: \`${clip(oneLine(g.stack.split("\n")[1] ?? ""), 140)}\``
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (requests) {
|
|
474
|
+
const s = requests.summary;
|
|
475
|
+
out.push(
|
|
476
|
+
`Traffic: ${s.count} recent requests, ${(s.errorRate * 100).toFixed(1)}% 5xx, p50 ${ms(s.p50Ms)}, p95 ${ms(s.p95Ms)}, p99 ${ms(s.p99Ms)}.`
|
|
477
|
+
);
|
|
478
|
+
const failed = requests.entries.filter((r) => r.status >= 500).slice(-5);
|
|
479
|
+
if (failed.length > 0) {
|
|
480
|
+
problems.push("## Failing requests");
|
|
481
|
+
for (const r of failed) {
|
|
482
|
+
problems.push(`- #${r.id} ${routeOf(r)} -> ${r.status} in ${ms(r.durationMs)}`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const slow = [...s.routes].sort((a, b) => b.p95Ms - a.p95Ms).slice(0, 3);
|
|
486
|
+
if (slow.length > 0) {
|
|
487
|
+
problems.push("## Slowest routes (by p95)");
|
|
488
|
+
for (const r of slow) {
|
|
489
|
+
problems.push(
|
|
490
|
+
`- ${r.method} ${r.path}: p95 ${ms(r.p95Ms)}, avg ${ms(r.avgMs)}, ${r.count} calls, ${r.errors} errors`
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (queries && (queries.slow > 0 || queries.nPlusOneGroups > 0 || queries.failed > 0)) {
|
|
496
|
+
problems.push(
|
|
497
|
+
`## Database: ${queries.slow} slow (>= ${queries.slowQueryMs} ms), ${queries.nPlusOneGroups} N+1 suspects, ${queries.failed} failed`
|
|
498
|
+
);
|
|
499
|
+
const interesting = queries.entries.filter((q) => q.slow || q.nPlusOne || q.error).slice(-5);
|
|
500
|
+
for (const q of interesting) {
|
|
501
|
+
const flags = [q.slow && "slow", q.nPlusOne && `N+1 x${q.repeats ?? "?"}`, q.error && "error"].filter(Boolean).join(", ");
|
|
502
|
+
problems.push(
|
|
503
|
+
`- [${flags}] ${ms(q.durationMs)}${q.requestId !== void 0 ? `, request #${q.requestId}` : ""}: \`${clip(oneLine(q.sql), 160)}\``
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (outgoing && outgoing.failed > 0) {
|
|
508
|
+
problems.push(`## Outgoing calls: ${outgoing.failed} failed`);
|
|
509
|
+
for (const o of outgoing.entries.filter((e) => e.error || (e.status ?? 0) >= 500).slice(-5)) {
|
|
510
|
+
problems.push(
|
|
511
|
+
`- ${o.method} ${o.url} -> ${o.error ?? o.status}${o.requestId !== void 0 ? `, request #${o.requestId}` : ""}`
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
out.push(
|
|
516
|
+
problems.length > 0 ? problems.join("\n") : "No errors, failing requests, slow or N+1 queries, or failing outgoing calls were observed."
|
|
517
|
+
);
|
|
518
|
+
return out.join("\n\n");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// src/mcp.ts
|
|
522
|
+
var SERVER_INFO = { name: "nodeui", version: package_default.version };
|
|
523
|
+
var DEFAULT_PROTOCOL = "2025-03-26";
|
|
524
|
+
var DEFAULT_LIMIT = 20;
|
|
525
|
+
var MAX_LIMIT = 200;
|
|
526
|
+
var num = (v, fallback) => typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
|
527
|
+
var limitOf = (v) => Math.min(Math.max(Math.trunc(num(v, DEFAULT_LIMIT)), 1), MAX_LIMIT);
|
|
528
|
+
var text = (v) => JSON.stringify(v, null, 1);
|
|
529
|
+
var optional = (p) => p.catch(() => null);
|
|
530
|
+
var limitProp = {
|
|
531
|
+
limit: { type: "integer", description: `Max items, newest last (default ${DEFAULT_LIMIT}).` }
|
|
532
|
+
};
|
|
533
|
+
var TOOLS = [
|
|
534
|
+
{
|
|
535
|
+
name: "nodeui_overview",
|
|
536
|
+
description: "START HERE. Prioritised digest of the running app: health, traffic, grouped errors with stack frames, failing and slow requests, slow/N+1 queries, failing outgoing calls.",
|
|
537
|
+
properties: {},
|
|
538
|
+
async run(_args, t) {
|
|
539
|
+
const [health, requests, errors, queries, outgoing] = await Promise.all([
|
|
540
|
+
optional(fetchPanel(t, "health")),
|
|
541
|
+
optional(fetchPanel(t, "requests")),
|
|
542
|
+
optional(fetchPanel(t, "errors")),
|
|
543
|
+
optional(fetchPanel(t, "queries")),
|
|
544
|
+
optional(fetchPanel(t, "outgoing"))
|
|
545
|
+
]);
|
|
546
|
+
return buildDigest({ service: t.name, health, requests, errors, queries, outgoing });
|
|
547
|
+
}
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
name: "nodeui_errors",
|
|
551
|
+
description: "Errors grouped by fingerprint with count, first/last seen, full stack and the last linked request id.",
|
|
552
|
+
properties: limitProp,
|
|
553
|
+
async run(args, t) {
|
|
554
|
+
const data = await fetchPanel(t, "errors");
|
|
555
|
+
return text(data.groups.slice(0, limitOf(args.limit)));
|
|
556
|
+
}
|
|
557
|
+
},
|
|
558
|
+
{
|
|
559
|
+
name: "nodeui_requests",
|
|
560
|
+
description: "Recent HTTP requests handled by the app (method, route pattern, status, duration, query/headers when captured). Filter by minimum status or minimum duration.",
|
|
561
|
+
properties: {
|
|
562
|
+
...limitProp,
|
|
563
|
+
minStatus: { type: "integer", description: "Only requests with status >= this (e.g. 500)." },
|
|
564
|
+
minDurationMs: { type: "number", description: "Only requests at least this slow." }
|
|
565
|
+
},
|
|
566
|
+
async run(args, t) {
|
|
567
|
+
const data = await fetchPanel(t, "requests");
|
|
568
|
+
const minStatus = num(args.minStatus, 0);
|
|
569
|
+
const minMs = num(args.minDurationMs, 0);
|
|
570
|
+
const rows = data.entries.filter((r) => r.status >= minStatus && r.durationMs >= minMs);
|
|
571
|
+
return text({ summary: data.summary, entries: rows.slice(-limitOf(args.limit)) });
|
|
572
|
+
}
|
|
573
|
+
},
|
|
574
|
+
{
|
|
575
|
+
name: "nodeui_request",
|
|
576
|
+
description: "One request in full: its details plus everything it caused (outgoing HTTP calls, database queries, log lines) joined by request id.",
|
|
577
|
+
properties: { id: { type: "integer", description: "Request id, from nodeui_requests." } },
|
|
578
|
+
async run(args, t) {
|
|
579
|
+
const id = num(args.id, NaN);
|
|
580
|
+
if (!Number.isFinite(id)) throw new PanelError("id is required", "bad-args");
|
|
581
|
+
const [requests, outgoing, queries, logs] = await Promise.all([
|
|
582
|
+
fetchPanel(t, "requests"),
|
|
583
|
+
optional(fetchPanel(t, "outgoing")),
|
|
584
|
+
optional(fetchPanel(t, "queries")),
|
|
585
|
+
optional(fetchPanel(t, "logs"))
|
|
586
|
+
]);
|
|
587
|
+
const request = requests.entries.find((r) => r.id === id);
|
|
588
|
+
if (!request) throw new PanelError(`request #${id} is not in the recent buffer`, "not-found");
|
|
589
|
+
return text({
|
|
590
|
+
request,
|
|
591
|
+
outgoing: outgoing?.entries.filter((e) => e.requestId === id) ?? [],
|
|
592
|
+
queries: queries?.entries.filter((e) => e.requestId === id) ?? [],
|
|
593
|
+
logs: logs?.entries.filter((e) => e.requestId === id) ?? []
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
},
|
|
597
|
+
{
|
|
598
|
+
name: "nodeui_queries",
|
|
599
|
+
description: "Database statements (parameters never recorded) with duration, rows, and slow / N+1 flags.",
|
|
600
|
+
properties: {
|
|
601
|
+
...limitProp,
|
|
602
|
+
onlyProblems: { type: "boolean", description: "Only slow, N+1 or failed queries." }
|
|
603
|
+
},
|
|
604
|
+
async run(args, t) {
|
|
605
|
+
const data = await fetchPanel(t, "queries");
|
|
606
|
+
const rows = args.onlyProblems === true ? data.entries.filter((q) => q.slow || q.nPlusOne || q.error) : data.entries;
|
|
607
|
+
return text({
|
|
608
|
+
slowQueryMs: data.slowQueryMs,
|
|
609
|
+
nPlusOneGroups: data.nPlusOneGroups,
|
|
610
|
+
entries: rows.slice(-limitOf(args.limit))
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
},
|
|
614
|
+
{
|
|
615
|
+
name: "nodeui_outgoing",
|
|
616
|
+
description: "Outgoing HTTP calls the app made (host/path, status, duration, error).",
|
|
617
|
+
properties: {
|
|
618
|
+
...limitProp,
|
|
619
|
+
onlyFailed: { type: "boolean", description: "Only calls with an error or status >= 500." }
|
|
620
|
+
},
|
|
621
|
+
async run(args, t) {
|
|
622
|
+
const data = await fetchPanel(t, "outgoing");
|
|
623
|
+
const rows = args.onlyFailed === true ? data.entries.filter((e) => e.error || (e.status ?? 0) >= 500) : data.entries;
|
|
624
|
+
return text(rows.slice(-limitOf(args.limit)));
|
|
625
|
+
}
|
|
626
|
+
},
|
|
627
|
+
{
|
|
628
|
+
name: "nodeui_logs",
|
|
629
|
+
description: "Recent application log lines (secrets masked), optionally only warn/error.",
|
|
630
|
+
properties: {
|
|
631
|
+
...limitProp,
|
|
632
|
+
minLevel: { type: "string", enum: ["debug", "info", "warn", "error"] }
|
|
633
|
+
},
|
|
634
|
+
async run(args, t) {
|
|
635
|
+
const order = ["debug", "info", "warn", "error"];
|
|
636
|
+
const min = order.indexOf(typeof args.minLevel === "string" ? args.minLevel : "debug");
|
|
637
|
+
const data = await fetchPanel(t, "logs");
|
|
638
|
+
const rows = data.entries.filter((l) => order.indexOf(l.level) >= Math.max(min, 0));
|
|
639
|
+
return text(rows.slice(-limitOf(args.limit)));
|
|
640
|
+
}
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
name: "nodeui_routes",
|
|
644
|
+
description: "The registered route table (method, path, handler).",
|
|
645
|
+
properties: {},
|
|
646
|
+
async run(_args, t) {
|
|
647
|
+
return text((await fetchPanel(t, "routes")).routes);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
];
|
|
651
|
+
function toolDefinitions(multi) {
|
|
652
|
+
return TOOLS.map((t) => ({
|
|
653
|
+
name: t.name,
|
|
654
|
+
description: t.description + " Read-only.",
|
|
655
|
+
inputSchema: {
|
|
656
|
+
type: "object",
|
|
657
|
+
properties: multi ? { service: { type: "string", description: "Service name." }, ...t.properties } : t.properties,
|
|
658
|
+
additionalProperties: false
|
|
659
|
+
},
|
|
660
|
+
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
661
|
+
}));
|
|
662
|
+
}
|
|
663
|
+
function serveMcp(options) {
|
|
664
|
+
const input = options.input ?? process.stdin;
|
|
665
|
+
const output = options.output ?? process.stdout;
|
|
666
|
+
const services = options.services;
|
|
667
|
+
const multi = services.length > 1;
|
|
668
|
+
const send = (msg) => {
|
|
669
|
+
output.write(`${JSON.stringify(msg)}
|
|
670
|
+
`);
|
|
671
|
+
};
|
|
672
|
+
const reply = (id, result) => send({ jsonrpc: "2.0", id, result });
|
|
673
|
+
const fail = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, message } });
|
|
674
|
+
async function call(params) {
|
|
675
|
+
const tool = TOOLS.find((t) => t.name === params?.name);
|
|
676
|
+
if (!tool) return { isError: true, content: [{ type: "text", text: "unknown tool" }] };
|
|
677
|
+
const args = params?.arguments ?? {};
|
|
678
|
+
const wanted = typeof args.service === "string" ? args.service : void 0;
|
|
679
|
+
const target = wanted ? services.find((s) => s.name === wanted) : multi ? void 0 : services[0];
|
|
680
|
+
try {
|
|
681
|
+
if (!target) {
|
|
682
|
+
throw new PanelError(
|
|
683
|
+
`specify "service": one of ${services.map((s) => s.name).join(", ")}`,
|
|
684
|
+
"bad-args"
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
return { content: [{ type: "text", text: await tool.run(args, target) }] };
|
|
688
|
+
} catch (err) {
|
|
689
|
+
return {
|
|
690
|
+
isError: true,
|
|
691
|
+
content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }]
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
async function handle(req) {
|
|
696
|
+
const isNotification = req.id === void 0;
|
|
697
|
+
switch (req.method) {
|
|
698
|
+
case "initialize":
|
|
699
|
+
reply(req.id, {
|
|
700
|
+
protocolVersion: typeof req.params?.protocolVersion === "string" ? req.params.protocolVersion : DEFAULT_PROTOCOL,
|
|
701
|
+
capabilities: { tools: {} },
|
|
702
|
+
serverInfo: SERVER_INFO,
|
|
703
|
+
instructions: "Read-only view of a running Node app. Call nodeui_overview first, then drill into errors, requests, queries."
|
|
704
|
+
});
|
|
705
|
+
for (const s of services) {
|
|
706
|
+
void Promise.all([
|
|
707
|
+
optional(fetchPanel(s, "outgoing")),
|
|
708
|
+
optional(fetchPanel(s, "queries"))
|
|
709
|
+
]);
|
|
710
|
+
}
|
|
711
|
+
return;
|
|
712
|
+
case "ping":
|
|
713
|
+
reply(req.id, {});
|
|
714
|
+
return;
|
|
715
|
+
case "tools/list":
|
|
716
|
+
reply(req.id, { tools: toolDefinitions(multi) });
|
|
717
|
+
return;
|
|
718
|
+
case "tools/call":
|
|
719
|
+
reply(req.id, await call(req.params));
|
|
720
|
+
return;
|
|
721
|
+
default:
|
|
722
|
+
if (!isNotification) fail(req.id, -32601, `method not found: ${req.method}`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
const rl = (0, import_node_readline.createInterface)({ input });
|
|
726
|
+
const pending = /* @__PURE__ */ new Set();
|
|
727
|
+
rl.on("line", (line) => {
|
|
728
|
+
if (!line.trim()) return;
|
|
729
|
+
let req;
|
|
730
|
+
try {
|
|
731
|
+
req = JSON.parse(line);
|
|
732
|
+
} catch {
|
|
733
|
+
fail(null, -32700, "parse error");
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
const p = handle(req).catch((err) => {
|
|
737
|
+
if (req.id !== void 0)
|
|
738
|
+
fail(req.id, -32603, err instanceof Error ? err.message : "error");
|
|
739
|
+
}).finally(() => pending.delete(p));
|
|
740
|
+
pending.add(p);
|
|
741
|
+
});
|
|
742
|
+
const done = new Promise((resolve) => {
|
|
743
|
+
rl.on("close", () => {
|
|
744
|
+
void Promise.all(pending).then(() => resolve());
|
|
745
|
+
});
|
|
746
|
+
});
|
|
747
|
+
return { done };
|
|
748
|
+
}
|
|
749
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
750
|
+
0 && (module.exports = {
|
|
751
|
+
PanelError,
|
|
752
|
+
attach,
|
|
753
|
+
attachEnv,
|
|
754
|
+
buildDigest,
|
|
755
|
+
fetchPanel,
|
|
756
|
+
parseTarget,
|
|
757
|
+
preloadPath,
|
|
758
|
+
serveMcp,
|
|
759
|
+
startDashboard
|
|
760
|
+
});
|
|
761
|
+
//# sourceMappingURL=index.cjs.map
|