@cluesmith/codev 1.4.1 → 1.4.3

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