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