@ctrlpi/ctrlpi-lab 0.0.0-stage → 0.9.25

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/ctrlpi-lab.js ADDED
@@ -0,0 +1,585 @@
1
+ #!/usr/bin/env node
2
+ // Served on Node's built-in http module with zero external dependencies.
3
+ import http from "http";
4
+ import { spawn, execFile } from "child_process";
5
+ import dgram from "dgram";
6
+ import fs from "fs";
7
+ import os from "os";
8
+ import path from "path";
9
+ import { fileURLToPath } from "url";
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+
13
+ function openBrowser(url) {
14
+ const plat = process.platform;
15
+ if (plat === 'darwin') {
16
+ execFile('open', [url], () => {});
17
+ } else if (plat === 'win32') {
18
+ execFile('cmd.exe', ['/c', 'start', '""', url], () => {});
19
+ } else {
20
+ execFile('xdg-open', [url], () => {});
21
+ }
22
+ }
23
+
24
+ // --- Config file paths ---------------------------------------------------
25
+ // Config files are stored in ~/.ctrlpi/ (or overridden via --config <path>):
26
+ // config.json shared agent list ({name, ip, port, api_key}) across ctrlPi tools
27
+ // lab-config.json dashboard state: selected_agent, mcp configuration, online_agents
28
+ function resolveConfigFile(fileName) {
29
+ const args = process.argv.slice(2);
30
+ const i = args.indexOf("--config");
31
+ let file = path.join(os.homedir(), ".ctrlpi", fileName);
32
+ if (i >= 0 && args[i + 1] && !args[i + 1].startsWith("--")) {
33
+ const p = path.resolve(args[i + 1]);
34
+ file = p.toLowerCase().endsWith(".json") ? p : path.join(p, fileName);
35
+ }
36
+ try { fs.mkdirSync(path.dirname(file), { recursive: true }); } catch (e) {
37
+ console.error(`Could not create ${path.dirname(file)}: ${e.message}`);
38
+ }
39
+ return file;
40
+ }
41
+
42
+ const CONFIG_FILE = resolveConfigFile("lab-config.json");
43
+ const SHARED_FILE = path.join(path.dirname(CONFIG_FILE), "config.json");
44
+ const STATE_DIR = path.dirname(CONFIG_FILE);
45
+ const PAGES_DIR = path.join(__dirname, "pages");
46
+ const IMAGES_DIR = path.join(__dirname, "images");
47
+ const portArgIndex = process.argv.indexOf("--port");
48
+ if (portArgIndex >= 0 && (!process.argv[portArgIndex + 1] || process.argv[portArgIndex + 1].startsWith("--"))) {
49
+ throw new Error("--port requires a port number, e.g. --port 7474");
50
+ }
51
+ const PORT = Number(portArgIndex >= 0 ? process.argv[portArgIndex + 1] : (process.env.PORT ?? 7474));
52
+ const hostArgIndex = process.argv.indexOf("--host");
53
+ if (hostArgIndex >= 0 && (!process.argv[hostArgIndex + 1] || process.argv[hostArgIndex + 1].startsWith("--"))) {
54
+ throw new Error("--host requires an address, e.g. --host 0.0.0.0");
55
+ }
56
+ const HOST = hostArgIndex >= 0 ? process.argv[hostArgIndex + 1] : process.env.HOST || "127.0.0.1";
57
+ const NO_OPEN = process.argv.includes("--no-open");
58
+ const NO_STARTUP_SCAN = process.argv.includes("--no-startup-scan");
59
+ // Maximum accepted request body size (64 KB). Rejects larger payloads with 413.
60
+ const MAX_BODY = 64 * 1024;
61
+
62
+ // ── small helpers ────────────────────────────────────────────────────
63
+ class HttpError extends Error {
64
+ constructor(status, detail) { super(detail); this.status = status; this.detail = detail; }
65
+ }
66
+
67
+ // Reads lab-config.json (selected_agent, mcp, online_agents).
68
+ function readConfig() {
69
+ if (fs.existsSync(CONFIG_FILE)) {
70
+ try { return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8")); } catch { /* fall through */ }
71
+ }
72
+ return {};
73
+ }
74
+ function writeConfig(data) {
75
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2));
76
+ }
77
+
78
+ // Reads shared config.json (agents list).
79
+ function readShared() {
80
+ if (fs.existsSync(SHARED_FILE)) {
81
+ try { return JSON.parse(fs.readFileSync(SHARED_FILE, "utf-8")); } catch { /* fall through */ }
82
+ }
83
+ return {};
84
+ }
85
+ function writeSharedAgents(agents) {
86
+ const shared = readShared();
87
+ shared.agents = agents;
88
+ fs.writeFileSync(SHARED_FILE, JSON.stringify(shared, null, 2));
89
+ }
90
+
91
+ // ── config: agents / scan results ────────────────────────────────────
92
+ function stripScheme(v) {
93
+ return String(v || "").trim().replace(/^https?:\/\//i, "").replace(/\/+$/, "");
94
+ }
95
+
96
+ // Agent records use {name, ip, port, api_key}. Default agent port is 8314.
97
+ const DEFAULT_AGENT_PORT = 8314;
98
+
99
+ function agentToUrl(a) {
100
+ if (!a || !a.ip) return "";
101
+ const host = /:\/\//.test(a.ip) ? a.ip : `http://${a.ip}`;
102
+ return `${host}:${a.port || DEFAULT_AGENT_PORT}`;
103
+ }
104
+ function urlToAgent(url, api_key = "", name = "") {
105
+ let host = String(url || "").trim().replace(/\/+$/, "");
106
+ let port = DEFAULT_AGENT_PORT;
107
+ const m = host.match(/^(.*?):(\d+)$/);
108
+ if (m && !/^https?:$/i.test(m[1])) { host = m[1]; port = Number(m[2]); }
109
+ const ip = host.replace(/^http:\/\//i, "");
110
+ // mcp-bridge identifies agents by name; omit when not specified.
111
+ return { ...(name ? { name } : {}), ip, port, api_key: api_key || "" };
112
+ }
113
+
114
+ // Loads [{url, api_key, name?}] from shared config.json.
115
+ function loadServers() {
116
+ const raw = readShared().agents;
117
+ return (Array.isArray(raw) ? raw : [])
118
+ .map(a => ({ url: agentToUrl(a), api_key: a.api_key || "", ...(a.name ? { name: a.name } : {}) }))
119
+ .filter(s => s.url);
120
+ }
121
+ function saveServers(servers) {
122
+ const merged = {};
123
+ for (const s of servers) if (s.url) merged[s.url] = s;
124
+ const agents = Object.keys(merged).sort()
125
+ .map(u => urlToAgent(u, merged[u].api_key, merged[u].name));
126
+ try { writeSharedAgents(agents); } catch { /* best effort */ }
127
+ }
128
+ // apiKey=null preserves an existing key (scan rediscovery); "" clears it.
129
+ // name: agent name from /hello - stored when non-empty, preserved otherwise.
130
+ function upsertServer(url, apiKey, name) {
131
+ const byUrl = {};
132
+ for (const s of loadServers()) byUrl[s.url] = s;
133
+ const existing = byUrl[url] || { url, api_key: "" };
134
+ if (apiKey !== null && apiKey !== undefined) existing.api_key = apiKey;
135
+ if (name) existing.name = name;
136
+ byUrl[url] = existing;
137
+ saveServers(Object.values(byUrl));
138
+ return loadServers();
139
+ }
140
+ // Returns online agents from the most recent scan snapshot as URLs, or null if no scan has run.
141
+ function loadOnlineAgents() {
142
+ const data = readConfig();
143
+ if (!("online_agents" in data)) return null;
144
+ const list = data.online_agents;
145
+ return Array.isArray(list) ? list.map(e => `http://${stripScheme(e)}`) : list;
146
+ }
147
+ function saveOnlineAgents(found) {
148
+ const data = readConfig();
149
+ data.online_agents = [...found].map(stripScheme).sort();
150
+ try { writeConfig(data); } catch { /* best effort */ }
151
+ }
152
+
153
+ // Loads the currently selected agent ({ip, port, api_key}).
154
+ function loadSelected() {
155
+ const sel = readConfig().selected_agent || {};
156
+ const ip = stripScheme(sel.ip) || "localhost";
157
+ const port = String(sel.port ?? "8314");
158
+ // Prefer explicit selection key; fallback to matching entry in shared agents list.
159
+ let key = sel.api_key;
160
+ if (!key) {
161
+ const match = loadServers().find(s => s.url === `http://${ip}:${port}`);
162
+ key = match && match.api_key;
163
+ }
164
+ return { ip, port, api_key: key };
165
+ }
166
+
167
+ function getDefaultApiConfig() {
168
+ let mcpUrl = "http://localhost:8315";
169
+ let mcpKey = process.env.MCP_API_KEY;
170
+
171
+ const data = readConfig();
172
+ const sel = loadSelected();
173
+ // Format agent target as URL for the dashboard.
174
+ const apiHost = `http://${sel.ip}`;
175
+ const apiPort = sel.port;
176
+ const apiKey = process.env.API_KEY || sel.api_key;
177
+ const mcp = data.mcp || {};
178
+ if (mcp.ip) mcpUrl = `http://${stripScheme(mcp.ip)}:${mcp.port || 8315}`;
179
+ else if (mcp.url) mcpUrl = mcp.url;
180
+ if (!mcpKey) mcpKey = mcp.api_key;
181
+
182
+ return {
183
+ api_key: apiKey || "your-secret-key",
184
+ api_host: apiHost,
185
+ api_port: apiPort,
186
+ api_url: `${apiHost}:${apiPort}`,
187
+ mcp: { url: mcpUrl, api_key: mcpKey || "your-mcp-key" },
188
+ };
189
+ }
190
+
191
+ // ── merged dashboard HTML (recursive <!--INCLUDE:--> + in-memory cache) ─
192
+ function resolveIncludes(html, baseDir, depth = 0) {
193
+ if (depth > 10) return html;
194
+ return html.replace(/<!--INCLUDE:([^>]+)-->/g, (_m, rel) => {
195
+ const p = path.join(baseDir, rel.trim());
196
+ try { return resolveIncludes(fs.readFileSync(p, "utf-8"), baseDir, depth + 1); }
197
+ catch { return `<!-- INCLUDE NOT FOUND: ${rel} -->`; }
198
+ });
199
+ }
200
+ let indexHtmlCache = null;
201
+ function loadIndexHtml() {
202
+ const htmlPath = path.join(PAGES_DIR, "main.html");
203
+ if (!fs.existsSync(htmlPath)) throw new HttpError(404, "pages/main.html file not found");
204
+ indexHtmlCache = resolveIncludes(fs.readFileSync(htmlPath, "utf-8"), __dirname);
205
+ return indexHtmlCache;
206
+ }
207
+
208
+ function sendJson(res, status, obj) {
209
+ const body = JSON.stringify(obj);
210
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Content-Length": Buffer.byteLength(body) });
211
+ res.end(body);
212
+ }
213
+ function sendHtml(res, status, html) {
214
+ res.writeHead(status, { "Content-Type": "text/html; charset=utf-8", "Content-Length": Buffer.byteLength(html) });
215
+ res.end(html);
216
+ }
217
+
218
+ // Buffer the raw request body up to the given limit.
219
+ function readRawBody(req, limit) {
220
+ return new Promise((resolve, reject) => {
221
+ const chunks = [];
222
+ let size = 0;
223
+ let over = false;
224
+ req.on("data", chunk => {
225
+ // Reject with 413 and stop accumulating when body exceeds limit.
226
+ if (over) return;
227
+ size += chunk.length;
228
+ if (size > limit) {
229
+ over = true;
230
+ chunks.length = 0;
231
+ reject(new HttpError(413, "payload too large"));
232
+ return;
233
+ }
234
+ chunks.push(chunk);
235
+ });
236
+ req.on("end", () => resolve(Buffer.concat(chunks)));
237
+ req.on("error", reject);
238
+ });
239
+ }
240
+ // Parse a JSON request body; empty body => {}.
241
+ async function readJsonBody(req, limit) {
242
+ const buf = await readRawBody(req, limit);
243
+ if (buf.length === 0) return {};
244
+ try { return JSON.parse(buf.toString("utf-8")); }
245
+ catch { throw new HttpError(400, "invalid JSON body"); }
246
+ }
247
+
248
+ const CONTENT_TYPES = {
249
+ ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8",
250
+ ".js": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8",
251
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
252
+ ".svg": "image/svg+xml", ".ico": "image/x-icon", ".webp": "image/webp", ".txt": "text/plain; charset=utf-8",
253
+ };
254
+ // Serve a static file from root directory, rejecting path traversal and dotfiles.
255
+ function sendStatic(res, root, filename) {
256
+ if (!filename || filename.startsWith(".") || filename.includes("/") || filename.includes("\\")) {
257
+ throw new HttpError(404, "not found");
258
+ }
259
+ const full = path.join(root, filename);
260
+ if (path.dirname(full) !== root) throw new HttpError(404, "not found"); // defense in depth
261
+ let data;
262
+ try { data = fs.readFileSync(full); }
263
+ catch { throw new HttpError(404, "not found"); }
264
+ const ext = path.extname(full).toLowerCase();
265
+ res.writeHead(200, { "Content-Type": CONTENT_TYPES[ext] || "application/octet-stream", "Content-Length": data.length });
266
+ res.end(data);
267
+ }
268
+
269
+ // ── route handlers ───────────────────────────────────────────────────
270
+ // General HTTP proxy - forwards any method/body to the ?url= target, relaying the
271
+ // Mcp-Session-Id response header the MCP Streamable HTTP transport relies on.
272
+ async function handleProxy(req, res, url) {
273
+ const target = url.searchParams.get("url");
274
+ if (!target) throw new HttpError(400, "url query param required");
275
+
276
+ const forwardHeaders = {};
277
+ for (const name of ["api-key", "content-type", "accept", "mcp-session-id"]) {
278
+ const val = req.headers[name];
279
+ if (val) forwardHeaders[name] = val;
280
+ }
281
+ const bodyBuf = await readRawBody(req, MAX_BODY);
282
+ const hasBody = bodyBuf.length > 0;
283
+
284
+ let r;
285
+ try {
286
+ r = await fetch(target, {
287
+ method: req.method,
288
+ headers: forwardHeaders,
289
+ body: hasBody ? bodyBuf : undefined,
290
+ signal: AbortSignal.timeout(30000),
291
+ });
292
+ } catch (e) {
293
+ throw new HttpError(502, `Could not reach target: ${e.message}`);
294
+ }
295
+
296
+ const sessionId = r.headers.get("mcp-session-id");
297
+ if (sessionId) res.setHeader("Mcp-Session-Id", sessionId);
298
+
299
+ const text = await r.text();
300
+ if ((r.headers.get("content-type") || "").includes("application/json")) {
301
+ try { return sendJson(res, r.status, JSON.parse(text)); } catch { /* fall through */ }
302
+ }
303
+ return sendJson(res, r.status, { text });
304
+ }
305
+
306
+ async function handleSaveConfig(req, res) {
307
+ const body = await readJsonBody(req, MAX_BODY);
308
+ const data = readConfig();
309
+ // The wire format keeps api_host/api_port (the dashboard's field names); on disk they
310
+ // are the `selected_agent` section of this project's own file.
311
+ if ("api_host" in body || "api_port" in body || "api_key" in body) {
312
+ const sel = data.selected_agent || {};
313
+ if ("api_host" in body) sel.ip = stripScheme(body.api_host);
314
+ if ("api_port" in body) sel.port = Number(body.api_port) || body.api_port;
315
+ if ("api_key" in body) sel.api_key = body.api_key;
316
+ data.selected_agent = sel;
317
+ }
318
+ // The MCP tab sends one URL; on disk it is ip + port, matching agents[] and selected.
319
+ const setMcpUrl = (mcp, url) => {
320
+ const bare = stripScheme(url);
321
+ const m = bare.match(/^(.*?):(\d+)$/);
322
+ mcp.ip = m ? m[1] : bare;
323
+ mcp.port = m ? Number(m[2]) : 8315;
324
+ delete mcp.url;
325
+ };
326
+ if (body.mcp && typeof body.mcp === "object") {
327
+ const mcp = data.mcp || {};
328
+ if ("url" in body.mcp) setMcpUrl(mcp, body.mcp.url);
329
+ if ("api_key" in body.mcp) mcp.api_key = body.mcp.api_key;
330
+ data.mcp = mcp;
331
+ }
332
+ if ("mcp_url" in body || "mcp_api_key" in body) {
333
+ const mcp = data.mcp || {};
334
+ if ("mcp_url" in body) setMcpUrl(mcp, body.mcp_url);
335
+ if ("mcp_api_key" in body) mcp.api_key = body.mcp_api_key;
336
+ data.mcp = mcp;
337
+ }
338
+ try { writeConfig(data); sendJson(res, 200, { status: "success" }); }
339
+ catch (e) { throw new HttpError(500, `Failed to save config: ${e.message}`); }
340
+ }
341
+
342
+ async function handleLogs(req, res, url) {
343
+ const parsedLimit = Number.parseInt(url.searchParams.get("limit") ?? "300", 10);
344
+ const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 300;
345
+ const search = url.searchParams.get("search");
346
+ const category = url.searchParams.get("category");
347
+
348
+ const config = getDefaultApiConfig();
349
+ let data;
350
+ try {
351
+ const r = await fetch(`${config.api_url}/logs`, {
352
+ headers: { "Api-Key": config.api_key },
353
+ signal: AbortSignal.timeout(10000),
354
+ });
355
+ if (!r.ok) throw new HttpError(r.status, `Pi error: ${r.status}`);
356
+ data = await r.json();
357
+ } catch (e) {
358
+ if (e instanceof HttpError) throw e;
359
+ throw new HttpError(502, `Could not reach Pi: ${e.message}`);
360
+ }
361
+
362
+ const filtered = (data.logs || []).filter(line => {
363
+ if (!line) return false;
364
+ if (search && !line.toLowerCase().includes(String(search).toLowerCase())) return false;
365
+ if (category && category !== "ALL" && !line.includes(`[${category}]`)) return false;
366
+ return true;
367
+ });
368
+ sendJson(res, 200, { logs: filtered.slice(-limit) });
369
+ }
370
+
371
+ function handleServersGet(res) {
372
+ // The agent list lives in the shared file, so that is the one whose mtime says whether
373
+ // it changed (another project may have updated it since this page loaded).
374
+ const lastModified = fs.existsSync(SHARED_FILE) ? fs.statSync(SHARED_FILE).mtimeMs / 1000 : null;
375
+ sendJson(res, 200, { servers: loadServers(), last_modified: lastModified, online_agents: loadOnlineAgents() });
376
+ }
377
+ async function handleServersPost(req, res) {
378
+ const body = await readJsonBody(req, MAX_BODY);
379
+ const url = ((body.url || "").trim()).replace(/\/+$/, "");
380
+ if (!url) throw new HttpError(400, "url is required");
381
+ const apiKey = "api_key" in body ? body.api_key : null;
382
+ const servers = upsertServer(url, apiKey, (body.name || "").trim());
383
+
384
+ let online = loadOnlineAgents() || [];
385
+ if (!online.includes(url)) {
386
+ online.push(url);
387
+ saveOnlineAgents(online);
388
+ }
389
+
390
+ sendJson(res, 200, { servers, online_agents: loadOnlineAgents() });
391
+ }
392
+
393
+ const DEFAULT_AGENT_KEY = "your-secret-key"; // agents' out-of-the-box Api-Key
394
+
395
+ // Sweep the local /24 subnet for agents answering GET /hello.
396
+ // Updates online agents and saves new agents with default key if verified.
397
+ const SCAN_TIMEOUT = 1500; // /hello timeout during a /24 sweep
398
+ const SCAN_BATCH = 48; // hosts probed at once
399
+
400
+ async function scanSubnet(port = 8314, announce = false, onFound = () => {}) {
401
+ const localIp = await detectLocalIp();
402
+ const prefix = localIp.split(".").slice(0, 3).join(".");
403
+ if (announce) console.log(`scanning: ${prefix}.0/24 (port ${port})`);
404
+ const saved = new Map(loadServers().map(server => [server.url, server]));
405
+ const found = [];
406
+ const names = {}; // url -> agent name from its /hello response
407
+ const hosts = Array.from({ length: 255 }, (_, i) => `${prefix}.${i + 1}`);
408
+ // Probe timeout and concurrency tuned for low-power boards (Zero 2 W / Pico).
409
+ await runLimited(hosts, SCAN_BATCH, async host => {
410
+ try {
411
+ const r = await fetch(`http://${host}:${port}/hello`, { signal: AbortSignal.timeout(SCAN_TIMEOUT) });
412
+ if (r.status === 200) {
413
+ const u = `http://${host}:${port}`;
414
+ found.push(u);
415
+ try { names[u] = ((await r.json()).name || "").trim(); } catch { /* non-JSON hello */ }
416
+ onFound({ url: u, name: names[u] || "", api_key: saved.get(u)?.api_key ?? DEFAULT_AGENT_KEY });
417
+ }
418
+ } catch { /* unreachable */ }
419
+ });
420
+
421
+ const storedKey = new Map(loadServers().map(s => [s.url, s.api_key || ""]));
422
+ for (const u of found) {
423
+ // Preserve saved key; for new agents, store default key only if verified.
424
+ const key = storedKey.get(u)
425
+ ? null
426
+ : (await defaultKeyWorks(u)) ? DEFAULT_AGENT_KEY : "";
427
+ upsertServer(u, key, names[u]);
428
+ }
429
+ saveOnlineAgents(found);
430
+ return { subnet: `${prefix}.0/24`, found: [...found].sort(), names };
431
+ }
432
+
433
+ async function defaultKeyWorks(baseUrl) {
434
+ try {
435
+ const r = await fetch(`${baseUrl}/config/read`, {
436
+ headers: { "Api-Key": DEFAULT_AGENT_KEY },
437
+ signal: AbortSignal.timeout(2000),
438
+ });
439
+ return r.status === 200;
440
+ } catch {
441
+ return false;
442
+ }
443
+ }
444
+
445
+ async function handleScan(req, res, url) {
446
+ const port = parseInt(url.searchParams.get("port") ?? "8314", 10);
447
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new HttpError(400, "Invalid port");
448
+ if (url.searchParams.get("stream") === "1") {
449
+ res.writeHead(200, { "Content-Type": "application/x-ndjson", "Cache-Control": "no-cache", "X-Accel-Buffering": "no" });
450
+ res.flushHeaders();
451
+ const emit = event => { if (!res.destroyed) res.write(JSON.stringify(event) + "\n"); };
452
+ try {
453
+ const result = await scanSubnet(port, false, agent => emit({ type: "agent", agent }));
454
+ emit({ type: "done", ...result });
455
+ } catch (error) {
456
+ emit({ type: "error", error: error.message });
457
+ } finally {
458
+ res.end();
459
+ }
460
+ return;
461
+ }
462
+ let result;
463
+ try { result = await scanSubnet(port); }
464
+ catch { throw new HttpError(500, "Could not determine local network interface"); }
465
+ sendJson(res, 200, result);
466
+ }
467
+
468
+ // ── router ───────────────────────────────────────────────────────────
469
+ async function dispatch(req, res) {
470
+ // CORS: allow all for local dashboard development.
471
+ res.setHeader("Access-Control-Allow-Origin", req.headers.origin || "*");
472
+ res.setHeader("Access-Control-Allow-Credentials", "true");
473
+ res.setHeader("Access-Control-Allow-Methods", "*");
474
+ res.setHeader("Access-Control-Allow-Headers", "*");
475
+ if (req.method === "OPTIONS") { res.writeHead(200); res.end(); return; }
476
+
477
+ const url = new URL(req.url, "http://localhost");
478
+ const p = url.pathname;
479
+ const m = req.method;
480
+ try {
481
+ if (p === "/api/proxy/url") return await handleProxy(req, res, url); // any method
482
+ if (p === "/" && m === "GET") {
483
+ if (indexHtmlCache === null) loadIndexHtml();
484
+ return sendHtml(res, 200, indexHtmlCache);
485
+ }
486
+ if (p === "/api/reload-cache" && m === "POST") { loadIndexHtml(); return sendJson(res, 200, { status: "reloaded" }); }
487
+ if (p === "/api/default-config" && m === "GET") {
488
+ let localNetworkPrefix = "";
489
+ try { localNetworkPrefix = (await detectLocalIp()).split(".").slice(0, 3).join(".") + "."; }
490
+ catch { /* The address can still be entered manually when offline. */ }
491
+ return sendJson(res, 200, { ...getDefaultApiConfig(), local_network_prefix: localNetworkPrefix, dev_mode: process.env.NODE_ENV === "development" });
492
+ }
493
+ if (p === "/api/save-config" && m === "POST") return await handleSaveConfig(req, res);
494
+ if (p === "/api/logs" && m === "GET") return await handleLogs(req, res, url);
495
+ if (p === "/api/servers" && m === "GET") return handleServersGet(res);
496
+ if (p === "/api/servers" && m === "POST") return await handleServersPost(req, res);
497
+ if (p === "/api/scan" && m === "GET") return await handleScan(req, res, url);
498
+ if (p.startsWith("/images/") && m === "GET") return sendStatic(res, IMAGES_DIR, decodeURIComponent(p.slice("/images/".length)));
499
+ if (p.startsWith("/pages/") && m === "GET") return sendStatic(res, PAGES_DIR, decodeURIComponent(p.slice("/pages/".length)));
500
+ throw new HttpError(404, "not found");
501
+ } catch (err) {
502
+ // JSON error response {error: detail}.
503
+ if (res.headersSent) return;
504
+ sendJson(res, err.status || 500, { error: err.detail || err.message || "error" });
505
+ }
506
+ }
507
+
508
+ // ── utilities ────────────────────────────────────────────────────────
509
+ function detectLocalIp() {
510
+ return new Promise((resolve, reject) => {
511
+ const s = dgram.createSocket("udp4");
512
+ s.once("error", e => { try { s.close(); } catch { /* ignore */ } reject(e); });
513
+ try {
514
+ s.connect(80, "8.8.8.8", () => {
515
+ try { const ip = s.address().address; s.close(); resolve(ip); }
516
+ catch (e) { try { s.close(); } catch { /* ignore */ } reject(e); }
517
+ });
518
+ } catch (e) { try { s.close(); } catch { /* ignore */ } reject(e); }
519
+ });
520
+ }
521
+
522
+ // Run an async worker over items with a bounded concurrency.
523
+ async function runLimited(items, limit, worker) {
524
+ let i = 0;
525
+ const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
526
+ while (i < items.length) { const item = items[i++]; await worker(item); }
527
+ });
528
+ await Promise.all(runners);
529
+ }
530
+
531
+ // ── start ────────────────────────────────────────────────────────────
532
+ loadIndexHtml();
533
+ const server = http.createServer((req, res) => {
534
+ dispatch(req, res).catch(err => {
535
+ if (!res.headersSent) sendJson(res, err.status || 500, { error: err.detail || err.message || "error" });
536
+ });
537
+ });
538
+ // Scan once at startup so the agent list is current without anyone pressing Scan. It runs
539
+ // after the listen callback and never blocks it: the dashboard is up immediately, and the
540
+ // list fills in when the sweep finishes.
541
+ async function startupScan() {
542
+ try {
543
+ try {
544
+ const stats = await fs.promises.stat(SHARED_FILE);
545
+ if (Date.now() - stats.mtimeMs < 5 * 60 * 1000) {
546
+ console.log("Startup scan skipped: config.json was updated less than 5 minutes ago. Running quick reachability test.");
547
+ const saved = loadServers();
548
+ const alive = [];
549
+ await Promise.all(saved.map(async s => {
550
+ try {
551
+ const r = await fetch(`${s.url}/hello`, { signal: AbortSignal.timeout(1500) });
552
+ if (r.status === 200) alive.push(s.url);
553
+ } catch (e) { }
554
+ }));
555
+ saveOnlineAgents(alive);
556
+ return;
557
+ }
558
+ } catch (e) {
559
+ // File might not exist yet, proceed with scan
560
+ }
561
+
562
+ const { found } = await scanSubnet(DEFAULT_AGENT_PORT, true);
563
+ console.log(found.length
564
+ ? `Startup scan: ${found.length} agent(s) found - ${found.join(", ")}`
565
+ : "Startup scan: no agents found on this subnet.");
566
+ } catch (e) {
567
+ console.log(`Startup scan skipped: ${e.message}`);
568
+ }
569
+ }
570
+
571
+ server.listen(PORT, HOST, () => {
572
+ const localAddr = `http://localhost:${server.address().port}`;
573
+ const divider = "-".repeat(Math.max(21, localAddr.length));
574
+ console.log(`\nctrlPi Lab started on →\n${divider}\n${localAddr}\n${divider}`);
575
+ if (!NO_OPEN) openBrowser(localAddr);
576
+ if (!NO_STARTUP_SCAN) startupScan();
577
+ });
578
+ function shutdown() { server.close(() => process.exit(0)); setTimeout(() => process.exit(0), 1000); }
579
+ process.on("SIGINT", shutdown);
580
+ process.on("SIGTERM", shutdown);
581
+
582
+ // A parent process can request graceful shutdown over IPC (also works on Windows).
583
+ if (process.send) process.on("message", message => {
584
+ if (message === "shutdown") shutdown();
585
+ });
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "@ctrlpi/ctrlpi-lab",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.9.25",
4
+ "description": "A local browser dashboard for your ctrlPi setup. Test GPIO and sensor endpoints, watch webhook events, and tail agent logs, with zero dependencies.",
5
+ "type": "module",
6
+ "main": "ctrlpi-lab.js",
7
+ "bin": {
8
+ "ctrlpi-lab": "ctrlpi-lab.js"
9
+ },
10
+ "files": [
11
+ "ctrlpi-lab.js",
12
+ "pages"
13
+ ],
14
+ "scripts": {
15
+ "dev": "NODE_ENV=development node ctrlpi-lab.js",
16
+ "test": "node --test",
17
+ "start": "node ctrlpi-lab.js",
18
+ "run": "node ctrlpi-lab.js",
19
+ "test:ci": "npm test"
20
+ },
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
24
+ "license": "MIT",
25
+ "author": "ctrlPi",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/ctrlpi/ctrlpi-lab.git"
29
+ },
30
+ "bugs": {
31
+ "url": "https://github.com/ctrlpi/ctrlpi-lab/issues"
32
+ },
33
+ "homepage": "https://github.com/ctrlpi/ctrlpi-lab#readme"
34
+ }