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/LICENSE +21 -0
- package/README.md +191 -0
- package/bin.js +46 -0
- package/index.html +1067 -0
- package/init.js +68 -0
- package/package.json +33 -0
- package/server.js +711 -0
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
|
+
});
|