@dreb/dashboard 2.35.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.
Files changed (48) hide show
  1. package/README.md +146 -0
  2. package/dist/index.d.ts +30 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +251 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/server/auth.d.ts +164 -0
  7. package/dist/server/auth.d.ts.map +1 -0
  8. package/dist/server/auth.js +396 -0
  9. package/dist/server/auth.js.map +1 -0
  10. package/dist/server/event-hub.d.ts +31 -0
  11. package/dist/server/event-hub.d.ts.map +1 -0
  12. package/dist/server/event-hub.js +73 -0
  13. package/dist/server/event-hub.js.map +1 -0
  14. package/dist/server/files.d.ts +48 -0
  15. package/dist/server/files.d.ts.map +1 -0
  16. package/dist/server/files.js +194 -0
  17. package/dist/server/files.js.map +1 -0
  18. package/dist/server/pairing-storage.d.ts +18 -0
  19. package/dist/server/pairing-storage.d.ts.map +1 -0
  20. package/dist/server/pairing-storage.js +83 -0
  21. package/dist/server/pairing-storage.js.map +1 -0
  22. package/dist/server/runtime-pool.d.ts +89 -0
  23. package/dist/server/runtime-pool.d.ts.map +1 -0
  24. package/dist/server/runtime-pool.js +377 -0
  25. package/dist/server/runtime-pool.js.map +1 -0
  26. package/dist/server/server.d.ts +30 -0
  27. package/dist/server/server.d.ts.map +1 -0
  28. package/dist/server/server.js +557 -0
  29. package/dist/server/server.js.map +1 -0
  30. package/dist/server/subagent-log.d.ts +42 -0
  31. package/dist/server/subagent-log.d.ts.map +1 -0
  32. package/dist/server/subagent-log.js +155 -0
  33. package/dist/server/subagent-log.js.map +1 -0
  34. package/dist/shared/protocol.d.ts +261 -0
  35. package/dist/shared/protocol.d.ts.map +1 -0
  36. package/dist/shared/protocol.js +26 -0
  37. package/dist/shared/protocol.js.map +1 -0
  38. package/dist/static/assets/index-Cgwg2zRA.js +77 -0
  39. package/dist/static/assets/index-DnFrQoI9.css +1 -0
  40. package/dist/static/icons/apple-touch-icon.png +0 -0
  41. package/dist/static/icons/favicon-32.png +0 -0
  42. package/dist/static/icons/punk-1024.png +0 -0
  43. package/dist/static/icons/punk-192.png +0 -0
  44. package/dist/static/icons/punk-512.png +0 -0
  45. package/dist/static/index.html +32 -0
  46. package/dist/static/manifest.webmanifest +30 -0
  47. package/dist/static/sw.js +110 -0
  48. package/package.json +57 -0
@@ -0,0 +1,557 @@
1
+ /**
2
+ * Dashboard HTTP server — Express app wiring auth, the runtime pool, the SSE
3
+ * hub, and the file API into the REST surface the browser client consumes.
4
+ *
5
+ * Bind address discipline: local mode binds 127.0.0.1 only. The
6
+ * caller decides the bind address; `createDashboardServer` never listens by
7
+ * itself. Remote mode still passes every request through DashboardAuth.
8
+ */
9
+ import { existsSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { basename, join } from "node:path";
12
+ import express from "express";
13
+ import { MAX_PROMPT_BODY_BYTES } from "../shared/protocol.js";
14
+ import { EventHub } from "./event-hub.js";
15
+ import { defaultPlaces, FileApi } from "./files.js";
16
+ import { readSubagentMessages } from "./subagent-log.js";
17
+ const DEVICE_COOKIE = "dreb_dashboard_device";
18
+ export const MAX_SSE_BUFFERED_BYTES = 4 * 1024 * 1024;
19
+ /** Parse the device cookie from a Cookie header. */
20
+ export function parseDeviceCookie(cookieHeader) {
21
+ if (!cookieHeader)
22
+ return undefined;
23
+ for (const part of cookieHeader.split(";")) {
24
+ const eq = part.indexOf("=");
25
+ if (eq === -1)
26
+ continue;
27
+ if (part.slice(0, eq).trim() === DEVICE_COOKIE)
28
+ return part.slice(eq + 1).trim();
29
+ }
30
+ return undefined;
31
+ }
32
+ export function createDashboardServer(options) {
33
+ const { auth, pool } = options;
34
+ const serverStartedAt = new Date().toISOString();
35
+ const log = options.logger ?? ((line) => console.log(`[dashboard] ${line}`));
36
+ const files = new FileApi((op, path, detail) => log(`file ${op}: ${path}${detail ? ` (${detail})` : ""}`));
37
+ const hub = new EventHub();
38
+ pool.onEvent((key, event) => hub.publish(key, event));
39
+ const app = express();
40
+ app.disable("x-powered-by");
41
+ app.use(express.json({ limit: MAX_PROMPT_BODY_BYTES }));
42
+ // -- auth middleware (every route, fail-closed) ---------------------------
43
+ app.use((req, res, next) => {
44
+ auth
45
+ .authenticate({
46
+ remoteAddress: req.socket.remoteAddress,
47
+ hostHeader: req.headers.host,
48
+ originHeader: req.headers.origin,
49
+ deviceToken: parseDeviceCookie(req.headers.cookie),
50
+ })
51
+ .then((decision) => {
52
+ req.authDecision = decision;
53
+ if (decision.allowed)
54
+ return next();
55
+ const canRenderAuthScreen = decision.needsPairing || Boolean(decision.identity);
56
+ if (canRenderAuthScreen) {
57
+ // The auth/pairing endpoints must be reachable by allowed-but-unpaired
58
+ // identities, and /api/auth must also be reachable by rejected
59
+ // Tailscale identities so the SPA denial screen can name them.
60
+ if (req.path === "/api/auth" || (decision.needsPairing && req.path === "/api/pair"))
61
+ return next();
62
+ // Let the SPA shell + static assets load so the client-side pairing or
63
+ // denial screen can render. No data exposure: every /api/* data route
64
+ // below stays fail-closed — only non-API GETs (the app shell) are allowed.
65
+ if (req.method === "GET" && !req.path.startsWith("/api/"))
66
+ return next();
67
+ }
68
+ log(`denied ${req.method} ${req.path}: ${decision.reason}`);
69
+ res.status(decision.status).json({
70
+ error: decision.reason,
71
+ needsPairing: decision.needsPairing ?? false,
72
+ identity: decision.identity?.loginName,
73
+ });
74
+ })
75
+ .catch((err) => {
76
+ // authenticate() already catches internally; this is belt-and-suspenders.
77
+ log(`auth middleware error — denying: ${err instanceof Error ? err.message : String(err)}`);
78
+ res.status(500).json({ error: "Auth subsystem error — denied" });
79
+ });
80
+ });
81
+ // -- auth/pairing ----------------------------------------------------------
82
+ app.get("/api/auth", (req, res) => {
83
+ const decision = req.authDecision;
84
+ if (decision.allowed) {
85
+ const status = decision.mode === "local"
86
+ ? { mode: "local" }
87
+ : { mode: "remote", identity: decision.identity.loginName, device: decision.identity.device };
88
+ res.json({ ...status, needsPairing: false });
89
+ return;
90
+ }
91
+ res.status(decision.status).json({
92
+ error: decision.reason,
93
+ needsPairing: decision.needsPairing ?? false,
94
+ identity: decision.identity?.loginName,
95
+ });
96
+ });
97
+ app.get("/api/pairing-code", (req, res) => {
98
+ const decision = req.authDecision;
99
+ if (!decision.allowed || decision.mode !== "local") {
100
+ res.status(403).json({ error: "Pairing code is only available from the host machine" });
101
+ return;
102
+ }
103
+ if (!auth.isRemoteEnabled) {
104
+ const body = { enabled: false };
105
+ res.json(body);
106
+ return;
107
+ }
108
+ const body = { enabled: true, ...auth.currentPairingCode() };
109
+ res.json(body);
110
+ });
111
+ app.post("/api/pair", (req, res) => {
112
+ const pin = typeof req.body?.pin === "string" ? req.body.pin : "";
113
+ auth
114
+ .pair({
115
+ remoteAddress: req.socket.remoteAddress,
116
+ hostHeader: req.headers.host,
117
+ originHeader: req.headers.origin,
118
+ deviceToken: undefined,
119
+ }, pin)
120
+ .then(({ token, device }) => {
121
+ log(`paired device ${device.id} (${device.identity})`);
122
+ res.cookie(DEVICE_COOKIE, token, {
123
+ httpOnly: true,
124
+ sameSite: "strict",
125
+ secure: false, // Tailscale already encrypts; the dashboard serves plain HTTP on the tailnet.
126
+ expires: new Date(device.expiresAt),
127
+ }).json({ device });
128
+ })
129
+ .catch((err) => {
130
+ const status = typeof err?.status === "number" ? err.status : 500;
131
+ log(`pairing failed: ${err instanceof Error ? err.message : String(err)}`);
132
+ res.status(status).json({ error: err instanceof Error ? err.message : String(err) });
133
+ });
134
+ });
135
+ app.get("/api/devices", (_req, res) => {
136
+ auth
137
+ .listDevices()
138
+ .then((devices) => res.json({ devices }))
139
+ .catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));
140
+ });
141
+ app.delete("/api/devices/:id", (req, res) => {
142
+ auth
143
+ .unpair(req.params.id)
144
+ .then((removed) => {
145
+ if (!removed) {
146
+ res.status(404).json({ error: `No paired device with id ${String(req.params.id)}` });
147
+ return;
148
+ }
149
+ log(`unpaired device ${String(req.params.id)}`);
150
+ res.json({ ok: true });
151
+ })
152
+ .catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));
153
+ });
154
+ // -- events (SSE) ----------------------------------------------------------
155
+ app.get("/api/events", (req, res) => {
156
+ res.writeHead(200, {
157
+ "content-type": "text/event-stream",
158
+ "cache-control": "no-cache",
159
+ connection: "keep-alive",
160
+ });
161
+ const guardedWrite = (chunk, context) => {
162
+ if (res.destroyed || res.writableEnded)
163
+ return false;
164
+ const accepted = res.write(chunk);
165
+ if (!accepted && res.writableLength > MAX_SSE_BUFFERED_BYTES) {
166
+ log(`SSE client buffer exceeded ${MAX_SSE_BUFFERED_BYTES} bytes during ${context} (${res.writableLength} bytes queued); destroying connection`);
167
+ res.destroy();
168
+ return false;
169
+ }
170
+ return true;
171
+ };
172
+ if (!guardedWrite(":ok\n\n", "initial handshake"))
173
+ return;
174
+ const lastIdRaw = req.headers["last-event-id"] ?? req.query.lastEventId;
175
+ const lastEventId = typeof lastIdRaw === "string" && /^\d+$/.test(lastIdRaw) ? Number.parseInt(lastIdRaw, 10) : undefined;
176
+ const detach = hub.attach({ write: (chunk) => guardedWrite(chunk, "event fanout") }, lastEventId);
177
+ const keepAlive = setInterval(() => {
178
+ guardedWrite(":ka\n\n", "keepalive");
179
+ }, 25_000);
180
+ req.on("close", () => {
181
+ clearInterval(keepAlive);
182
+ detach();
183
+ });
184
+ });
185
+ // -- fleet -----------------------------------------------------------------
186
+ app.get("/api/fleet", (_req, res) => {
187
+ (async () => {
188
+ const runtimes = await Promise.all(pool.list().map((h) => pool.describe(h)));
189
+ const diskSessions = (await options.listAllSessions()).filter((session) => existsSync(session.cwd));
190
+ const fleet = { runtimes, diskSessions };
191
+ res.json(fleet);
192
+ })().catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));
193
+ });
194
+ // -- runtimes ---------------------------------------------------------------
195
+ app.post("/api/runtimes", (req, res) => {
196
+ (async () => {
197
+ const cwd = typeof req.body?.cwd === "string" ? req.body.cwd : "";
198
+ if (!cwd || !existsSync(cwd)) {
199
+ res.status(400).json({ error: `Working directory does not exist: ${cwd || "(empty)"}` });
200
+ return;
201
+ }
202
+ const sessionPath = typeof req.body?.sessionPath === "string" ? req.body.sessionPath : undefined;
203
+ const handle = await pool.create(cwd, sessionPath);
204
+ log(`runtime ${handle.key} started in ${cwd}${sessionPath ? ` (resume ${basename(sessionPath)})` : ""}`);
205
+ const firstPrompt = typeof req.body?.firstPrompt === "string" ? req.body.firstPrompt : undefined;
206
+ if (firstPrompt)
207
+ await handle.client.prompt(firstPrompt);
208
+ res.status(201).json(await pool.describe(handle));
209
+ })().catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));
210
+ });
211
+ app.delete("/api/runtimes/:key", (req, res) => {
212
+ pool
213
+ .stop(req.params.key)
214
+ .then((stopped) => {
215
+ if (!stopped) {
216
+ res.status(404).json({ error: `No runtime ${String(req.params.key)}` });
217
+ return;
218
+ }
219
+ log(`runtime ${String(req.params.key)} stopped`);
220
+ res.json({ ok: true });
221
+ })
222
+ .catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));
223
+ });
224
+ /** Helper: run an async op against a pooled runtime with uniform errors. */
225
+ function withRuntime(req, res, fn) {
226
+ const handle = pool.get(String(req.params.key));
227
+ if (!handle) {
228
+ res.status(404).json({ error: `No runtime ${String(req.params.key)}` });
229
+ return;
230
+ }
231
+ fn(handle)
232
+ .then((data) => res.json(data ?? { ok: true }))
233
+ .catch((err) => {
234
+ res.status(502).json({ error: String(err?.message ?? err) });
235
+ });
236
+ }
237
+ app.get("/api/runtimes/:key", (req, res) => {
238
+ withRuntime(req, res, (h) => pool.describe(h));
239
+ });
240
+ app.get("/api/runtimes/:key/messages", (req, res) => {
241
+ withRuntime(req, res, async (h) => ({ messages: await h.client.getMessages() }));
242
+ });
243
+ app.get("/api/runtimes/:key/pending", (req, res) => {
244
+ withRuntime(req, res, (h) => h.client.getPendingMessages());
245
+ });
246
+ app.post("/api/runtimes/:key/dequeue", (req, res) => {
247
+ withRuntime(req, res, (h) => h.client.clearPendingMessages());
248
+ });
249
+ function parseImages(body) {
250
+ const images = body?.images;
251
+ if (images === undefined)
252
+ return undefined;
253
+ if (!Array.isArray(images))
254
+ return "invalid";
255
+ const parsed = [];
256
+ for (const image of images) {
257
+ if (!image ||
258
+ typeof image !== "object" ||
259
+ typeof image.data !== "string" ||
260
+ typeof image.mimeType !== "string") {
261
+ return "invalid";
262
+ }
263
+ parsed.push({ data: image.data, mimeType: image.mimeType });
264
+ }
265
+ return parsed;
266
+ }
267
+ app.post("/api/runtimes/:key/prompt", (req, res) => {
268
+ const { message, mode } = req.body ?? {};
269
+ if (typeof message !== "string" || message.length === 0) {
270
+ res.status(400).json({ error: "message is required" });
271
+ return;
272
+ }
273
+ const images = parseImages(req.body);
274
+ if (images === "invalid") {
275
+ res.status(400).json({ error: "images must be an array of {data, mimeType} objects" });
276
+ return;
277
+ }
278
+ const rpcImages = images?.map((image) => ({
279
+ type: "image",
280
+ data: image.data,
281
+ mimeType: image.mimeType,
282
+ }));
283
+ withRuntime(req, res, async (h) => {
284
+ if (mode === "steer")
285
+ await h.client.steer(message, rpcImages);
286
+ else if (mode === "follow_up")
287
+ await h.client.followUp(message, rpcImages);
288
+ else
289
+ await h.client.prompt(message, rpcImages);
290
+ });
291
+ });
292
+ app.post("/api/runtimes/:key/abort", (req, res) => {
293
+ withRuntime(req, res, (h) => h.client.abort());
294
+ });
295
+ app.post("/api/runtimes/:key/abort-compaction", (req, res) => {
296
+ withRuntime(req, res, (h) => h.client.abortCompaction());
297
+ });
298
+ app.post("/api/runtimes/:key/abort-retry", (req, res) => {
299
+ withRuntime(req, res, (h) => h.client.abortRetry());
300
+ });
301
+ app.post("/api/runtimes/:key/model", (req, res) => {
302
+ const { provider, modelId } = req.body ?? {};
303
+ if (typeof provider !== "string" || typeof modelId !== "string") {
304
+ res.status(400).json({ error: "provider and modelId are required" });
305
+ return;
306
+ }
307
+ withRuntime(req, res, (h) => h.client.setModel(provider, modelId));
308
+ });
309
+ app.get("/api/runtimes/:key/models", (req, res) => {
310
+ withRuntime(req, res, async (h) => ({ models: await h.client.getAvailableModels() }));
311
+ });
312
+ app.post("/api/runtimes/:key/thinking", (req, res) => {
313
+ const { level } = req.body ?? {};
314
+ if (typeof level !== "string") {
315
+ res.status(400).json({ error: "level is required" });
316
+ return;
317
+ }
318
+ withRuntime(req, res, (h) => h.client.setThinkingLevel(level));
319
+ });
320
+ app.post("/api/runtimes/:key/compact", (req, res) => {
321
+ const instructions = typeof req.body?.instructions === "string" ? req.body.instructions : undefined;
322
+ withRuntime(req, res, (h) => h.client.compact(instructions));
323
+ });
324
+ app.post("/api/runtimes/:key/name", (req, res) => {
325
+ const { name } = req.body ?? {};
326
+ if (typeof name !== "string" || name.length === 0) {
327
+ res.status(400).json({ error: "name is required" });
328
+ return;
329
+ }
330
+ withRuntime(req, res, (h) => h.client.setSessionName(name));
331
+ });
332
+ app.get("/api/runtimes/:key/stats", (req, res) => {
333
+ withRuntime(req, res, (h) => h.client.getSessionStats());
334
+ });
335
+ app.get("/api/runtimes/:key/performance", (req, res) => {
336
+ withRuntime(req, res, (h) => h.client.getPerformanceStats());
337
+ });
338
+ app.get("/api/runtimes/:key/resources", (req, res) => {
339
+ withRuntime(req, res, (h) => h.client.getResources());
340
+ });
341
+ app.get("/api/runtimes/:key/commands", (req, res) => {
342
+ withRuntime(req, res, async (h) => ({ commands: await h.client.getCommands() }));
343
+ });
344
+ app.get("/api/runtimes/:key/branch", (req, res) => {
345
+ withRuntime(req, res, async (h) => ({ branch: await h.client.getGitBranch() }));
346
+ });
347
+ app.get("/api/runtimes/:key/fork-messages", (req, res) => {
348
+ withRuntime(req, res, async (h) => ({ messages: await h.client.getForkMessages() }));
349
+ });
350
+ app.post("/api/runtimes/:key/fork", (req, res) => {
351
+ const { entryId } = req.body ?? {};
352
+ if (typeof entryId !== "string") {
353
+ res.status(400).json({ error: "entryId is required" });
354
+ return;
355
+ }
356
+ withRuntime(req, res, (h) => h.client.fork(entryId));
357
+ });
358
+ app.get("/api/runtimes/:key/export-html", (req, res) => {
359
+ const handle = pool.get(String(req.params.key));
360
+ if (!handle) {
361
+ res.status(404).json({ error: `No runtime ${String(req.params.key)}` });
362
+ return;
363
+ }
364
+ handle.client
365
+ .exportHtml()
366
+ .then(({ path }) => {
367
+ res.download(path);
368
+ })
369
+ .catch((err) => res.status(502).json({ error: String(err?.message ?? err) }));
370
+ });
371
+ app.get("/api/runtimes/:key/background-agents", (req, res) => {
372
+ withRuntime(req, res, async (h) => ({ agents: await h.client.listBackgroundAgents() }));
373
+ });
374
+ app.get("/api/runtimes/:key/subagents/:agentId/messages", (req, res) => {
375
+ const agentId = String(req.params.agentId);
376
+ withRuntime(req, res, async (h) => {
377
+ // The runtime's registry is authoritative for status + log location.
378
+ const agents = await h.client.listBackgroundAgents();
379
+ const agent = agents.find((a) => a.agentId === agentId);
380
+ if (!agent)
381
+ throw new Error(`No background agent ${agentId} in this runtime`);
382
+ const messages = readSubagentMessages(agent);
383
+ return { agent, messages };
384
+ });
385
+ });
386
+ app.post("/api/runtimes/:key/extension-ui-response", (req, res) => {
387
+ const handle = pool.get(String(req.params.key));
388
+ if (!handle) {
389
+ res.status(404).json({ error: `No runtime ${String(req.params.key)}` });
390
+ return;
391
+ }
392
+ try {
393
+ handle.client.sendExtensionUIResponse(req.body);
394
+ res.json({ ok: true });
395
+ }
396
+ catch (err) {
397
+ res.status(502).json({ error: String(err?.message ?? err) });
398
+ }
399
+ });
400
+ // -- disk sessions -----------------------------------------------------------
401
+ app.delete("/api/sessions", (req, res) => {
402
+ const path = typeof req.body?.path === "string" ? req.body.path : "";
403
+ if (!path) {
404
+ res.status(400).json({ error: "path is required" });
405
+ return;
406
+ }
407
+ options
408
+ .deleteSession(path)
409
+ .then((result) => {
410
+ log(`session deleted: ${path}`);
411
+ res.json(result ?? { ok: true });
412
+ })
413
+ .catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));
414
+ });
415
+ // -- settings ------------------------------------------------------------------
416
+ // Settings are process-global persistent defaults. They route through hidden
417
+ // utility runtimes instead of whichever user session happened to open first.
418
+ // Agent-definition discovery is cwd-sensitive, so callers may pass an explicit
419
+ // project cwd for endpoints that need project-local .dreb/agents.
420
+ function withAnyRuntime(res, fn, cwd) {
421
+ pool
422
+ .ensureUtilityRuntime(cwd)
423
+ .then((handle) => fn(handle))
424
+ .then((data) => res.json(data ?? { ok: true }))
425
+ .catch((err) => {
426
+ res.status(502).json({ error: String(err?.message ?? err) });
427
+ });
428
+ }
429
+ app.get("/api/settings", (_req, res) => {
430
+ withAnyRuntime(res, (h) => h.client.getSettings());
431
+ });
432
+ app.get("/api/settings/models", (_req, res) => {
433
+ withAnyRuntime(res, async (h) => ({ models: await h.client.getAvailableModels() }));
434
+ });
435
+ app.get("/api/settings/agent-types", (req, res) => {
436
+ const cwd = typeof req.query.cwd === "string" && req.query.cwd.trim() ? req.query.cwd : undefined;
437
+ if (cwd && !existsSync(cwd)) {
438
+ res.status(400).json({ error: `cwd does not exist: ${cwd}` });
439
+ return;
440
+ }
441
+ withAnyRuntime(res, async (h) => ({ agentTypes: await h.client.listAgentTypes() }), cwd);
442
+ });
443
+ app.get("/api/daily-cost", (_req, res) => {
444
+ withAnyRuntime(res, async (h) => ({ cost: await h.client.getDailyCost() }));
445
+ });
446
+ app.put("/api/settings", (req, res) => {
447
+ withAnyRuntime(res, (h) => h.client.setSettings(req.body ?? {}));
448
+ });
449
+ app.get("/api/version", (_req, res) => {
450
+ withAnyRuntime(res, async (h) => ({ version: await h.client.getVersion() }));
451
+ });
452
+ // -- server lifecycle ----------------------------------------------------------
453
+ // Build/version of the *server* process (distinct from a freshly-spawned RPC
454
+ // child's version) so a stale long-running service is visible at a glance.
455
+ app.get("/api/server/info", (_req, res) => {
456
+ res.json({
457
+ version: options.serverVersion ?? null,
458
+ startedAt: serverStartedAt,
459
+ // systemd sets INVOCATION_ID; other supervisors set LISTEN_PID. Best-effort.
460
+ supervised: Boolean(process.env.INVOCATION_ID || process.env.LISTEN_PID),
461
+ restartable: Boolean(options.onRestart),
462
+ });
463
+ });
464
+ app.post("/api/server/restart", (_req, res) => {
465
+ if (!options.onRestart) {
466
+ res.status(501).json({
467
+ error: "Restart is unavailable — the dashboard is not running under a supervisor that can respawn it",
468
+ });
469
+ return;
470
+ }
471
+ log("restart requested via API");
472
+ res.json({ ok: true, restarting: true });
473
+ // Defer so the HTTP response flushes before the process exits.
474
+ setTimeout(() => options.onRestart?.(), 100);
475
+ });
476
+ // -- files -----------------------------------------------------------------------
477
+ app.get("/api/files", (req, res) => {
478
+ const path = typeof req.query.path === "string" ? req.query.path : homedir();
479
+ files
480
+ .list(path)
481
+ .then((listing) => res.json(listing))
482
+ .catch((err) => res.status(err?.status ?? 500).json({ error: String(err?.message ?? err) }));
483
+ });
484
+ app.get("/api/files/places", (_req, res) => {
485
+ const roots = [...new Set(pool.list().map((h) => h.cwd))];
486
+ res.json({ places: defaultPlaces(homedir(), roots) });
487
+ });
488
+ app.get("/api/files/download", (req, res) => {
489
+ const path = typeof req.query.path === "string" ? req.query.path : "";
490
+ files
491
+ .resolveDownload(path)
492
+ .then(({ path: real }) => {
493
+ res.download(real);
494
+ })
495
+ .catch((err) => res.status(err?.status ?? 500).json({ error: String(err?.message ?? err) }));
496
+ });
497
+ app.post("/api/files/upload", (req, res) => {
498
+ (async () => {
499
+ const dir = typeof req.query.dir === "string" ? req.query.dir : "";
500
+ const name = typeof req.query.name === "string" ? req.query.name : "";
501
+ const overwrite = req.query.overwrite === "true";
502
+ const upload = await files.prepareUpload(dir, name, overwrite);
503
+ try {
504
+ await new Promise((resolve, reject) => {
505
+ let settled = false;
506
+ const fail = (err) => {
507
+ if (settled)
508
+ return;
509
+ settled = true;
510
+ upload.stream.destroy();
511
+ reject(err);
512
+ };
513
+ req.pipe(upload.stream);
514
+ upload.stream.on("finish", () => {
515
+ if (settled)
516
+ return;
517
+ settled = true;
518
+ resolve();
519
+ });
520
+ upload.stream.on("error", fail);
521
+ req.on("error", fail);
522
+ req.on("aborted", () => fail(Object.assign(new Error("Upload aborted"), { status: 499 })));
523
+ });
524
+ await upload.commit();
525
+ res.json({ path: upload.path });
526
+ }
527
+ catch (err) {
528
+ await upload.cleanup();
529
+ throw err;
530
+ }
531
+ })().catch((err) => {
532
+ if (!res.headersSent)
533
+ res.status(err?.status ?? 500).json({ error: String(err?.message ?? err) });
534
+ });
535
+ });
536
+ app.post("/api/files/mkdir", (req, res) => {
537
+ const { dir, name } = req.body ?? {};
538
+ if (typeof dir !== "string" || typeof name !== "string") {
539
+ res.status(400).json({ error: "dir and name are required" });
540
+ return;
541
+ }
542
+ files
543
+ .mkdir(dir, name)
544
+ .then((path) => res.json({ path }))
545
+ .catch((err) => res.status(err?.status ?? 500).json({ error: String(err?.message ?? err) }));
546
+ });
547
+ // -- static client -----------------------------------------------------------------
548
+ if (options.staticDir) {
549
+ app.use(express.static(options.staticDir));
550
+ // SPA fallback: serve index.html for non-API GETs (client-side routing).
551
+ app.get(/^\/(?!api\/).*/, (_req, res) => {
552
+ res.sendFile(join(options.staticDir, "index.html"));
553
+ });
554
+ }
555
+ return app;
556
+ }
557
+ //# sourceMappingURL=server.js.map