@cluesmith/codev 1.4.0 → 1.4.2

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 (35) hide show
  1. package/bin/af.js +0 -0
  2. package/bin/codev.js +0 -0
  3. package/bin/consult.js +0 -0
  4. package/bin/generate-image.js +0 -0
  5. package/dist/agent-farm/servers/dashboard-server.js +487 -5
  6. package/dist/agent-farm/servers/dashboard-server.js.map +1 -1
  7. package/dist/commands/adopt.d.ts.map +1 -1
  8. package/dist/commands/adopt.js +10 -0
  9. package/dist/commands/adopt.js.map +1 -1
  10. package/dist/commands/init.d.ts.map +1 -1
  11. package/dist/commands/init.js +8 -0
  12. package/dist/commands/init.js.map +1 -1
  13. package/package.json +1 -1
  14. package/skeleton/templates/projectlist-archive.md +21 -0
  15. package/skeleton/templates/projectlist.md +17 -0
  16. package/templates/dashboard/css/activity.css +151 -0
  17. package/templates/dashboard/css/dialogs.css +149 -0
  18. package/templates/dashboard/css/files.css +530 -0
  19. package/templates/dashboard/css/layout.css +124 -0
  20. package/templates/dashboard/css/projects.css +501 -0
  21. package/templates/dashboard/css/statusbar.css +23 -0
  22. package/templates/dashboard/css/tabs.css +314 -0
  23. package/templates/dashboard/css/utilities.css +50 -0
  24. package/templates/dashboard/css/variables.css +45 -0
  25. package/templates/dashboard/index.html +158 -0
  26. package/templates/dashboard/js/activity.js +238 -0
  27. package/templates/dashboard/js/dialogs.js +328 -0
  28. package/templates/dashboard/js/files.js +436 -0
  29. package/templates/dashboard/js/main.js +487 -0
  30. package/templates/dashboard/js/projects.js +544 -0
  31. package/templates/dashboard/js/state.js +91 -0
  32. package/templates/dashboard/js/tabs.js +500 -0
  33. package/templates/dashboard/js/utils.js +57 -0
  34. package/templates/dashboard-split.html +1186 -171
  35. package/templates/open.html +7 -2
package/bin/af.js CHANGED
File without changes
package/bin/codev.js CHANGED
File without changes
package/bin/consult.js CHANGED
File without changes
File without changes
@@ -7,9 +7,11 @@ import http from 'node:http';
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import net from 'node:net';
10
- import { spawn, execSync } from 'node:child_process';
10
+ import { spawn, execSync, exec } from 'node:child_process';
11
+ import { promisify } from 'node:util';
11
12
  import { randomUUID } from 'node:crypto';
12
13
  import { fileURLToPath } from 'node:url';
14
+ const execAsync = promisify(exec);
13
15
  import { Command } from 'commander';
14
16
  import { loadState, getAnnotations, addAnnotation, removeAnnotation, getUtils, tryAddUtil, removeUtil, getBuilder, getBuilders, removeBuilder, upsertBuilder, clearState, } from '../state.js';
15
17
  import { spawnTtyd } from '../utils/shell.js';
@@ -89,7 +91,9 @@ function findTemplatePath(filename, required = false) {
89
91
  return null;
90
92
  }
91
93
  const projectRoot = findProjectRoot();
92
- const templatePath = findTemplatePath('dashboard-split.html', true);
94
+ // Use modular dashboard template (Spec 0060)
95
+ const templatePath = findTemplatePath('dashboard/index.html', true);
96
+ // Keep legacy paths for backwards compatibility (will be removed in cleanup)
93
97
  const legacyTemplatePath = findTemplatePath('dashboard.html', true);
94
98
  // Clean up dead processes from state (called on state load)
95
99
  function cleanupDeadProcesses() {
@@ -351,13 +355,14 @@ function spawnWorktreeBuilder(builderPort, state) {
351
355
  // Create git branch and worktree
352
356
  execSync(`git branch "${branchName}" HEAD`, { cwd: projectRoot, stdio: 'ignore' });
353
357
  execSync(`git worktree add "${worktreePath}" "${branchName}"`, { cwd: projectRoot, stdio: 'ignore' });
354
- // Get builder command from config or use default
358
+ // Get builder command from config or use default shell
355
359
  const configPath = path.resolve(projectRoot, 'codev', 'config.json');
356
- let builderCommand = 'claude';
360
+ const defaultShell = process.env.SHELL || 'bash';
361
+ let builderCommand = defaultShell;
357
362
  if (fs.existsSync(configPath)) {
358
363
  try {
359
364
  const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
360
- builderCommand = config?.shell?.builder || 'claude';
365
+ builderCommand = config?.shell?.builder || defaultShell;
361
366
  }
362
367
  catch {
363
368
  // Use default
@@ -500,6 +505,393 @@ function getOpenServerPath() {
500
505
  }
501
506
  return { script: jsPath, useTsx: false };
502
507
  }
508
+ /**
509
+ * Escape a string for safe use in shell commands
510
+ * Handles special characters that could cause command injection
511
+ */
512
+ function escapeShellArg(str) {
513
+ // Single-quote the string and escape any single quotes within it
514
+ return "'" + str.replace(/'/g, "'\\''") + "'";
515
+ }
516
+ /**
517
+ * Get today's git commits from all branches for the current user
518
+ */
519
+ async function getGitCommits(projectRoot) {
520
+ try {
521
+ const { stdout: authorRaw } = await execAsync('git config user.name', { cwd: projectRoot });
522
+ const author = authorRaw.trim();
523
+ if (!author)
524
+ return [];
525
+ // Escape author name to prevent command injection
526
+ const safeAuthor = escapeShellArg(author);
527
+ // Get commits from all branches since midnight
528
+ const { stdout: output } = await execAsync(`git log --all --since="midnight" --author=${safeAuthor} --format="%H|%s|%aI|%D"`, { cwd: projectRoot, maxBuffer: 10 * 1024 * 1024 });
529
+ if (!output.trim())
530
+ return [];
531
+ return output.trim().split('\n').filter(Boolean).map(line => {
532
+ const parts = line.split('|');
533
+ const hash = parts[0] || '';
534
+ const message = parts[1] || '';
535
+ const time = parts[2] || '';
536
+ const refs = parts.slice(3).join('|'); // refs might contain |
537
+ // Extract branch name from refs
538
+ let branch = 'unknown';
539
+ const headMatch = refs.match(/HEAD -> ([^,]+)/);
540
+ const branchMatch = refs.match(/([^,\s]+)$/);
541
+ if (headMatch) {
542
+ branch = headMatch[1];
543
+ }
544
+ else if (branchMatch && branchMatch[1]) {
545
+ branch = branchMatch[1];
546
+ }
547
+ return {
548
+ hash: hash.slice(0, 7),
549
+ message: message.slice(0, 100), // Truncate long messages
550
+ time,
551
+ branch,
552
+ };
553
+ });
554
+ }
555
+ catch (err) {
556
+ console.error('Error getting git commits:', err.message);
557
+ return [];
558
+ }
559
+ }
560
+ /**
561
+ * Get unique files modified today
562
+ */
563
+ async function getModifiedFiles(projectRoot) {
564
+ try {
565
+ const { stdout: authorRaw } = await execAsync('git config user.name', { cwd: projectRoot });
566
+ const author = authorRaw.trim();
567
+ if (!author)
568
+ return [];
569
+ // Escape author name to prevent command injection
570
+ const safeAuthor = escapeShellArg(author);
571
+ const { stdout: output } = await execAsync(`git log --all --since="midnight" --author=${safeAuthor} --name-only --format=""`, { cwd: projectRoot, maxBuffer: 10 * 1024 * 1024 });
572
+ if (!output.trim())
573
+ return [];
574
+ const files = [...new Set(output.trim().split('\n').filter(Boolean))];
575
+ return files.sort();
576
+ }
577
+ catch (err) {
578
+ console.error('Error getting modified files:', err.message);
579
+ return [];
580
+ }
581
+ }
582
+ /**
583
+ * Get GitHub PRs created or merged today via gh CLI
584
+ * Combines PRs created today AND PRs merged today (which may have been created earlier)
585
+ */
586
+ async function getGitHubPRs(projectRoot) {
587
+ try {
588
+ // Use local time for the date (spec says "today" means local machine time)
589
+ const now = new Date();
590
+ const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
591
+ // Fetch PRs created today AND PRs merged today in parallel
592
+ const [createdResult, mergedResult] = await Promise.allSettled([
593
+ execAsync(`gh pr list --author "@me" --state all --search "created:>=${today}" --json number,title,state,url`, { cwd: projectRoot, timeout: 15000 }),
594
+ execAsync(`gh pr list --author "@me" --state merged --search "merged:>=${today}" --json number,title,state,url`, { cwd: projectRoot, timeout: 15000 }),
595
+ ]);
596
+ const prsMap = new Map();
597
+ // Process PRs created today
598
+ if (createdResult.status === 'fulfilled' && createdResult.value.stdout.trim()) {
599
+ const prs = JSON.parse(createdResult.value.stdout);
600
+ for (const pr of prs) {
601
+ prsMap.set(pr.number, {
602
+ number: pr.number,
603
+ title: pr.title.slice(0, 100),
604
+ state: pr.state,
605
+ url: pr.url,
606
+ });
607
+ }
608
+ }
609
+ // Process PRs merged today (may overlap with created, deduped by Map)
610
+ if (mergedResult.status === 'fulfilled' && mergedResult.value.stdout.trim()) {
611
+ const prs = JSON.parse(mergedResult.value.stdout);
612
+ for (const pr of prs) {
613
+ prsMap.set(pr.number, {
614
+ number: pr.number,
615
+ title: pr.title.slice(0, 100),
616
+ state: pr.state,
617
+ url: pr.url,
618
+ });
619
+ }
620
+ }
621
+ return Array.from(prsMap.values());
622
+ }
623
+ catch (err) {
624
+ // gh CLI might not be available or authenticated
625
+ console.error('Error getting GitHub PRs:', err.message);
626
+ return [];
627
+ }
628
+ }
629
+ /**
630
+ * Get builder activity from state.db for today
631
+ * Note: state.json doesn't track timestamps, so we can only report current builders
632
+ * without duration. They'll be counted as activity points, not time intervals.
633
+ */
634
+ function getBuilderActivity() {
635
+ try {
636
+ const builders = getBuilders();
637
+ // Return current builders without time tracking (state.json lacks timestamps)
638
+ // Time tracking will rely primarily on git commits
639
+ return builders.map(b => ({
640
+ id: b.id,
641
+ status: b.status || 'unknown',
642
+ startTime: '', // Unknown - not tracked in state.json
643
+ endTime: undefined,
644
+ }));
645
+ }
646
+ catch (err) {
647
+ console.error('Error getting builder activity:', err.message);
648
+ return [];
649
+ }
650
+ }
651
+ /**
652
+ * Detect project status changes in projectlist.md today
653
+ * Handles YAML format inside Markdown fenced code blocks
654
+ */
655
+ async function getProjectChanges(projectRoot) {
656
+ try {
657
+ const projectlistPath = path.join(projectRoot, 'codev/projectlist.md');
658
+ if (!fs.existsSync(projectlistPath))
659
+ return [];
660
+ // Get the first commit hash from today that touched projectlist.md
661
+ const { stdout: firstCommitOutput } = await execAsync(`git log --since="midnight" --format=%H -- codev/projectlist.md | tail -1`, { cwd: projectRoot });
662
+ if (!firstCommitOutput.trim())
663
+ return [];
664
+ // Get diff of projectlist.md from that commit's parent to HEAD
665
+ let diff;
666
+ try {
667
+ const { stdout } = await execAsync(`git diff ${firstCommitOutput.trim()}^..HEAD -- codev/projectlist.md`, { cwd: projectRoot, maxBuffer: 1024 * 1024 });
668
+ diff = stdout;
669
+ }
670
+ catch {
671
+ return [];
672
+ }
673
+ if (!diff.trim())
674
+ return [];
675
+ // Parse status changes from diff
676
+ // Format is YAML inside Markdown code blocks:
677
+ // - id: "0058"
678
+ // title: "File Search Autocomplete"
679
+ // status: implementing
680
+ const changes = [];
681
+ const lines = diff.split('\n');
682
+ let currentId = '';
683
+ let currentTitle = '';
684
+ let oldStatus = '';
685
+ let newStatus = '';
686
+ for (const line of lines) {
687
+ // Track current project context from YAML id field
688
+ // Match lines like: " - id: \"0058\"" or "+ - id: \"0058\""
689
+ const idMatch = line.match(/^[+-]?\s*-\s*id:\s*["']?(\d{4})["']?/);
690
+ if (idMatch) {
691
+ // If we have a pending status change from previous project, emit it
692
+ if (oldStatus && newStatus && currentId) {
693
+ changes.push({
694
+ id: currentId,
695
+ title: currentTitle,
696
+ oldStatus,
697
+ newStatus,
698
+ });
699
+ oldStatus = '';
700
+ newStatus = '';
701
+ }
702
+ currentId = idMatch[1];
703
+ currentTitle = ''; // Will be filled by title line
704
+ }
705
+ // Track title (comes after id in YAML)
706
+ // Match lines like: " title: \"File Search Autocomplete\""
707
+ const titleMatch = line.match(/^[+-]?\s*title:\s*["']?([^"']+)["']?/);
708
+ if (titleMatch && currentId) {
709
+ currentTitle = titleMatch[1].trim();
710
+ }
711
+ // Track status changes
712
+ // Match lines like: "- status: implementing" or "+ status: implemented"
713
+ const statusMatch = line.match(/^([+-])\s*status:\s*(\w+)/);
714
+ if (statusMatch) {
715
+ const [, modifier, status] = statusMatch;
716
+ if (modifier === '-') {
717
+ oldStatus = status;
718
+ }
719
+ else if (modifier === '+') {
720
+ newStatus = status;
721
+ }
722
+ }
723
+ }
724
+ // Emit final pending change if exists
725
+ if (oldStatus && newStatus && currentId) {
726
+ changes.push({
727
+ id: currentId,
728
+ title: currentTitle,
729
+ oldStatus,
730
+ newStatus,
731
+ });
732
+ }
733
+ return changes;
734
+ }
735
+ catch (err) {
736
+ console.error('Error getting project changes:', err.message);
737
+ return [];
738
+ }
739
+ }
740
+ /**
741
+ * Merge overlapping time intervals
742
+ */
743
+ function mergeIntervals(intervals) {
744
+ if (intervals.length === 0)
745
+ return [];
746
+ // Sort by start time
747
+ const sorted = [...intervals].sort((a, b) => a.start.getTime() - b.start.getTime());
748
+ const merged = [{ ...sorted[0] }];
749
+ for (let i = 1; i < sorted.length; i++) {
750
+ const last = merged[merged.length - 1];
751
+ const current = sorted[i];
752
+ // If overlapping or within 2 hours, merge
753
+ const gapMs = current.start.getTime() - last.end.getTime();
754
+ const twoHoursMs = 2 * 60 * 60 * 1000;
755
+ if (gapMs <= twoHoursMs) {
756
+ last.end = new Date(Math.max(last.end.getTime(), current.end.getTime()));
757
+ }
758
+ else {
759
+ merged.push({ ...current });
760
+ }
761
+ }
762
+ return merged;
763
+ }
764
+ /**
765
+ * Calculate active time from commits and builder activity
766
+ */
767
+ function calculateTimeTracking(commits, builders) {
768
+ const intervals = [];
769
+ const fiveMinutesMs = 5 * 60 * 1000;
770
+ // Add commit timestamps (treat each as 5-minute interval)
771
+ for (const commit of commits) {
772
+ if (commit.time) {
773
+ const time = new Date(commit.time);
774
+ if (!isNaN(time.getTime())) {
775
+ intervals.push({
776
+ start: time,
777
+ end: new Date(time.getTime() + fiveMinutesMs),
778
+ });
779
+ }
780
+ }
781
+ }
782
+ // Add builder sessions
783
+ for (const builder of builders) {
784
+ if (builder.startTime) {
785
+ const start = new Date(builder.startTime);
786
+ const end = builder.endTime ? new Date(builder.endTime) : new Date();
787
+ if (!isNaN(start.getTime()) && !isNaN(end.getTime())) {
788
+ intervals.push({ start, end });
789
+ }
790
+ }
791
+ }
792
+ if (intervals.length === 0) {
793
+ return {
794
+ activeMinutes: 0,
795
+ firstActivity: '',
796
+ lastActivity: '',
797
+ };
798
+ }
799
+ const merged = mergeIntervals(intervals);
800
+ const totalMinutes = merged.reduce((sum, interval) => sum + (interval.end.getTime() - interval.start.getTime()) / (1000 * 60), 0);
801
+ return {
802
+ activeMinutes: Math.round(totalMinutes),
803
+ firstActivity: merged[0].start.toISOString(),
804
+ lastActivity: merged[merged.length - 1].end.toISOString(),
805
+ };
806
+ }
807
+ /**
808
+ * Find the consult CLI path
809
+ * Returns the path to the consult binary, checking multiple locations
810
+ */
811
+ function findConsultPath() {
812
+ // When running from dist/, check relative paths
813
+ // dist/agent-farm/servers/ -> ../../../bin/consult.js
814
+ const distPath = path.join(__dirname, '../../../bin/consult.js');
815
+ if (fs.existsSync(distPath)) {
816
+ return distPath;
817
+ }
818
+ // When running from src/ with tsx, check src-relative paths
819
+ // src/agent-farm/servers/ -> ../../../bin/consult.js (won't exist, it's .ts in src)
820
+ // But bin/ is at packages/codev/bin/consult.js, so it should still work
821
+ // Fall back to npx consult (works if @cluesmith/codev is installed)
822
+ return 'npx consult';
823
+ }
824
+ /**
825
+ * Generate AI summary via consult CLI
826
+ */
827
+ async function generateAISummary(data) {
828
+ // Build prompt with commit messages and file names only (security: no full diffs)
829
+ const hours = Math.floor(data.timeTracking.activeMinutes / 60);
830
+ const mins = data.timeTracking.activeMinutes % 60;
831
+ const prompt = `Summarize this developer's activity today for a standup report.
832
+
833
+ Commits (${data.commits.length}):
834
+ ${data.commits.slice(0, 20).map(c => `- ${c.message}`).join('\n') || '(none)'}
835
+ ${data.commits.length > 20 ? `... and ${data.commits.length - 20} more` : ''}
836
+
837
+ PRs: ${data.prs.map(p => `#${p.number} ${p.title} (${p.state})`).join(', ') || 'None'}
838
+
839
+ Files modified: ${data.files.length} files
840
+ ${data.files.slice(0, 10).join(', ')}${data.files.length > 10 ? ` ... and ${data.files.length - 10} more` : ''}
841
+
842
+ Project status changes:
843
+ ${data.projectChanges.map(p => `- ${p.id} ${p.title}: ${p.oldStatus} → ${p.newStatus}`).join('\n') || '(none)'}
844
+
845
+ Active time: ~${hours}h ${mins}m
846
+
847
+ Write a brief, professional summary (2-3 sentences) focusing on accomplishments. Be concise and suitable for a standup or status report.`;
848
+ try {
849
+ // Use consult CLI to generate summary
850
+ const consultCmd = findConsultPath();
851
+ const safePrompt = escapeShellArg(prompt);
852
+ // Use async exec with timeout
853
+ const { stdout } = await execAsync(`${consultCmd} --model gemini general ${safePrompt}`, { timeout: 60000, maxBuffer: 1024 * 1024 });
854
+ return stdout.trim();
855
+ }
856
+ catch (err) {
857
+ console.error('AI summary generation failed:', err.message);
858
+ return '';
859
+ }
860
+ }
861
+ /**
862
+ * Collect all activity data for today
863
+ */
864
+ async function collectActivitySummary(projectRoot) {
865
+ // Collect data from all sources in parallel - these are now truly async
866
+ const [commits, files, prs, builders, projectChanges] = await Promise.all([
867
+ getGitCommits(projectRoot),
868
+ getModifiedFiles(projectRoot),
869
+ getGitHubPRs(projectRoot),
870
+ Promise.resolve(getBuilderActivity()), // This one is sync (reads from state)
871
+ getProjectChanges(projectRoot),
872
+ ]);
873
+ const timeTracking = calculateTimeTracking(commits, builders);
874
+ // Generate AI summary (skip if no activity)
875
+ let aiSummary = '';
876
+ if (commits.length > 0 || prs.length > 0) {
877
+ aiSummary = await generateAISummary({
878
+ commits,
879
+ prs,
880
+ files,
881
+ timeTracking,
882
+ projectChanges,
883
+ });
884
+ }
885
+ return {
886
+ commits,
887
+ prs,
888
+ builders,
889
+ projectChanges,
890
+ files,
891
+ timeTracking,
892
+ aiSummary: aiSummary || undefined,
893
+ };
894
+ }
503
895
  // Use split template as main, legacy is already loaded via findTemplatePath
504
896
  const finalTemplatePath = templatePath;
505
897
  // Security: Validate request origin
@@ -1144,6 +1536,96 @@ const server = http.createServer(async (req, res) => {
1144
1536
  res.end(JSON.stringify(tree));
1145
1537
  return;
1146
1538
  }
1539
+ // API: Get daily activity summary (Spec 0059)
1540
+ if (req.method === 'GET' && url.pathname === '/api/activity-summary') {
1541
+ try {
1542
+ const activitySummary = await collectActivitySummary(projectRoot);
1543
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1544
+ res.end(JSON.stringify(activitySummary));
1545
+ }
1546
+ catch (err) {
1547
+ console.error('Activity summary error:', err);
1548
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1549
+ res.end(JSON.stringify({ error: err.message }));
1550
+ }
1551
+ return;
1552
+ }
1553
+ // API: Hot reload check (Spec 0060)
1554
+ // Returns modification times for all dashboard CSS/JS files
1555
+ if (req.method === 'GET' && url.pathname === '/api/hot-reload') {
1556
+ try {
1557
+ const dashboardDir = path.join(__dirname, '../../../templates/dashboard');
1558
+ const cssDir = path.join(dashboardDir, 'css');
1559
+ const jsDir = path.join(dashboardDir, 'js');
1560
+ const mtimes = {};
1561
+ // Collect CSS file modification times
1562
+ if (fs.existsSync(cssDir)) {
1563
+ for (const file of fs.readdirSync(cssDir)) {
1564
+ if (file.endsWith('.css')) {
1565
+ const stat = fs.statSync(path.join(cssDir, file));
1566
+ mtimes[`css/${file}`] = stat.mtimeMs;
1567
+ }
1568
+ }
1569
+ }
1570
+ // Collect JS file modification times
1571
+ if (fs.existsSync(jsDir)) {
1572
+ for (const file of fs.readdirSync(jsDir)) {
1573
+ if (file.endsWith('.js')) {
1574
+ const stat = fs.statSync(path.join(jsDir, file));
1575
+ mtimes[`js/${file}`] = stat.mtimeMs;
1576
+ }
1577
+ }
1578
+ }
1579
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1580
+ res.end(JSON.stringify({ mtimes }));
1581
+ }
1582
+ catch (err) {
1583
+ console.error('Hot reload check error:', err);
1584
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1585
+ res.end(JSON.stringify({ error: err.message }));
1586
+ }
1587
+ return;
1588
+ }
1589
+ // Serve dashboard CSS files
1590
+ if (req.method === 'GET' && url.pathname.startsWith('/dashboard/css/')) {
1591
+ const filename = url.pathname.replace('/dashboard/css/', '');
1592
+ // Validate filename to prevent path traversal
1593
+ if (!filename || filename.includes('..') || filename.includes('/') || !filename.endsWith('.css')) {
1594
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
1595
+ res.end('Invalid filename');
1596
+ return;
1597
+ }
1598
+ const cssPath = path.join(__dirname, '../../../templates/dashboard/css', filename);
1599
+ if (fs.existsSync(cssPath)) {
1600
+ const content = fs.readFileSync(cssPath, 'utf-8');
1601
+ res.writeHead(200, { 'Content-Type': 'text/css; charset=utf-8' });
1602
+ res.end(content);
1603
+ return;
1604
+ }
1605
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
1606
+ res.end('CSS file not found');
1607
+ return;
1608
+ }
1609
+ // Serve dashboard JS files
1610
+ if (req.method === 'GET' && url.pathname.startsWith('/dashboard/js/')) {
1611
+ const filename = url.pathname.replace('/dashboard/js/', '');
1612
+ // Validate filename to prevent path traversal
1613
+ if (!filename || filename.includes('..') || filename.includes('/') || !filename.endsWith('.js')) {
1614
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
1615
+ res.end('Invalid filename');
1616
+ return;
1617
+ }
1618
+ const jsPath = path.join(__dirname, '../../../templates/dashboard/js', filename);
1619
+ if (fs.existsSync(jsPath)) {
1620
+ const content = fs.readFileSync(jsPath, 'utf-8');
1621
+ res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
1622
+ res.end(content);
1623
+ return;
1624
+ }
1625
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
1626
+ res.end('JS file not found');
1627
+ return;
1628
+ }
1147
1629
  // Serve dashboard
1148
1630
  if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
1149
1631
  try {