mdboard 1.2.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/bin.js +44 -16
- package/build.js +44 -0
- package/index.html +1835 -216
- package/package.json +7 -10
- package/src/cli/cli.js +362 -0
- package/src/cli/init.js +123 -0
- package/src/cli/status.js +150 -0
- package/src/cli/sync.js +194 -0
- package/src/cli/theme.js +142 -0
- package/src/client/app.js +266 -0
- package/src/client/board.js +157 -0
- package/src/client/core.js +331 -0
- package/src/client/editor.js +318 -0
- package/src/client/history.js +137 -0
- package/src/client/metrics.js +38 -0
- package/src/client/milestones.js +77 -0
- package/src/client/notes.js +183 -0
- package/src/client/overview.js +104 -0
- package/src/client/panel.js +637 -0
- package/src/client/styles.css +471 -0
- package/src/client/table.js +111 -0
- package/src/client/template.html +144 -0
- package/src/client/themes.js +261 -0
- package/src/client/workspace.js +164 -0
- package/src/core/agent-scanner.js +260 -0
- package/{config.js → src/core/config.js} +27 -2
- package/src/core/history.js +130 -0
- package/{scanner.js → src/core/scanner.js} +141 -21
- package/{yaml.js → src/core/yaml.js} +5 -1
- package/{api.js → src/server/api.js} +150 -9
- package/{server.js → src/server/server.js} +105 -32
- package/{watcher.js → src/server/watcher.js} +40 -9
- package/init.js +0 -109
- /package/{workspace.js → src/core/workspace.js} +0 -0
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
const { URL } = require('url');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const { resolveTheme } = require('../core/config');
|
|
17
|
+
const { isValidProject } = require('../core/history');
|
|
15
18
|
|
|
16
19
|
function jsonResponse(res, data, status = 200) {
|
|
17
20
|
res.writeHead(status, {
|
|
@@ -41,6 +44,7 @@ function buildPatchMap(config) {
|
|
|
41
44
|
const map = {
|
|
42
45
|
features: 'tasks', tasks: 'tasks',
|
|
43
46
|
epics: 'epics', milestones: 'milestones', sprints: 'sprints',
|
|
47
|
+
notes: 'notes',
|
|
44
48
|
};
|
|
45
49
|
map[config.entities.task.plural.toLowerCase()] = 'tasks';
|
|
46
50
|
map[config.entities.epic.plural.toLowerCase()] = 'epics';
|
|
@@ -78,6 +82,8 @@ function formatTask(f) {
|
|
|
78
82
|
started: f.started,
|
|
79
83
|
completed: f.completed,
|
|
80
84
|
content: f.content,
|
|
85
|
+
ai: f._ai && Object.keys(f._ai).length > 0 ? f._ai : undefined,
|
|
86
|
+
aiOwn: f._aiOwn && Object.keys(f._aiOwn).length > 0 ? f._aiOwn : undefined,
|
|
81
87
|
...sourceFields(f),
|
|
82
88
|
};
|
|
83
89
|
}
|
|
@@ -94,6 +100,8 @@ function formatMilestone(ms) {
|
|
|
94
100
|
completedCount: ms._completedCount || 0,
|
|
95
101
|
created: ms.created,
|
|
96
102
|
content: ms.content,
|
|
103
|
+
ai: ms._ai && Object.keys(ms._ai).length > 0 ? ms._ai : undefined,
|
|
104
|
+
aiOwn: ms._aiOwn && Object.keys(ms._aiOwn).length > 0 ? ms._aiOwn : undefined,
|
|
97
105
|
...sourceFields(ms),
|
|
98
106
|
};
|
|
99
107
|
}
|
|
@@ -111,6 +119,8 @@ function formatEpic(e) {
|
|
|
111
119
|
totalPoints: e._totalPoints || 0,
|
|
112
120
|
progress: e._progress || 0,
|
|
113
121
|
content: e.content,
|
|
122
|
+
ai: e._ai && Object.keys(e._ai).length > 0 ? e._ai : undefined,
|
|
123
|
+
aiOwn: e._aiOwn && Object.keys(e._aiOwn).length > 0 ? e._aiOwn : undefined,
|
|
114
124
|
...sourceFields(e),
|
|
115
125
|
};
|
|
116
126
|
}
|
|
@@ -130,6 +140,18 @@ function formatSprint(s) {
|
|
|
130
140
|
};
|
|
131
141
|
}
|
|
132
142
|
|
|
143
|
+
function formatNote(n, includeContent) {
|
|
144
|
+
const result = {
|
|
145
|
+
id: n.id,
|
|
146
|
+
title: n.title,
|
|
147
|
+
created: n.created,
|
|
148
|
+
updated: n.updated,
|
|
149
|
+
...sourceFields(n),
|
|
150
|
+
};
|
|
151
|
+
if (includeContent) result.content = n.content;
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
|
|
133
155
|
function filterTasks(tasks, url) {
|
|
134
156
|
const status = url.searchParams.get('status');
|
|
135
157
|
const epic = url.searchParams.get('epic');
|
|
@@ -191,7 +213,7 @@ function itemsBySource(model, sourceName) {
|
|
|
191
213
|
sprints: filter(model.sprints),
|
|
192
214
|
boards: filter(model.boards),
|
|
193
215
|
reviews: filter(model.reviews),
|
|
194
|
-
|
|
216
|
+
notes: filter(model.notes),
|
|
195
217
|
};
|
|
196
218
|
}
|
|
197
219
|
|
|
@@ -233,8 +255,71 @@ async function handleApi(req, res, ctx) {
|
|
|
233
255
|
return handleOverviewRoute(req, res, ctx, url, pathname);
|
|
234
256
|
}
|
|
235
257
|
|
|
258
|
+
// ─── GET /api/history ───────────────────────────────────────────────
|
|
259
|
+
if (req.method === 'GET' && pathname === '/api/history') {
|
|
260
|
+
if (ctx.getProjectHistory) {
|
|
261
|
+
return jsonResponse(res, ctx.getProjectHistory());
|
|
262
|
+
}
|
|
263
|
+
return jsonResponse(res, []);
|
|
264
|
+
}
|
|
265
|
+
|
|
236
266
|
// ─── POST routes ────────────────────────────────────────────────────
|
|
237
267
|
if (req.method === 'POST') {
|
|
268
|
+
if (pathname === '/api/history/switch') {
|
|
269
|
+
const body = await parseBody(req);
|
|
270
|
+
const targetPath = body && body.path;
|
|
271
|
+
if (!targetPath) {
|
|
272
|
+
return jsonResponse(res, { error: 'Missing path' }, 400);
|
|
273
|
+
}
|
|
274
|
+
if (!fs.existsSync(targetPath)) {
|
|
275
|
+
return jsonResponse(res, { error: 'Directory does not exist: ' + targetPath }, 400);
|
|
276
|
+
}
|
|
277
|
+
if (!isValidProject(targetPath)) {
|
|
278
|
+
return jsonResponse(res, { error: 'Not a valid mdboard project: ' + targetPath }, 400);
|
|
279
|
+
}
|
|
280
|
+
if (ctx.loadProject) {
|
|
281
|
+
try {
|
|
282
|
+
ctx.loadProject(targetPath);
|
|
283
|
+
const name = (ctx.model && ctx.model.project && ctx.model.project.name) || require('path').basename(targetPath);
|
|
284
|
+
return jsonResponse(res, { ok: true, projectDir: ctx.projectDir, name });
|
|
285
|
+
} catch (err) {
|
|
286
|
+
return jsonResponse(res, { error: err.message }, 500);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return jsonResponse(res, { error: 'Project switching not available' }, 500);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (pathname === '/api/theme') {
|
|
293
|
+
const body = await parseBody(req);
|
|
294
|
+
if (!body.themeId || !body.css) {
|
|
295
|
+
return jsonResponse(res, { error: 'Missing themeId or css' }, 400);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const path = require('path');
|
|
299
|
+
const os = require('os');
|
|
300
|
+
|
|
301
|
+
if (body.setDefault) {
|
|
302
|
+
// Global: write CSS to ~/.config/mdboard/mdboard.css
|
|
303
|
+
const globalDir = path.join(os.homedir(), '.config', 'mdboard');
|
|
304
|
+
if (!fs.existsSync(globalDir)) fs.mkdirSync(globalDir, { recursive: true });
|
|
305
|
+
fs.writeFileSync(path.join(globalDir, 'mdboard.css'), body.css, 'utf-8');
|
|
306
|
+
// Also save theme ID in config JSON
|
|
307
|
+
const configPath = path.join(globalDir, 'mdboard.json');
|
|
308
|
+
let cfg = {};
|
|
309
|
+
try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8')); } catch {}
|
|
310
|
+
cfg.theme = body.themeId;
|
|
311
|
+
fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2) + '\n', 'utf-8');
|
|
312
|
+
} else {
|
|
313
|
+
// Local: write CSS to project/mdboard.css
|
|
314
|
+
const projectCssPath = path.join(ctx.projectDir, 'project', 'mdboard.css');
|
|
315
|
+
const fallbackCssPath = path.join(ctx.projectDir, 'mdboard.css');
|
|
316
|
+
const cssPath = fs.existsSync(path.dirname(projectCssPath)) ? projectCssPath : fallbackCssPath;
|
|
317
|
+
fs.writeFileSync(cssPath, body.css, 'utf-8');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return jsonResponse(res, { ok: true });
|
|
321
|
+
}
|
|
322
|
+
|
|
238
323
|
if (pathname === '/api/sources/sync') {
|
|
239
324
|
if (ctx.syncRemotes) {
|
|
240
325
|
try {
|
|
@@ -248,7 +333,7 @@ async function handleApi(req, res, ctx) {
|
|
|
248
333
|
}
|
|
249
334
|
|
|
250
335
|
// Legacy CRUD: POST /api/tasks, /api/milestones, etc.
|
|
251
|
-
const postMap = { tasks: 'tasks', milestones: 'milestones', epics: 'epics', sprints: 'sprints' };
|
|
336
|
+
const postMap = { tasks: 'tasks', milestones: 'milestones', epics: 'epics', sprints: 'sprints', notes: 'notes' };
|
|
252
337
|
const postMatch = pathname.match(/^\/api\/([\w-]+)$/);
|
|
253
338
|
if (postMatch && postMap[postMatch[1]]) {
|
|
254
339
|
return handleLegacyCreate(req, res, ctx, postMap[postMatch[1]]);
|
|
@@ -283,6 +368,17 @@ async function handleApi(req, res, ctx) {
|
|
|
283
368
|
return jsonResponse(res, { error: 'Not found' }, 404);
|
|
284
369
|
}
|
|
285
370
|
|
|
371
|
+
// ─── GET /api/notes/:id ─────────────────────────────────────────────
|
|
372
|
+
if (req.method === 'GET') {
|
|
373
|
+
const noteMatch = pathname.match(/^\/api\/notes\/(.+)$/);
|
|
374
|
+
if (noteMatch) {
|
|
375
|
+
const noteId = decodeURIComponent(noteMatch[1]);
|
|
376
|
+
const note = model.notes.find(n => n.id === noteId);
|
|
377
|
+
if (!note) return jsonResponse(res, { error: 'Note not found: ' + noteId }, 404);
|
|
378
|
+
return jsonResponse(res, formatNote(note, true));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
286
382
|
// ─── GET routes (legacy + global) ──────────────────────────────────
|
|
287
383
|
switch (pathname) {
|
|
288
384
|
case '/api/config': {
|
|
@@ -293,6 +389,10 @@ async function handleApi(req, res, ctx) {
|
|
|
293
389
|
boardColumns: config.boardColumns,
|
|
294
390
|
completedStatus: config.completedStatus,
|
|
295
391
|
};
|
|
392
|
+
const theme = resolveTheme(ctx.projectDir, process.env.MDBOARD_CONFIG);
|
|
393
|
+
result.theme = theme || null;
|
|
394
|
+
result.hasProject = ctx.hasProject != null ? ctx.hasProject : true;
|
|
395
|
+
result.projectDir = ctx.projectDir || null;
|
|
296
396
|
if (sources && sources.length > 0) {
|
|
297
397
|
result.workspace = {
|
|
298
398
|
sources: sources.map(s => ({
|
|
@@ -337,8 +437,17 @@ async function handleApi(req, res, ctx) {
|
|
|
337
437
|
});
|
|
338
438
|
}
|
|
339
439
|
|
|
340
|
-
case '/api/metrics':
|
|
341
|
-
|
|
440
|
+
case '/api/metrics': {
|
|
441
|
+
const cs = config.completedStatus;
|
|
442
|
+
return jsonResponse(res, {
|
|
443
|
+
totalTasks: model.tasks.length,
|
|
444
|
+
completedTasks: model.tasks.filter(t => t.status === cs).length,
|
|
445
|
+
totalMilestones: model.milestones.length,
|
|
446
|
+
totalEpics: model.epics.length,
|
|
447
|
+
totalPoints: model.tasks.reduce((sum, t) => sum + (t.points || 0), 0),
|
|
448
|
+
completedPoints: model.tasks.filter(t => t.status === cs).reduce((sum, t) => sum + (t.points || 0), 0),
|
|
449
|
+
});
|
|
450
|
+
}
|
|
342
451
|
|
|
343
452
|
case '/api/health':
|
|
344
453
|
return jsonResponse(res, computeHealth(model, config));
|
|
@@ -364,6 +473,12 @@ async function handleApi(req, res, ctx) {
|
|
|
364
473
|
}));
|
|
365
474
|
}
|
|
366
475
|
|
|
476
|
+
case '/api/notes':
|
|
477
|
+
return jsonResponse(res, model.notes.map(function(n) { return formatNote(n, false); }));
|
|
478
|
+
|
|
479
|
+
case '/api/ai-suggestions':
|
|
480
|
+
return jsonResponse(res, ctx.getAiSuggestions ? ctx.getAiSuggestions() : { skills: [], agents: [], mcps: [], commands: [], context: [] });
|
|
481
|
+
|
|
367
482
|
case '/api/events': {
|
|
368
483
|
res.writeHead(200, {
|
|
369
484
|
'Content-Type': 'text/event-stream',
|
|
@@ -411,6 +526,15 @@ async function handleSourceRoute(req, res, ctx, url, sourceName, subPath) {
|
|
|
411
526
|
|
|
412
527
|
// ── GET routes ──
|
|
413
528
|
if (req.method === 'GET') {
|
|
529
|
+
// GET /api/sources/:name/notes/:id
|
|
530
|
+
const noteSubMatch = subPath.match(/^notes\/(.+)$/);
|
|
531
|
+
if (noteSubMatch) {
|
|
532
|
+
const noteId = decodeURIComponent(noteSubMatch[1]);
|
|
533
|
+
const note = (sourceModel.notes || []).find(n => n.id === noteId);
|
|
534
|
+
if (!note) return jsonResponse(res, { error: 'Note not found: ' + noteId }, 404);
|
|
535
|
+
return jsonResponse(res, formatNote(note, true));
|
|
536
|
+
}
|
|
537
|
+
|
|
414
538
|
switch (subPath) {
|
|
415
539
|
case 'config': {
|
|
416
540
|
// Source-specific config (from sourceStates if available)
|
|
@@ -426,8 +550,11 @@ async function handleSourceRoute(req, res, ctx, url, sourceName, subPath) {
|
|
|
426
550
|
});
|
|
427
551
|
}
|
|
428
552
|
|
|
429
|
-
case 'project':
|
|
430
|
-
|
|
553
|
+
case 'project': {
|
|
554
|
+
const sourceState = ctx.sourceStates ? ctx.sourceStates.get(sourceName) : null;
|
|
555
|
+
const proj = sourceState && sourceState.model ? sourceState.model.project : sourceModel.project;
|
|
556
|
+
return jsonResponse(res, proj || {});
|
|
557
|
+
}
|
|
431
558
|
|
|
432
559
|
case 'milestones':
|
|
433
560
|
return jsonResponse(res, sourceModel.milestones.map(formatMilestone));
|
|
@@ -453,11 +580,23 @@ async function handleSourceRoute(req, res, ctx, url, sourceName, subPath) {
|
|
|
453
580
|
});
|
|
454
581
|
}
|
|
455
582
|
|
|
583
|
+
case 'notes':
|
|
584
|
+
return jsonResponse(res, (sourceModel.notes || []).map(function(n) { return formatNote(n, false); }));
|
|
585
|
+
|
|
456
586
|
case 'health':
|
|
457
587
|
return jsonResponse(res, computeHealth(sourceModel, config));
|
|
458
588
|
|
|
459
|
-
case 'metrics':
|
|
460
|
-
|
|
589
|
+
case 'metrics': {
|
|
590
|
+
const cs2 = config.completedStatus;
|
|
591
|
+
return jsonResponse(res, {
|
|
592
|
+
totalTasks: sourceModel.tasks.length,
|
|
593
|
+
completedTasks: sourceModel.tasks.filter(t => t.status === cs2).length,
|
|
594
|
+
totalMilestones: sourceModel.milestones.length,
|
|
595
|
+
totalEpics: sourceModel.epics.length,
|
|
596
|
+
totalPoints: sourceModel.tasks.reduce((sum, t) => sum + (t.points || 0), 0),
|
|
597
|
+
completedPoints: sourceModel.tasks.filter(t => t.status === cs2).reduce((sum, t) => sum + (t.points || 0), 0),
|
|
598
|
+
});
|
|
599
|
+
}
|
|
461
600
|
|
|
462
601
|
default:
|
|
463
602
|
return jsonResponse(res, { error: 'Not found' }, 404);
|
|
@@ -470,7 +609,7 @@ async function handleSourceRoute(req, res, ctx, url, sourceName, subPath) {
|
|
|
470
609
|
return jsonResponse(res, { error: 'Source is read-only' }, 403);
|
|
471
610
|
}
|
|
472
611
|
|
|
473
|
-
const entityMap = { tasks: 'tasks', milestones: 'milestones', epics: 'epics', sprints: 'sprints' };
|
|
612
|
+
const entityMap = { tasks: 'tasks', milestones: 'milestones', epics: 'epics', sprints: 'sprints', notes: 'notes' };
|
|
474
613
|
if (entityMap[subPath] && ctx.createItem) {
|
|
475
614
|
const body = await parseBody(req);
|
|
476
615
|
try {
|
|
@@ -656,6 +795,7 @@ async function handlePatch(req, res, collection, id, ctx, sourceName) {
|
|
|
656
795
|
case 'epics': item = findIn(model.epics); break;
|
|
657
796
|
case 'milestones': item = findIn(model.milestones); break;
|
|
658
797
|
case 'sprints': item = findIn(model.sprints); break;
|
|
798
|
+
case 'notes': item = findIn(model.notes); break;
|
|
659
799
|
}
|
|
660
800
|
|
|
661
801
|
if (!item) return jsonResponse(res, { error: collection.slice(0, -1) + ' not found: ' + id }, 404);
|
|
@@ -695,6 +835,7 @@ async function handleDelete(req, res, ctx, collection, id, sourceName) {
|
|
|
695
835
|
case 'epics': item = findIn(model.epics); break;
|
|
696
836
|
case 'milestones': item = findIn(model.milestones); break;
|
|
697
837
|
case 'sprints': item = findIn(model.sprints); break;
|
|
838
|
+
case 'notes': item = findIn(model.notes); break;
|
|
698
839
|
}
|
|
699
840
|
|
|
700
841
|
if (!item) return jsonResponse(res, { error: collection.slice(0, -1) + ' not found: ' + id }, 404);
|
|
@@ -15,12 +15,14 @@ const http = require('http');
|
|
|
15
15
|
const fs = require('fs');
|
|
16
16
|
const path = require('path');
|
|
17
17
|
const os = require('os');
|
|
18
|
-
const { loadConfig, deepMerge } = require('
|
|
19
|
-
const { createModel, scanSource, computeProgress, updateMarkdownFile, mergeResults,
|
|
20
|
-
createTask, createMilestone, createEpic, createSprint, archiveItem } = require('
|
|
21
|
-
const {
|
|
18
|
+
const { loadConfig, deepMerge } = require('../core/config');
|
|
19
|
+
const { createModel, scanSource, computeProgress, computeAgentProps, updateMarkdownFile, mergeResults,
|
|
20
|
+
createTask, createMilestone, createEpic, createSprint, createNote, archiveItem } = require('../core/scanner');
|
|
21
|
+
const { scanAgentConfigs } = require('../core/agent-scanner');
|
|
22
|
+
const { setupWatchers, closeWatchers } = require('./watcher');
|
|
22
23
|
const { handleApi } = require('./api');
|
|
23
|
-
const { loadWorkspace, syncAllRemotes, getGitAuthor } = require('
|
|
24
|
+
const { loadWorkspace, syncAllRemotes, getGitAuthor } = require('../core/workspace');
|
|
25
|
+
const { registerProject, getHistory, isValidProject } = require('../core/history');
|
|
24
26
|
|
|
25
27
|
// ---------------------------------------------------------------------------
|
|
26
28
|
// CLI argument parsing
|
|
@@ -52,8 +54,8 @@ for (let i = 0; i < args.length; i++) {
|
|
|
52
54
|
}
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
|
|
56
|
-
const boardDir = __dirname;
|
|
57
|
+
let projectPath = path.join(projectDir, 'project');
|
|
58
|
+
const boardDir = path.resolve(__dirname, '..', '..');
|
|
57
59
|
|
|
58
60
|
// ---------------------------------------------------------------------------
|
|
59
61
|
// Load configuration
|
|
@@ -88,6 +90,12 @@ function broadcast(data) {
|
|
|
88
90
|
// ---------------------------------------------------------------------------
|
|
89
91
|
let model = createModel();
|
|
90
92
|
const sourceStates = new Map();
|
|
93
|
+
let aiSuggestions = null;
|
|
94
|
+
|
|
95
|
+
function refreshAiSuggestions() {
|
|
96
|
+
try { aiSuggestions = scanAgentConfigs(projectDir); }
|
|
97
|
+
catch { aiSuggestions = { skills: [], agents: [], mcps: [], commands: [], context: [] }; }
|
|
98
|
+
}
|
|
91
99
|
|
|
92
100
|
function loadSourceConfig(source) {
|
|
93
101
|
// Try to load per-source mdboard.json
|
|
@@ -159,6 +167,8 @@ function scanAll() {
|
|
|
159
167
|
}
|
|
160
168
|
|
|
161
169
|
computeProgress(model, config.completedStatus);
|
|
170
|
+
computeAgentProps(model);
|
|
171
|
+
refreshAiSuggestions();
|
|
162
172
|
}
|
|
163
173
|
|
|
164
174
|
// ---------------------------------------------------------------------------
|
|
@@ -311,6 +321,8 @@ function createItemFn(sourceName, collection, data) {
|
|
|
311
321
|
return createEpic(sourcePath, cfg, data, model.epics);
|
|
312
322
|
case 'sprints':
|
|
313
323
|
return createSprint(sourcePath, cfg, data, model.sprints);
|
|
324
|
+
case 'notes':
|
|
325
|
+
return createNote(sourcePath, data, model.notes);
|
|
314
326
|
default:
|
|
315
327
|
throw new Error('Unknown collection: ' + collection);
|
|
316
328
|
}
|
|
@@ -333,7 +345,7 @@ const apiCtx = {
|
|
|
333
345
|
get model() { return model; },
|
|
334
346
|
get config() { return config; },
|
|
335
347
|
port,
|
|
336
|
-
projectDir,
|
|
348
|
+
get projectDir() { return projectDir; },
|
|
337
349
|
get sources() { return sources; },
|
|
338
350
|
get sourceStates() { return sourceStates; },
|
|
339
351
|
sseClients,
|
|
@@ -343,6 +355,13 @@ const apiCtx = {
|
|
|
343
355
|
syncRemotes: doSyncRemotes,
|
|
344
356
|
createItem: createItemFn,
|
|
345
357
|
archiveItem: archiveItemFn,
|
|
358
|
+
getAiSuggestions: () => aiSuggestions || { skills: [], agents: [], mcps: [], commands: [], context: [] },
|
|
359
|
+
get hasProject() {
|
|
360
|
+
return fs.existsSync(path.join(projectDir, 'project')) ||
|
|
361
|
+
(workspace && sources.length > 0);
|
|
362
|
+
},
|
|
363
|
+
loadProject: loadProject,
|
|
364
|
+
getProjectHistory: () => getHistory(projectDir),
|
|
346
365
|
};
|
|
347
366
|
|
|
348
367
|
const server = http.createServer(async (req, res) => {
|
|
@@ -371,33 +390,84 @@ if (!workspace && !fs.existsSync(projectPath)) {
|
|
|
371
390
|
scanAll();
|
|
372
391
|
|
|
373
392
|
// Setup watchers
|
|
374
|
-
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
if (workspace && sources.length > 0) {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
393
|
+
function buildWatcherOpts() {
|
|
394
|
+
const pDirs = [];
|
|
395
|
+
const rDirs = [projectDir];
|
|
396
|
+
if (workspace && sources.length > 0) {
|
|
397
|
+
for (const s of sources) {
|
|
398
|
+
if (s._resolvedPath && fs.existsSync(s._resolvedPath)) {
|
|
399
|
+
pDirs.push(s._resolvedPath);
|
|
400
|
+
}
|
|
381
401
|
}
|
|
402
|
+
} else if (fs.existsSync(projectPath)) {
|
|
403
|
+
pDirs.push(projectPath);
|
|
382
404
|
}
|
|
383
|
-
|
|
384
|
-
|
|
405
|
+
return {
|
|
406
|
+
projectDirs: pDirs,
|
|
407
|
+
rootDirs: rDirs,
|
|
408
|
+
onScan: () => scanAll(),
|
|
409
|
+
onConfigReload: () => reloadConfig(),
|
|
410
|
+
onBroadcast: (data) => broadcast(data),
|
|
411
|
+
workspacePath: workspace ? workspace._path : null,
|
|
412
|
+
onWorkspaceChange: () => {
|
|
413
|
+
workspace = loadWorkspace(projectDir, workspacePath);
|
|
414
|
+
sources = workspace ? workspace.sources : [];
|
|
415
|
+
scanAll();
|
|
416
|
+
broadcast({ type: 'update', timestamp: new Date().toISOString() });
|
|
417
|
+
},
|
|
418
|
+
};
|
|
385
419
|
}
|
|
386
420
|
|
|
387
|
-
setupWatchers(
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
421
|
+
let activeWatchers = setupWatchers(buildWatcherOpts());
|
|
422
|
+
|
|
423
|
+
// Register current project in history
|
|
424
|
+
if (isValidProject(projectDir)) {
|
|
425
|
+
const projName = (model.project && model.project.name) || path.basename(projectDir);
|
|
426
|
+
registerProject(projectDir, projName);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Load a different project directory dynamically.
|
|
431
|
+
*
|
|
432
|
+
* @param {string} newProjectDir - Absolute path to the new project
|
|
433
|
+
*/
|
|
434
|
+
function loadProject(newProjectDir) {
|
|
435
|
+
// 1. Close existing watchers
|
|
436
|
+
closeWatchers(activeWatchers);
|
|
437
|
+
|
|
438
|
+
// 2. Stop sync interval
|
|
439
|
+
if (syncInterval) clearInterval(syncInterval);
|
|
440
|
+
syncInterval = null;
|
|
441
|
+
|
|
442
|
+
// 3. Update paths
|
|
443
|
+
projectDir = path.resolve(newProjectDir);
|
|
444
|
+
projectPath = path.join(projectDir, 'project');
|
|
445
|
+
workspacePath = null;
|
|
446
|
+
|
|
447
|
+
// 4. Reload config
|
|
448
|
+
config = loadConfig(projectDir);
|
|
449
|
+
|
|
450
|
+
// 5. Reload workspace
|
|
451
|
+
workspace = loadWorkspace(projectDir, null);
|
|
452
|
+
sources = workspace ? workspace.sources : [];
|
|
453
|
+
|
|
454
|
+
// 6. Rescan
|
|
455
|
+
scanAll();
|
|
456
|
+
|
|
457
|
+
// 7. Setup new watchers
|
|
458
|
+
activeWatchers = setupWatchers(buildWatcherOpts());
|
|
459
|
+
|
|
460
|
+
// 8. Restart remote sync
|
|
461
|
+
doSyncRemotes().catch(() => {});
|
|
462
|
+
startSyncInterval();
|
|
463
|
+
|
|
464
|
+
// 9. Register in history
|
|
465
|
+
const projName = (model.project && model.project.name) || path.basename(projectDir);
|
|
466
|
+
registerProject(projectDir, projName);
|
|
467
|
+
|
|
468
|
+
// 10. Broadcast to SSE clients
|
|
469
|
+
broadcast({ type: 'project-changed', projectDir });
|
|
470
|
+
}
|
|
401
471
|
|
|
402
472
|
// Initial async sync + start periodic sync
|
|
403
473
|
doSyncRemotes().catch(() => {});
|
|
@@ -407,11 +477,12 @@ server.listen(port, () => {
|
|
|
407
477
|
const sourceInfo = workspace && sources.length > 0
|
|
408
478
|
? `\n Sources: ${sources.map(s => s.label || s.name).join(', ')}`
|
|
409
479
|
: '';
|
|
480
|
+
const watchCount = activeWatchers.length;
|
|
410
481
|
console.log(`
|
|
411
482
|
mdboard — Project Dashboard
|
|
412
483
|
Project: ${projectDir}
|
|
413
484
|
Server: http://localhost:${port}${config._path ? '\n Config: ' + config._path : ''}${sourceInfo}
|
|
414
|
-
${
|
|
485
|
+
${watchCount > 0 ? '\n Watching ' + watchCount + ' director' + (watchCount === 1 ? 'y' : 'ies') + ' for changes...' : ''}
|
|
415
486
|
`);
|
|
416
487
|
});
|
|
417
488
|
|
|
@@ -426,6 +497,7 @@ server.on('error', (err) => {
|
|
|
426
497
|
process.on('SIGINT', () => {
|
|
427
498
|
console.log('\n Shutting down...');
|
|
428
499
|
if (syncInterval) clearInterval(syncInterval);
|
|
500
|
+
closeWatchers(activeWatchers);
|
|
429
501
|
for (const client of sseClients) {
|
|
430
502
|
try { client.end(); } catch { /* ignore */ }
|
|
431
503
|
}
|
|
@@ -434,5 +506,6 @@ process.on('SIGINT', () => {
|
|
|
434
506
|
|
|
435
507
|
process.on('SIGTERM', () => {
|
|
436
508
|
if (syncInterval) clearInterval(syncInterval);
|
|
509
|
+
closeWatchers(activeWatchers);
|
|
437
510
|
server.close(() => process.exit(0));
|
|
438
511
|
});
|
|
@@ -14,12 +14,13 @@ const watchTimers = new Map();
|
|
|
14
14
|
*
|
|
15
15
|
* @param {string} dir - Directory to watch recursively
|
|
16
16
|
* @param {object} callbacks - { onChange(filename), onConfigChange(), onCssChange(filename) }
|
|
17
|
+
* @returns {fs.FSWatcher|null}
|
|
17
18
|
*/
|
|
18
19
|
function watchDir(dir, callbacks) {
|
|
19
|
-
if (!fs.existsSync(dir)) return;
|
|
20
|
+
if (!fs.existsSync(dir)) return null;
|
|
20
21
|
|
|
21
22
|
try {
|
|
22
|
-
fs.watch(dir, { recursive: true }, (eventType, filename) => {
|
|
23
|
+
const watcher = fs.watch(dir, { recursive: true }, (eventType, filename) => {
|
|
23
24
|
if (!filename) return;
|
|
24
25
|
|
|
25
26
|
const isMd = filename.endsWith('.md');
|
|
@@ -37,8 +38,10 @@ function watchDir(dir, callbacks) {
|
|
|
37
38
|
if (callbacks.onChange) callbacks.onChange(filename, isCss);
|
|
38
39
|
}, 200));
|
|
39
40
|
});
|
|
41
|
+
return watcher;
|
|
40
42
|
} catch {
|
|
41
43
|
console.warn(' Warning: file watching unavailable for ' + dir);
|
|
44
|
+
return null;
|
|
42
45
|
}
|
|
43
46
|
}
|
|
44
47
|
|
|
@@ -47,12 +50,13 @@ function watchDir(dir, callbacks) {
|
|
|
47
50
|
*
|
|
48
51
|
* @param {string} filePath - Absolute path to file
|
|
49
52
|
* @param {function} onChange - Callback on change
|
|
53
|
+
* @returns {fs.FSWatcher|null}
|
|
50
54
|
*/
|
|
51
55
|
function watchFile(filePath, onChange) {
|
|
52
|
-
if (!fs.existsSync(filePath)) return;
|
|
56
|
+
if (!fs.existsSync(filePath)) return null;
|
|
53
57
|
|
|
54
58
|
try {
|
|
55
|
-
fs.watch(filePath, (eventType) => {
|
|
59
|
+
const watcher = fs.watch(filePath, (eventType) => {
|
|
56
60
|
const key = 'file:' + filePath;
|
|
57
61
|
if (watchTimers.has(key)) clearTimeout(watchTimers.get(key));
|
|
58
62
|
|
|
@@ -61,11 +65,31 @@ function watchFile(filePath, onChange) {
|
|
|
61
65
|
if (onChange) onChange();
|
|
62
66
|
}, 200));
|
|
63
67
|
});
|
|
68
|
+
return watcher;
|
|
64
69
|
} catch {
|
|
65
|
-
|
|
70
|
+
return null;
|
|
66
71
|
}
|
|
67
72
|
}
|
|
68
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Close all FSWatcher instances and clear pending timers.
|
|
76
|
+
*
|
|
77
|
+
* @param {Array<fs.FSWatcher|null>} watchers - Array of watchers to close
|
|
78
|
+
*/
|
|
79
|
+
function closeWatchers(watchers) {
|
|
80
|
+
if (!watchers) return;
|
|
81
|
+
for (const w of watchers) {
|
|
82
|
+
if (w) {
|
|
83
|
+
try { w.close(); } catch { /* ignore */ }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Clear all pending debounce timers
|
|
87
|
+
for (const [key, timer] of watchTimers) {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
}
|
|
90
|
+
watchTimers.clear();
|
|
91
|
+
}
|
|
92
|
+
|
|
69
93
|
/**
|
|
70
94
|
* Setup watchers for project directories and workspace-level config.
|
|
71
95
|
*
|
|
@@ -77,13 +101,15 @@ function watchFile(filePath, onChange) {
|
|
|
77
101
|
* @param {function} opts.onBroadcast - Called with event data to broadcast via SSE
|
|
78
102
|
* @param {string} [opts.workspacePath] - Path to workspace.json to watch
|
|
79
103
|
* @param {function} [opts.onWorkspaceChange] - Called when workspace.json changes
|
|
104
|
+
* @returns {Array<fs.FSWatcher|null>} - Array of active watchers
|
|
80
105
|
*/
|
|
81
106
|
function setupWatchers(opts) {
|
|
82
107
|
const { projectDirs, rootDirs, onScan, onConfigReload, onBroadcast, workspacePath, onWorkspaceChange } = opts;
|
|
108
|
+
const watchers = [];
|
|
83
109
|
|
|
84
110
|
// Watch each project directory recursively
|
|
85
111
|
for (const dir of projectDirs) {
|
|
86
|
-
watchDir(dir, {
|
|
112
|
+
const w = watchDir(dir, {
|
|
87
113
|
onConfigChange: () => {
|
|
88
114
|
if (onConfigReload) onConfigReload();
|
|
89
115
|
},
|
|
@@ -94,12 +120,13 @@ function setupWatchers(opts) {
|
|
|
94
120
|
if (onBroadcast) onBroadcast(eventData);
|
|
95
121
|
},
|
|
96
122
|
});
|
|
123
|
+
if (w) watchers.push(w);
|
|
97
124
|
}
|
|
98
125
|
|
|
99
126
|
// Watch root directories for workspace-level mdboard.json and mdboard.css
|
|
100
127
|
for (const dir of rootDirs) {
|
|
101
128
|
try {
|
|
102
|
-
fs.watch(dir, (eventType, filename) => {
|
|
129
|
+
const w = fs.watch(dir, (eventType, filename) => {
|
|
103
130
|
if (!filename) return;
|
|
104
131
|
if (filename !== 'mdboard.json' && filename !== 'mdboard.css') return;
|
|
105
132
|
|
|
@@ -117,6 +144,7 @@ function setupWatchers(opts) {
|
|
|
117
144
|
if (onBroadcast) onBroadcast(eventData);
|
|
118
145
|
}, 200));
|
|
119
146
|
});
|
|
147
|
+
watchers.push(w);
|
|
120
148
|
} catch {
|
|
121
149
|
// Non-critical
|
|
122
150
|
}
|
|
@@ -124,8 +152,11 @@ function setupWatchers(opts) {
|
|
|
124
152
|
|
|
125
153
|
// Watch workspace.json
|
|
126
154
|
if (workspacePath && onWorkspaceChange) {
|
|
127
|
-
watchFile(workspacePath, onWorkspaceChange);
|
|
155
|
+
const w = watchFile(workspacePath, onWorkspaceChange);
|
|
156
|
+
if (w) watchers.push(w);
|
|
128
157
|
}
|
|
158
|
+
|
|
159
|
+
return watchers;
|
|
129
160
|
}
|
|
130
161
|
|
|
131
|
-
module.exports = { setupWatchers, watchDir, watchFile };
|
|
162
|
+
module.exports = { setupWatchers, closeWatchers, watchDir, watchFile };
|