leedab 0.1.9 → 0.2.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/dist/agent/resolveMember.d.ts +21 -0
- package/dist/agent/resolveMember.js +44 -0
- package/dist/dashboard/routes.d.ts +12 -0
- package/dist/dashboard/routes.js +235 -15
- package/dist/dashboard/server.js +6 -1
- package/dist/dashboard/static/admin.html +688 -0
- package/dist/dashboard/static/index.html +4 -26
- package/dist/dashboard/static/sessions.html +20 -24
- package/dist/license.d.ts +3 -0
- package/dist/license.js +9 -2
- package/dist/team/permissions.d.ts +65 -0
- package/dist/team/permissions.js +138 -0
- package/dist/team/syncAllowlists.d.ts +7 -0
- package/dist/team/syncAllowlists.js +62 -0
- package/dist/team.d.ts +10 -7
- package/dist/team.js +62 -68
- package/dist/templates/verticals/supply-chain/WORKFLOWS.md +5 -0
- package/dist/workflows/registry.d.ts +22 -0
- package/dist/workflows/registry.js +46 -0
- package/package.json +1 -1
- package/dist/dashboard/static/app.js +0 -351
- package/dist/dashboard/static/console.html +0 -252
- package/dist/dashboard/static/settings.html +0 -274
- package/dist/dashboard/static/team.html +0 -215
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type ChannelName } from "../team/permissions.js";
|
|
2
|
+
import type { Role } from "../team.js";
|
|
3
|
+
export interface ResolvedMember {
|
|
4
|
+
memberId: string;
|
|
5
|
+
name: string;
|
|
6
|
+
role: Role;
|
|
7
|
+
allowedWorkflows: string[];
|
|
8
|
+
allowedChannels: ChannelName[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Normalize a channel user id or handle for comparison.
|
|
12
|
+
* WhatsApp reports E.164 like "+13025551234" or "13025551234@c.us".
|
|
13
|
+
* Telegram reports a numeric id.
|
|
14
|
+
* Teams reports an Azure AD object id.
|
|
15
|
+
*/
|
|
16
|
+
export declare function normalizeHandle(value: string | undefined): string;
|
|
17
|
+
/**
|
|
18
|
+
* Look up a resolved member for a channel/userId pair.
|
|
19
|
+
* Returns null when no member in the overlay owns that handle on that channel.
|
|
20
|
+
*/
|
|
21
|
+
export declare function resolveMember(channel: ChannelName, userId: string): Promise<ResolvedMember | null>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readOverlay } from "../team/permissions.js";
|
|
2
|
+
/**
|
|
3
|
+
* Normalize a channel user id or handle for comparison.
|
|
4
|
+
* WhatsApp reports E.164 like "+13025551234" or "13025551234@c.us".
|
|
5
|
+
* Telegram reports a numeric id.
|
|
6
|
+
* Teams reports an Azure AD object id.
|
|
7
|
+
*/
|
|
8
|
+
export function normalizeHandle(value) {
|
|
9
|
+
if (!value)
|
|
10
|
+
return "";
|
|
11
|
+
return value
|
|
12
|
+
.trim()
|
|
13
|
+
.toLowerCase()
|
|
14
|
+
.replace(/[\s\-()]/g, "")
|
|
15
|
+
.replace(/^@/, "")
|
|
16
|
+
.replace(/^\+/, "")
|
|
17
|
+
.replace(/@c\.us$/, "")
|
|
18
|
+
.replace(/@s\.whatsapp\.net$/, "");
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Look up a resolved member for a channel/userId pair.
|
|
22
|
+
* Returns null when no member in the overlay owns that handle on that channel.
|
|
23
|
+
*/
|
|
24
|
+
export async function resolveMember(channel, userId) {
|
|
25
|
+
const needle = normalizeHandle(userId);
|
|
26
|
+
if (!needle)
|
|
27
|
+
return null;
|
|
28
|
+
const overlay = await readOverlay();
|
|
29
|
+
for (const [id, perms] of Object.entries(overlay.members)) {
|
|
30
|
+
if (channel === "dashboard")
|
|
31
|
+
continue;
|
|
32
|
+
const handle = perms.handles[channel];
|
|
33
|
+
if (handle && normalizeHandle(handle) === needle) {
|
|
34
|
+
return {
|
|
35
|
+
memberId: id,
|
|
36
|
+
name: perms.name,
|
|
37
|
+
role: perms.role,
|
|
38
|
+
allowedWorkflows: [...(perms.allowedWorkflows ?? [])],
|
|
39
|
+
allowedChannels: [...(perms.allowedChannels ?? [])],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
2
|
import type { LeedABConfig } from "../config/schema.js";
|
|
3
|
+
import { type ChannelName } from "../team/permissions.js";
|
|
3
4
|
type RouteHandler = (req: IncomingMessage, res: ServerResponse, url: URL) => Promise<void>;
|
|
4
5
|
export declare function createRoutes(config: LeedABConfig): Record<string, RouteHandler>;
|
|
6
|
+
/**
|
|
7
|
+
* Build the permission preamble injected into the agent prompt for a given
|
|
8
|
+
* channel message. Returns null when we have nothing to add (unknown user
|
|
9
|
+
* on a non-dashboard channel — the allowlist will already have rejected
|
|
10
|
+
* those).
|
|
11
|
+
*
|
|
12
|
+
* This preamble is the sole workflow-permission gate. We trust the agent to
|
|
13
|
+
* honor it. There is no server-side hard gate; admins restrict access by
|
|
14
|
+
* naming the workflows a member may use in the team page.
|
|
15
|
+
*/
|
|
16
|
+
export declare function buildPermissionPreamble(channel: ChannelName, userId: string): Promise<string | null>;
|
|
5
17
|
export {};
|
package/dist/dashboard/routes.js
CHANGED
|
@@ -5,9 +5,14 @@ import { promisify } from "node:util";
|
|
|
5
5
|
import { userInfo } from "node:os";
|
|
6
6
|
import { resolveOpenClawBin, openclawEnv } from "../openclaw.js";
|
|
7
7
|
import { addEntry, removeEntry, listEntries } from "../vault.js";
|
|
8
|
-
import { readAuditLog } from "../audit.js";
|
|
8
|
+
import { readAuditLog, logAudit } from "../audit.js";
|
|
9
9
|
import { getAnalytics } from "../analytics.js";
|
|
10
|
-
import { loadTeam, addMember, removeMember, updateRole } from "../team.js";
|
|
10
|
+
import { loadTeam, addMember, removeMember, updateRole, } from "../team.js";
|
|
11
|
+
import { loadLicense } from "../license.js";
|
|
12
|
+
import { setMemberPermissions, } from "../team/permissions.js";
|
|
13
|
+
import { syncAllowlists } from "../team/syncAllowlists.js";
|
|
14
|
+
import { resolveMember } from "../agent/resolveMember.js";
|
|
15
|
+
import { WORKFLOWS, getWorkflow } from "../workflows/registry.js";
|
|
11
16
|
import { STATE_DIR } from "../paths.js";
|
|
12
17
|
const execFileAsync = promisify(execFile);
|
|
13
18
|
async function restartGatewayQuietly(stateDir) {
|
|
@@ -35,10 +40,16 @@ export function createRoutes(config) {
|
|
|
35
40
|
json(res, { error: "message is required" }, 400);
|
|
36
41
|
return;
|
|
37
42
|
}
|
|
43
|
+
// Permission enforcement lives in the prompt preamble. The dashboard
|
|
44
|
+
// chat runs as the local admin / install owner, so the preamble here
|
|
45
|
+
// is informational ("assisting <admin>"). The same builder is the
|
|
46
|
+
// single gate used by channel handlers when we wire them up.
|
|
47
|
+
const preamble = await buildPermissionPreamble("dashboard", userInfo().username);
|
|
48
|
+
const agentMessage = preamble ? `${preamble}\n\n${message}` : message;
|
|
38
49
|
try {
|
|
39
50
|
const args = [
|
|
40
51
|
"agent",
|
|
41
|
-
"--message",
|
|
52
|
+
"--message", agentMessage,
|
|
42
53
|
"--session-id", session ?? "console",
|
|
43
54
|
"--json",
|
|
44
55
|
];
|
|
@@ -251,6 +262,36 @@ export function createRoutes(config) {
|
|
|
251
262
|
catch { }
|
|
252
263
|
}
|
|
253
264
|
}
|
|
265
|
+
// Enrich each session with sender name and first message summary
|
|
266
|
+
await Promise.all(results.map(async (s) => {
|
|
267
|
+
try {
|
|
268
|
+
const filePath = resolve(sessionsDir, `${s.sessionId}.jsonl`);
|
|
269
|
+
const raw = await readFile(filePath, "utf-8");
|
|
270
|
+
const firstMsg = raw.split("\n").find((line) => {
|
|
271
|
+
if (!line.trim())
|
|
272
|
+
return false;
|
|
273
|
+
try {
|
|
274
|
+
const obj = JSON.parse(line);
|
|
275
|
+
return obj.type === "message" && obj.message?.role === "user";
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
if (!firstMsg)
|
|
282
|
+
return;
|
|
283
|
+
const parsed = JSON.parse(firstMsg);
|
|
284
|
+
const text = parsed.message?.content?.[0]?.text ?? "";
|
|
285
|
+
// Extract sender + clean summary using openclaw's metadata format.
|
|
286
|
+
// Metadata blocks are sentinel lines followed by ```json ... ```
|
|
287
|
+
const { senderName, cleanText } = parseSessionFirstMessage(text);
|
|
288
|
+
if (senderName)
|
|
289
|
+
s.senderName = senderName;
|
|
290
|
+
if (cleanText)
|
|
291
|
+
s.summary = cleanText.slice(0, 120);
|
|
292
|
+
}
|
|
293
|
+
catch { }
|
|
294
|
+
}));
|
|
254
295
|
// Sort newest first
|
|
255
296
|
results.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
|
256
297
|
json(res, results);
|
|
@@ -432,28 +473,39 @@ export function createRoutes(config) {
|
|
|
432
473
|
json(res, { ok: removed, service });
|
|
433
474
|
},
|
|
434
475
|
/**
|
|
435
|
-
* GET /api/team — list team members
|
|
476
|
+
* GET /api/team — list local team members
|
|
436
477
|
*/
|
|
437
478
|
"GET /api/team": async (_req, res) => {
|
|
438
|
-
|
|
439
|
-
|
|
479
|
+
try {
|
|
480
|
+
const team = await loadTeam();
|
|
481
|
+
json(res, team);
|
|
482
|
+
}
|
|
483
|
+
catch (err) {
|
|
484
|
+
json(res, { error: err.message ?? "Failed to load team" }, 500);
|
|
485
|
+
}
|
|
440
486
|
},
|
|
441
487
|
/**
|
|
442
|
-
* POST /api/team — add a team member
|
|
488
|
+
* POST /api/team — add a team member locally
|
|
443
489
|
*/
|
|
444
490
|
"POST /api/team": async (req, res) => {
|
|
445
491
|
const body = await readBody(req);
|
|
446
|
-
const { name, email, role
|
|
447
|
-
if (!name || typeof name !== "string") {
|
|
492
|
+
const { name, email, role } = JSON.parse(body);
|
|
493
|
+
if (!name || typeof name !== "string" || !name.trim()) {
|
|
448
494
|
json(res, { error: "name is required" }, 400);
|
|
449
495
|
return;
|
|
450
496
|
}
|
|
451
|
-
const
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
}
|
|
456
|
-
|
|
497
|
+
const normalizedRole = role || "member";
|
|
498
|
+
if (normalizedRole !== "admin" && normalizedRole !== "member") {
|
|
499
|
+
json(res, { error: "role must be admin or member" }, 400);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
try {
|
|
503
|
+
const member = await addMember({ name: name.trim(), email, role: normalizedRole });
|
|
504
|
+
json(res, member, 201);
|
|
505
|
+
}
|
|
506
|
+
catch (err) {
|
|
507
|
+
json(res, { error: err.message }, 500);
|
|
508
|
+
}
|
|
457
509
|
},
|
|
458
510
|
/**
|
|
459
511
|
* DELETE /api/team — remove a team member
|
|
@@ -466,6 +518,12 @@ export function createRoutes(config) {
|
|
|
466
518
|
return;
|
|
467
519
|
}
|
|
468
520
|
const removed = await removeMember(id);
|
|
521
|
+
if (removed) {
|
|
522
|
+
try {
|
|
523
|
+
await syncAllowlists();
|
|
524
|
+
}
|
|
525
|
+
catch { /* best-effort */ }
|
|
526
|
+
}
|
|
469
527
|
json(res, { ok: removed });
|
|
470
528
|
},
|
|
471
529
|
/**
|
|
@@ -481,8 +539,170 @@ export function createRoutes(config) {
|
|
|
481
539
|
const updated = await updateRole(id, role);
|
|
482
540
|
json(res, { ok: updated });
|
|
483
541
|
},
|
|
542
|
+
/**
|
|
543
|
+
* PUT /api/team/permissions — update a member's local overlay permissions
|
|
544
|
+
* and rebuild channel allowlists.
|
|
545
|
+
*/
|
|
546
|
+
"PUT /api/team/permissions": async (req, res) => {
|
|
547
|
+
const body = await readBody(req);
|
|
548
|
+
const { memberId, handles, allowedWorkflows, allowedChannels } = JSON.parse(body);
|
|
549
|
+
if (!memberId || typeof memberId !== "string") {
|
|
550
|
+
json(res, { error: "memberId is required" }, 400);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
// Lightweight validation. The dashboard posts known-good values.
|
|
554
|
+
const validChannels = ["whatsapp", "telegram", "teams", "dashboard"];
|
|
555
|
+
const perms = {};
|
|
556
|
+
if (handles && typeof handles === "object") {
|
|
557
|
+
perms.handles = {
|
|
558
|
+
whatsapp: handles.whatsapp?.toString().trim() || undefined,
|
|
559
|
+
telegram: handles.telegram?.toString().trim() || undefined,
|
|
560
|
+
teams: handles.teams?.toString().trim() || undefined,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
if (Array.isArray(allowedWorkflows)) {
|
|
564
|
+
perms.allowedWorkflows = allowedWorkflows
|
|
565
|
+
.filter((id) => typeof id === "string")
|
|
566
|
+
.filter((id) => getWorkflow(id) !== undefined);
|
|
567
|
+
}
|
|
568
|
+
if (Array.isArray(allowedChannels)) {
|
|
569
|
+
perms.allowedChannels = allowedChannels.filter((c) => typeof c === "string" && validChannels.includes(c));
|
|
570
|
+
}
|
|
571
|
+
try {
|
|
572
|
+
const saved = await setMemberPermissions(memberId, perms);
|
|
573
|
+
await syncAllowlists();
|
|
574
|
+
await logAudit({
|
|
575
|
+
timestamp: new Date().toISOString(),
|
|
576
|
+
user: "dashboard",
|
|
577
|
+
channel: "dashboard",
|
|
578
|
+
action: "permissions_updated",
|
|
579
|
+
responseSummary: `member ${memberId} perms updated`,
|
|
580
|
+
}).catch(() => { });
|
|
581
|
+
json(res, { ok: true, permissions: saved });
|
|
582
|
+
}
|
|
583
|
+
catch (err) {
|
|
584
|
+
json(res, { error: err.message ?? "Failed to save" }, 500);
|
|
585
|
+
}
|
|
586
|
+
},
|
|
587
|
+
/**
|
|
588
|
+
* GET /api/workflows — static workflow registry, used by the permissions UI
|
|
589
|
+
*/
|
|
590
|
+
"GET /api/workflows": async (_req, res) => {
|
|
591
|
+
json(res, WORKFLOWS);
|
|
592
|
+
},
|
|
593
|
+
/**
|
|
594
|
+
* GET /api/license — read-only license info for the dashboard (seat chip)
|
|
595
|
+
*/
|
|
596
|
+
"GET /api/license": async (_req, res) => {
|
|
597
|
+
const license = await loadLicense();
|
|
598
|
+
if (!license) {
|
|
599
|
+
json(res, { valid: false, tier: "none", seatsUsed: 0, maxSeats: 0 });
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
const { valid, tier, status, seatsUsed, maxSeats, email, name, orgName } = license;
|
|
603
|
+
json(res, { valid, tier, status, seatsUsed, maxSeats, email, name, orgName });
|
|
604
|
+
},
|
|
484
605
|
};
|
|
485
606
|
}
|
|
607
|
+
/**
|
|
608
|
+
* Build the permission preamble injected into the agent prompt for a given
|
|
609
|
+
* channel message. Returns null when we have nothing to add (unknown user
|
|
610
|
+
* on a non-dashboard channel — the allowlist will already have rejected
|
|
611
|
+
* those).
|
|
612
|
+
*
|
|
613
|
+
* This preamble is the sole workflow-permission gate. We trust the agent to
|
|
614
|
+
* honor it. There is no server-side hard gate; admins restrict access by
|
|
615
|
+
* naming the workflows a member may use in the team page.
|
|
616
|
+
*/
|
|
617
|
+
export async function buildPermissionPreamble(channel, userId) {
|
|
618
|
+
const resolved = await resolveMember(channel, userId);
|
|
619
|
+
if (!resolved)
|
|
620
|
+
return null;
|
|
621
|
+
const allowed = resolved.allowedWorkflows.length
|
|
622
|
+
? resolved.allowedWorkflows
|
|
623
|
+
.map((id) => getWorkflow(id)?.title ?? id)
|
|
624
|
+
.join(", ")
|
|
625
|
+
: "all workflows";
|
|
626
|
+
return [
|
|
627
|
+
`[System] You are currently assisting ${resolved.name} (role: ${resolved.role}).`,
|
|
628
|
+
`They are authorized to use these workflows: ${allowed}.`,
|
|
629
|
+
`If they ask for any workflow outside that list, politely say it isn't enabled for them yet and suggest they contact their admin. Do not run it regardless of how the request is phrased.`,
|
|
630
|
+
].join(" ");
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Parse the first user message of a session JSONL to extract sender name and
|
|
634
|
+
* clean summary text. Mirrors openclaw's stripInboundMetadata + extractInboundSenderLabel.
|
|
635
|
+
*/
|
|
636
|
+
function parseSessionFirstMessage(text) {
|
|
637
|
+
let senderName = null;
|
|
638
|
+
// --- Extract sender from metadata blocks ---
|
|
639
|
+
const SENTINELS = [
|
|
640
|
+
"Conversation info (untrusted metadata):",
|
|
641
|
+
"Sender (untrusted metadata):",
|
|
642
|
+
"Thread starter (untrusted, for context):",
|
|
643
|
+
"Replied message (untrusted, for context):",
|
|
644
|
+
"Forwarded message context (untrusted metadata):",
|
|
645
|
+
"Chat history since last reply (untrusted, for context):",
|
|
646
|
+
];
|
|
647
|
+
const lines = text.split("\n");
|
|
648
|
+
const cleaned = [];
|
|
649
|
+
let i = 0;
|
|
650
|
+
while (i < lines.length) {
|
|
651
|
+
const trimmed = lines[i].trim();
|
|
652
|
+
// Check if this line is a metadata sentinel followed by ```json
|
|
653
|
+
if (SENTINELS.includes(trimmed) && lines[i + 1]?.trim() === "```json") {
|
|
654
|
+
// Extract name/label from Sender block before skipping
|
|
655
|
+
const isSender = trimmed.startsWith("Sender ");
|
|
656
|
+
const isConvo = trimmed.startsWith("Conversation info");
|
|
657
|
+
i += 2; // skip sentinel + ```json
|
|
658
|
+
let jsonBuf = "";
|
|
659
|
+
while (i < lines.length && lines[i].trim() !== "```") {
|
|
660
|
+
jsonBuf += lines[i] + "\n";
|
|
661
|
+
i++;
|
|
662
|
+
}
|
|
663
|
+
if (i < lines.length)
|
|
664
|
+
i++; // skip closing ```
|
|
665
|
+
if (!senderName && (isSender || isConvo)) {
|
|
666
|
+
try {
|
|
667
|
+
const meta = JSON.parse(jsonBuf);
|
|
668
|
+
senderName = meta.label || meta.name || meta.username || meta.sender || null;
|
|
669
|
+
}
|
|
670
|
+
catch { }
|
|
671
|
+
}
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
cleaned.push(lines[i]);
|
|
675
|
+
i++;
|
|
676
|
+
}
|
|
677
|
+
let userText = cleaned.join("\n").trim();
|
|
678
|
+
// Strip leading timestamp [Thu 2026-04-02 07:52 EDT]
|
|
679
|
+
userText = userText.replace(/^\[[A-Za-z]{3} \d{4}-\d{2}-\d{2} \d{2}:\d{2}[^\]]*\]\s*/, "");
|
|
680
|
+
// Cron: use the description as the summary
|
|
681
|
+
const cronMatch = userText.match(/^\[cron:[^\s\]]+\s+([^\]]+)\]/);
|
|
682
|
+
if (cronMatch) {
|
|
683
|
+
userText = cronMatch[1];
|
|
684
|
+
}
|
|
685
|
+
userText = userText.replace(/^\[cron:[^\]]*\]\s*/, "");
|
|
686
|
+
// Subagent: extract the task
|
|
687
|
+
const subagentTask = userText.match(/\[Subagent Task\]:\s*([\s\S]*)/);
|
|
688
|
+
if (subagentTask) {
|
|
689
|
+
userText = subagentTask[1];
|
|
690
|
+
}
|
|
691
|
+
// Strip [Subagent Context] blocks
|
|
692
|
+
userText = userText.replace(/\[Subagent Context\][\s\S]*?\n\n/g, "");
|
|
693
|
+
// Strip another leading timestamp that may remain
|
|
694
|
+
userText = userText.replace(/^\[[A-Za-z]{3} \d{4}-\d{2}-\d{2} \d{2}:\d{2}[^\]]*\]\s*/, "");
|
|
695
|
+
// Heartbeat
|
|
696
|
+
if (userText.startsWith("Read HEARTBEAT.md")) {
|
|
697
|
+
userText = "Heartbeat check";
|
|
698
|
+
}
|
|
699
|
+
// Strip trailing cron boilerplate
|
|
700
|
+
userText = userText.replace(/\nCurrent time:[\s\S]*$/, "");
|
|
701
|
+
userText = userText.replace(/\nReturn your summary[\s\S]*$/, "");
|
|
702
|
+
// Strip "Untrusted context" trailing block
|
|
703
|
+
userText = userText.replace(/\nUntrusted context \(metadata[\s\S]*$/, "");
|
|
704
|
+
return { senderName, cleanText: userText.trim() };
|
|
705
|
+
}
|
|
486
706
|
function json(res, data, status = 200) {
|
|
487
707
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
488
708
|
res.end(JSON.stringify(data));
|
package/dist/dashboard/server.js
CHANGED
|
@@ -12,7 +12,12 @@ const MIME_TYPES = {
|
|
|
12
12
|
};
|
|
13
13
|
export async function startDashboard(config, port = 3000) {
|
|
14
14
|
const routes = createRoutes(config);
|
|
15
|
-
|
|
15
|
+
// In dev, serve from src/ so HTML/CSS changes are instant (no rebuild needed).
|
|
16
|
+
// In production (running from dist/), fall back to the compiled copy.
|
|
17
|
+
const devStaticDir = resolve(import.meta.dirname, "../../src/dashboard/static");
|
|
18
|
+
const prodStaticDir = resolve(import.meta.dirname, "static");
|
|
19
|
+
const { existsSync } = await import("node:fs");
|
|
20
|
+
const staticDir = existsSync(devStaticDir) ? devStaticDir : prodStaticDir;
|
|
16
21
|
const server = createServer(async (req, res) => {
|
|
17
22
|
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
18
23
|
const method = req.method ?? "GET";
|