mdboard 1.0.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/server.js ADDED
@@ -0,0 +1,711 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mdboard — Project Dashboard Server
4
+ *
5
+ * Zero-dependency Node.js server that reads markdown project management
6
+ * files and serves a visual dashboard + JSON API.
7
+ *
8
+ * Usage:
9
+ * mdboard --project /path/to/workspace --port 3333
10
+ */
11
+
12
+ const http = require('http');
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const { URL } = require('url');
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // CLI argument parsing
19
+ // ---------------------------------------------------------------------------
20
+ const args = process.argv.slice(2);
21
+ let projectDir = process.cwd();
22
+ let port = 3333;
23
+
24
+ for (let i = 0; i < args.length; i++) {
25
+ switch (args[i]) {
26
+ case '--project':
27
+ projectDir = path.resolve(args[++i] || '.');
28
+ break;
29
+ case '--port':
30
+ port = parseInt(args[++i], 10) || 3333;
31
+ break;
32
+ case 'init':
33
+ case '-h':
34
+ case '--help':
35
+ case '-v':
36
+ case '--version':
37
+ case 'help':
38
+ // These are handled by bin.js; if server.js is called directly, ignore.
39
+ break;
40
+ }
41
+ }
42
+
43
+ const projectPath = path.join(projectDir, 'project');
44
+ const boardDir = __dirname;
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // YAML frontmatter parser
48
+ // ---------------------------------------------------------------------------
49
+ function parseFrontmatter(content) {
50
+ const result = { frontmatter: {}, content: '' };
51
+ if (!content.startsWith('---')) return { frontmatter: {}, content };
52
+
53
+ const end = content.indexOf('\n---', 3);
54
+ if (end === -1) return { frontmatter: {}, content };
55
+
56
+ const yaml = content.substring(4, end).trim();
57
+ result.content = content.substring(end + 4).trim();
58
+ result.frontmatter = parseYaml(yaml);
59
+ return result;
60
+ }
61
+
62
+ function parseYaml(text) {
63
+ const obj = {};
64
+ const lines = text.split('\n');
65
+ let i = 0;
66
+
67
+ while (i < lines.length) {
68
+ const line = lines[i];
69
+ const match = line.match(/^(\w[\w.-]*)\s*:\s*(.*)/);
70
+
71
+ if (!match) { i++; continue; }
72
+
73
+ const key = match[1];
74
+ const rawValue = match[2].trim();
75
+
76
+ if (rawValue === '' || rawValue === '') {
77
+ const nested = {};
78
+ let dashList = null;
79
+ let j = i + 1;
80
+
81
+ while (j < lines.length) {
82
+ const nextLine = lines[j];
83
+ if (!nextLine.match(/^\s/) || nextLine.trim() === '') break;
84
+
85
+ const dashMatch = nextLine.match(/^\s+-\s+(.*)/);
86
+ const nestedMatch = nextLine.match(/^\s+(\w[\w.-]*)\s*:\s*(.*)/);
87
+
88
+ if (dashMatch) {
89
+ if (!dashList) dashList = [];
90
+ dashList.push(parseValue(dashMatch[1].trim()));
91
+ } else if (nestedMatch) {
92
+ nested[nestedMatch[1]] = parseValue(nestedMatch[2].trim());
93
+ }
94
+ j++;
95
+ }
96
+
97
+ obj[key] = dashList || (Object.keys(nested).length > 0 ? nested : parseValue(rawValue));
98
+ i = j;
99
+ continue;
100
+ }
101
+
102
+ obj[key] = parseValue(rawValue);
103
+ i++;
104
+ }
105
+
106
+ return obj;
107
+ }
108
+
109
+ function parseValue(raw) {
110
+ if (raw === '' || raw === undefined) return null;
111
+
112
+ if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
113
+ return raw.slice(1, -1);
114
+ }
115
+
116
+ if (raw.startsWith('[') && raw.endsWith(']')) {
117
+ const inner = raw.slice(1, -1).trim();
118
+ if (inner === '') return [];
119
+ return inner.split(',').map(s => parseValue(s.trim()));
120
+ }
121
+
122
+ if (raw === 'true') return true;
123
+ if (raw === 'false') return false;
124
+ if (raw === 'null' || raw === '~') return null;
125
+ if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw);
126
+
127
+ return raw;
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // YAML serializer (for writing back to .md files)
132
+ // ---------------------------------------------------------------------------
133
+ function serializeValue(v) {
134
+ if (v === null || v === undefined) return '';
135
+ if (typeof v === 'boolean') return v ? 'true' : 'false';
136
+ if (typeof v === 'number') return String(v);
137
+ if (typeof v === 'string') {
138
+ if (/[:#\[\]{}&*!|>'"%@`,\n]/.test(v) || v === '' || v === 'true' || v === 'false' || v === 'null') {
139
+ return '"' + v.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
140
+ }
141
+ return v;
142
+ }
143
+ return String(v);
144
+ }
145
+
146
+ function serializeYaml(obj) {
147
+ const lines = [];
148
+ for (const [key, value] of Object.entries(obj)) {
149
+ if (key.startsWith('_')) continue;
150
+ if (value === undefined) continue;
151
+ if (value === null) {
152
+ lines.push(key + ':');
153
+ continue;
154
+ }
155
+ if (Array.isArray(value)) {
156
+ if (value.length === 0) {
157
+ lines.push(key + ': []');
158
+ } else {
159
+ lines.push(key + ': [' + value.map(v => serializeValue(v)).join(', ') + ']');
160
+ }
161
+ } else if (typeof value === 'object') {
162
+ lines.push(key + ':');
163
+ for (const [k, v] of Object.entries(value)) {
164
+ lines.push(' ' + k + ': ' + serializeValue(v));
165
+ }
166
+ } else {
167
+ lines.push(key + ': ' + serializeValue(value));
168
+ }
169
+ }
170
+ return lines.join('\n');
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Markdown file updater
175
+ // ---------------------------------------------------------------------------
176
+ function updateMarkdownFile(relFile, updates) {
177
+ const filePath = path.join(projectPath, relFile);
178
+ if (!fs.existsSync(filePath)) throw new Error('File not found: ' + relFile);
179
+
180
+ const raw = fs.readFileSync(filePath, 'utf-8');
181
+ const parsed = parseFrontmatter(raw);
182
+
183
+ const newFm = { ...parsed.frontmatter };
184
+ let body = parsed.content;
185
+
186
+ for (const [k, v] of Object.entries(updates)) {
187
+ if (k === 'content') { body = v; continue; }
188
+ if (k.startsWith('_')) continue;
189
+ newFm[k] = v;
190
+ }
191
+
192
+ const yaml = serializeYaml(newFm);
193
+ fs.writeFileSync(filePath, '---\n' + yaml + '\n---\n\n' + (body || '') + '\n', 'utf-8');
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Body parser
198
+ // ---------------------------------------------------------------------------
199
+ function parseBody(req) {
200
+ return new Promise((resolve) => {
201
+ const chunks = [];
202
+ req.on('data', c => chunks.push(c));
203
+ req.on('end', () => {
204
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString())); }
205
+ catch { resolve({}); }
206
+ });
207
+ });
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // File scanner and in-memory model
212
+ // ---------------------------------------------------------------------------
213
+ const model = {
214
+ project: null,
215
+ milestones: [],
216
+ epics: [],
217
+ features: [],
218
+ sprints: [],
219
+ boards: [],
220
+ reviews: [],
221
+ metrics: null,
222
+ };
223
+
224
+ function safeReadFile(filePath) {
225
+ try {
226
+ return fs.readFileSync(filePath, 'utf-8');
227
+ } catch {
228
+ return null;
229
+ }
230
+ }
231
+
232
+ function scanAll() {
233
+ model.project = null;
234
+ model.milestones = [];
235
+ model.epics = [];
236
+ model.features = [];
237
+ model.sprints = [];
238
+ model.boards = [];
239
+ model.reviews = [];
240
+ model.metrics = null;
241
+
242
+ if (!fs.existsSync(projectPath)) return;
243
+
244
+ const projectMd = safeReadFile(path.join(projectPath, 'PROJECT.md'));
245
+ if (projectMd) {
246
+ const parsed = parseFrontmatter(projectMd);
247
+ model.project = { ...parsed.frontmatter, content: parsed.content, _file: 'PROJECT.md' };
248
+ }
249
+
250
+ const metricsMd = safeReadFile(path.join(projectPath, 'metrics.md'));
251
+ if (metricsMd) {
252
+ const parsed = parseFrontmatter(metricsMd);
253
+ model.metrics = { ...parsed.frontmatter, content: parsed.content, _file: 'metrics.md' };
254
+ }
255
+
256
+ const milestonesDir = path.join(projectPath, 'milestones');
257
+ if (fs.existsSync(milestonesDir)) {
258
+ for (const ms of safeDirEntries(milestonesDir)) {
259
+ const msPath = path.join(milestonesDir, ms);
260
+ if (!fs.statSync(msPath).isDirectory()) continue;
261
+
262
+ const msReadme = safeReadFile(path.join(msPath, 'README.md'));
263
+ if (msReadme) {
264
+ const parsed = parseFrontmatter(msReadme);
265
+ model.milestones.push({ ...parsed.frontmatter, content: parsed.content, _dir: ms, _file: `milestones/${ms}/README.md` });
266
+ }
267
+
268
+ const epicsDir = path.join(msPath, 'epics');
269
+ if (fs.existsSync(epicsDir)) {
270
+ for (const epic of safeDirEntries(epicsDir)) {
271
+ const epicPath = path.join(epicsDir, epic);
272
+ if (!fs.statSync(epicPath).isDirectory()) continue;
273
+
274
+ const epicReadme = safeReadFile(path.join(epicPath, 'README.md'));
275
+ if (epicReadme) {
276
+ const parsed = parseFrontmatter(epicReadme);
277
+ model.epics.push({ ...parsed.frontmatter, content: parsed.content, _dir: epic, _milestone: ms, _file: `milestones/${ms}/epics/${epic}/README.md` });
278
+ }
279
+
280
+ const backlogDir = path.join(epicPath, 'backlog');
281
+ if (fs.existsSync(backlogDir)) {
282
+ for (const feat of safeDirEntries(backlogDir)) {
283
+ if (!feat.startsWith('FEAT-') || !feat.endsWith('.md')) continue;
284
+
285
+ const featContent = safeReadFile(path.join(backlogDir, feat));
286
+ if (featContent) {
287
+ const parsed = parseFrontmatter(featContent);
288
+ model.features.push({
289
+ ...parsed.frontmatter,
290
+ content: parsed.content,
291
+ _filename: feat,
292
+ _epic: epic,
293
+ _milestone: ms,
294
+ _file: `milestones/${ms}/epics/${epic}/backlog/${feat}`,
295
+ });
296
+ }
297
+ }
298
+ }
299
+ }
300
+ }
301
+
302
+ const sprintsDir = path.join(msPath, 'sprints');
303
+ if (fs.existsSync(sprintsDir)) {
304
+ for (const sp of safeDirEntries(sprintsDir)) {
305
+ const spPath = path.join(sprintsDir, sp);
306
+ if (!fs.statSync(spPath).isDirectory()) continue;
307
+
308
+ const planMd = safeReadFile(path.join(spPath, 'plan.md'));
309
+ if (planMd) {
310
+ const parsed = parseFrontmatter(planMd);
311
+ model.sprints.push({ ...parsed.frontmatter, content: parsed.content, _dir: sp, _milestone: ms, _file: `milestones/${ms}/sprints/${sp}/plan.md` });
312
+ }
313
+
314
+ const boardMd = safeReadFile(path.join(spPath, 'board.md'));
315
+ if (boardMd) {
316
+ const parsed = parseFrontmatter(boardMd);
317
+ model.boards.push({ ...parsed.frontmatter, content: parsed.content, _dir: sp, _milestone: ms, _file: `milestones/${ms}/sprints/${sp}/board.md` });
318
+ }
319
+
320
+ const reviewMd = safeReadFile(path.join(spPath, 'review.md'));
321
+ if (reviewMd) {
322
+ const parsed = parseFrontmatter(reviewMd);
323
+ model.reviews.push({ ...parsed.frontmatter, content: parsed.content, _dir: sp, _milestone: ms, _file: `milestones/${ms}/sprints/${sp}/review.md` });
324
+ }
325
+ }
326
+ }
327
+ }
328
+ }
329
+
330
+ computeProgress();
331
+ }
332
+
333
+ function safeDirEntries(dir) {
334
+ try {
335
+ return fs.readdirSync(dir).filter(e => !e.startsWith('.'));
336
+ } catch {
337
+ return [];
338
+ }
339
+ }
340
+
341
+ function computeProgress() {
342
+ for (const epic of model.epics) {
343
+ const epicFeatures = model.features.filter(f => f._epic === epic._dir && f._milestone === epic._milestone);
344
+ const done = epicFeatures.filter(f => f.status === 'done').length;
345
+ epic._featureCount = epicFeatures.length;
346
+ epic._completedCount = done;
347
+ epic._progress = epicFeatures.length > 0 ? Math.round((done / epicFeatures.length) * 100) : 0;
348
+ epic._totalPoints = epicFeatures.reduce((sum, f) => sum + (f.points || 0), 0);
349
+ }
350
+
351
+ for (const ms of model.milestones) {
352
+ const msFeatures = model.features.filter(f => f._milestone === ms._dir);
353
+ const done = msFeatures.filter(f => f.status === 'done').length;
354
+ ms._featureCount = msFeatures.length;
355
+ ms._completedCount = done;
356
+ ms._progress = msFeatures.length > 0 ? Math.round((done / msFeatures.length) * 100) : 0;
357
+ }
358
+ }
359
+
360
+ // ---------------------------------------------------------------------------
361
+ // SSE clients
362
+ // ---------------------------------------------------------------------------
363
+ const sseClients = new Set();
364
+
365
+ function broadcast(data) {
366
+ const payload = `data: ${JSON.stringify(data)}\n\n`;
367
+ for (const res of sseClients) {
368
+ try { res.write(payload); } catch { sseClients.delete(res); }
369
+ }
370
+ }
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // File watcher
374
+ // ---------------------------------------------------------------------------
375
+ const watchTimers = new Map();
376
+
377
+ function setupWatcher() {
378
+ if (!fs.existsSync(projectPath)) return;
379
+
380
+ try {
381
+ fs.watch(projectPath, { recursive: true }, (eventType, filename) => {
382
+ if (!filename || !filename.endsWith('.md')) return;
383
+
384
+ const key = filename;
385
+ if (watchTimers.has(key)) clearTimeout(watchTimers.get(key));
386
+
387
+ watchTimers.set(key, setTimeout(() => {
388
+ watchTimers.delete(key);
389
+ scanAll();
390
+ broadcast({ type: 'update', file: filename, timestamp: new Date().toISOString() });
391
+ }, 200));
392
+ });
393
+ } catch {
394
+ console.warn(' Warning: file watching unavailable on this platform');
395
+ }
396
+ }
397
+
398
+ // ---------------------------------------------------------------------------
399
+ // API handlers
400
+ // ---------------------------------------------------------------------------
401
+ function jsonResponse(res, data, status = 200) {
402
+ res.writeHead(status, {
403
+ 'Content-Type': 'application/json',
404
+ 'Access-Control-Allow-Origin': '*',
405
+ 'Access-Control-Allow-Methods': 'GET, PATCH, OPTIONS',
406
+ 'Access-Control-Allow-Headers': 'Content-Type',
407
+ });
408
+ res.end(JSON.stringify(data, null, 2));
409
+ }
410
+
411
+ async function handlePatch(req, res, collection, id) {
412
+ const body = await parseBody(req);
413
+
414
+ let item;
415
+ switch (collection) {
416
+ case 'features': item = model.features.find(f => f.id === id); break;
417
+ case 'epics': item = model.epics.find(e => e.id === id); break;
418
+ case 'milestones': item = model.milestones.find(m => m.id === id); break;
419
+ case 'sprints': item = model.sprints.find(s => s.id === id); break;
420
+ }
421
+
422
+ if (!item) return jsonResponse(res, { error: collection.slice(0, -1) + ' not found: ' + id }, 404);
423
+
424
+ try {
425
+ updateMarkdownFile(item._file, body);
426
+ scanAll();
427
+ broadcast({ type: 'update', timestamp: new Date().toISOString() });
428
+ return jsonResponse(res, { ok: true });
429
+ } catch (err) {
430
+ return jsonResponse(res, { error: err.message }, 500);
431
+ }
432
+ }
433
+
434
+ async function handleApi(req, res) {
435
+ const url = new URL(req.url, `http://localhost:${port}`);
436
+ const pathname = url.pathname;
437
+
438
+ // CORS preflight
439
+ if (req.method === 'OPTIONS') {
440
+ res.writeHead(204, {
441
+ 'Access-Control-Allow-Origin': '*',
442
+ 'Access-Control-Allow-Methods': 'GET, PATCH, OPTIONS',
443
+ 'Access-Control-Allow-Headers': 'Content-Type',
444
+ });
445
+ return res.end();
446
+ }
447
+
448
+ // PATCH routes
449
+ if (req.method === 'PATCH') {
450
+ let match;
451
+ if ((match = pathname.match(/^\/api\/features\/(.+)$/))) {
452
+ return handlePatch(req, res, 'features', decodeURIComponent(match[1]));
453
+ }
454
+ if ((match = pathname.match(/^\/api\/epics\/(.+)$/))) {
455
+ return handlePatch(req, res, 'epics', decodeURIComponent(match[1]));
456
+ }
457
+ if ((match = pathname.match(/^\/api\/milestones\/(.+)$/))) {
458
+ return handlePatch(req, res, 'milestones', decodeURIComponent(match[1]));
459
+ }
460
+ if ((match = pathname.match(/^\/api\/sprints\/(.+)$/))) {
461
+ return handlePatch(req, res, 'sprints', decodeURIComponent(match[1]));
462
+ }
463
+ return jsonResponse(res, { error: 'Not found' }, 404);
464
+ }
465
+
466
+ // GET routes
467
+ switch (pathname) {
468
+ case '/api/project':
469
+ return jsonResponse(res, model.project || {});
470
+
471
+ case '/api/milestones':
472
+ return jsonResponse(res, model.milestones.map(ms => ({
473
+ id: ms.id,
474
+ title: ms.title,
475
+ status: ms.status,
476
+ deadline: ms.deadline,
477
+ progress: ms._progress || 0,
478
+ featureCount: ms._featureCount || 0,
479
+ completedCount: ms._completedCount || 0,
480
+ created: ms.created,
481
+ content: ms.content,
482
+ })));
483
+
484
+ case '/api/epics':
485
+ return jsonResponse(res, model.epics.map(e => ({
486
+ id: e.id,
487
+ title: e.title,
488
+ milestone: e._milestone,
489
+ status: e.status,
490
+ priority: e.priority,
491
+ dependencies: e.dependencies,
492
+ featureCount: e._featureCount || 0,
493
+ completedCount: e._completedCount || 0,
494
+ totalPoints: e._totalPoints || 0,
495
+ progress: e._progress || 0,
496
+ content: e.content,
497
+ })));
498
+
499
+ case '/api/features': {
500
+ let features = model.features.map(f => ({
501
+ id: f.id,
502
+ title: f.title,
503
+ epic: f._epic || f.epic,
504
+ milestone: f._milestone || f.milestone,
505
+ sprint: f.sprint,
506
+ status: f.status,
507
+ priority: f.priority,
508
+ points: f.points,
509
+ assigned: f.assigned,
510
+ branches: f.branches,
511
+ pull_requests: f.pull_requests,
512
+ created: f.created,
513
+ started: f.started,
514
+ completed: f.completed,
515
+ content: f.content,
516
+ }));
517
+
518
+ const status = url.searchParams.get('status');
519
+ const epic = url.searchParams.get('epic');
520
+ const milestone = url.searchParams.get('milestone');
521
+ const sprint = url.searchParams.get('sprint');
522
+
523
+ if (status) features = features.filter(f => f.status === status);
524
+ if (epic) features = features.filter(f => f.epic === epic);
525
+ if (milestone) features = features.filter(f => f.milestone === milestone);
526
+ if (sprint) features = features.filter(f => f.sprint === sprint);
527
+
528
+ return jsonResponse(res, features);
529
+ }
530
+
531
+ case '/api/sprints':
532
+ return jsonResponse(res, model.sprints.map(s => ({
533
+ id: s.id,
534
+ milestone: s._milestone,
535
+ status: s.status,
536
+ goal: s.goal,
537
+ start_date: s.start_date,
538
+ end_date: s.end_date,
539
+ planned_points: s.planned_points,
540
+ completed_points: s.completed_points,
541
+ features: s.features,
542
+ })));
543
+
544
+ case '/api/sprint': {
545
+ const activeSprint = model.sprints.find(s => s.status === 'active');
546
+ if (!activeSprint) return jsonResponse(res, null);
547
+
548
+ const board = model.boards.find(b => b._dir === activeSprint._dir && b._milestone === activeSprint._milestone);
549
+ return jsonResponse(res, {
550
+ id: activeSprint.id,
551
+ milestone: activeSprint._milestone,
552
+ status: activeSprint.status,
553
+ goal: activeSprint.goal,
554
+ start_date: activeSprint.start_date,
555
+ end_date: activeSprint.end_date,
556
+ planned_points: activeSprint.planned_points,
557
+ completed_points: activeSprint.completed_points,
558
+ features: activeSprint.features,
559
+ board: board ? board.content : null,
560
+ });
561
+ }
562
+
563
+ case '/api/metrics':
564
+ return jsonResponse(res, model.metrics || {});
565
+
566
+ case '/api/health': {
567
+ const activeMilestone = model.milestones.find(m => m.status === 'active');
568
+ const activeSprint = model.sprints.find(s => s.status === 'active');
569
+ const totalFeatures = model.features.length;
570
+ const completedFeatures = model.features.filter(f => f.status === 'done').length;
571
+ const inProgressFeatures = model.features.filter(f => f.status === 'in-progress').length;
572
+
573
+ const completedSprints = model.sprints.filter(s => s.status === 'completed');
574
+ let velocity = null;
575
+ if (completedSprints.length > 0) {
576
+ const totalVelocity = completedSprints.reduce((sum, s) => {
577
+ const planned = s.planned_points || 1;
578
+ const completed = s.completed_points || 0;
579
+ return sum + Math.round((completed / planned) * 100);
580
+ }, 0);
581
+ velocity = Math.round(totalVelocity / completedSprints.length);
582
+ }
583
+
584
+ return jsonResponse(res, {
585
+ status: 'ok',
586
+ activeMilestone: activeMilestone ? activeMilestone.id : null,
587
+ activeSprint: activeSprint ? activeSprint.id : null,
588
+ totalFeatures,
589
+ completedFeatures,
590
+ inProgressFeatures,
591
+ velocity,
592
+ });
593
+ }
594
+
595
+ case '/api/events':
596
+ res.writeHead(200, {
597
+ 'Content-Type': 'text/event-stream',
598
+ 'Cache-Control': 'no-cache',
599
+ 'Connection': 'keep-alive',
600
+ 'Access-Control-Allow-Origin': '*',
601
+ });
602
+ res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`);
603
+ sseClients.add(res);
604
+
605
+ const keepalive = setInterval(() => {
606
+ try { res.write(': keepalive\n\n'); } catch { /* client gone */ }
607
+ }, 30000);
608
+
609
+ req.on('close', () => {
610
+ sseClients.delete(res);
611
+ clearInterval(keepalive);
612
+ });
613
+ return;
614
+
615
+ default:
616
+ return jsonResponse(res, { error: 'Not found' }, 404);
617
+ }
618
+ }
619
+
620
+ // ---------------------------------------------------------------------------
621
+ // Static file serving
622
+ // ---------------------------------------------------------------------------
623
+ const MIME_TYPES = {
624
+ '.html': 'text/html',
625
+ '.css': 'text/css',
626
+ '.js': 'application/javascript',
627
+ '.json': 'application/json',
628
+ '.png': 'image/png',
629
+ '.svg': 'image/svg+xml',
630
+ '.ico': 'image/x-icon',
631
+ };
632
+
633
+ function serveStatic(req, res) {
634
+ let filePath = path.join(boardDir, 'index.html');
635
+
636
+ if (!filePath.startsWith(boardDir)) {
637
+ res.writeHead(403);
638
+ res.end('Forbidden');
639
+ return;
640
+ }
641
+
642
+ const ext = path.extname(filePath);
643
+ const contentType = MIME_TYPES[ext] || 'text/plain';
644
+
645
+ try {
646
+ const content = fs.readFileSync(filePath);
647
+ res.writeHead(200, { 'Content-Type': contentType });
648
+ res.end(content);
649
+ } catch {
650
+ res.writeHead(404);
651
+ res.end('Not found');
652
+ }
653
+ }
654
+
655
+ // ---------------------------------------------------------------------------
656
+ // HTTP server
657
+ // ---------------------------------------------------------------------------
658
+ const server = http.createServer(async (req, res) => {
659
+ try {
660
+ if (req.url.startsWith('/api/')) {
661
+ await handleApi(req, res);
662
+ } else {
663
+ serveStatic(req, res);
664
+ }
665
+ } catch (err) {
666
+ if (!res.headersSent) {
667
+ res.writeHead(500, { 'Content-Type': 'application/json' });
668
+ res.end(JSON.stringify({ error: 'Internal server error' }));
669
+ }
670
+ }
671
+ });
672
+
673
+ // ---------------------------------------------------------------------------
674
+ // Startup
675
+ // ---------------------------------------------------------------------------
676
+ if (!fs.existsSync(projectPath)) {
677
+ console.warn(`\n Warning: project/ directory not found at ${projectPath}`);
678
+ console.warn(' Run `mdboard init` to scaffold a new project.\n');
679
+ }
680
+
681
+ scanAll();
682
+ setupWatcher();
683
+
684
+ server.listen(port, () => {
685
+ console.log(`
686
+ mdboard — Project Dashboard
687
+ Project: ${projectDir}
688
+ Server: http://localhost:${port}
689
+ ${fs.existsSync(projectPath) ? '\n Watching project/ for changes...' : ''}
690
+ `);
691
+ });
692
+
693
+ server.on('error', (err) => {
694
+ if (err.code === 'EADDRINUSE') {
695
+ console.error(`\n Error: Port ${port} is already in use. Use --port <number> to pick a different port.\n`);
696
+ process.exit(1);
697
+ }
698
+ throw err;
699
+ });
700
+
701
+ process.on('SIGINT', () => {
702
+ console.log('\n Shutting down...');
703
+ for (const client of sseClients) {
704
+ try { client.end(); } catch { /* ignore */ }
705
+ }
706
+ server.close(() => process.exit(0));
707
+ });
708
+
709
+ process.on('SIGTERM', () => {
710
+ server.close(() => process.exit(0));
711
+ });