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.
@@ -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
- metrics: model.metrics && model.metrics._source === sourceName ? model.metrics : null,
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
- return jsonResponse(res, model.metrics || {});
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
- return jsonResponse(res, sourceModel.project || {});
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
- return jsonResponse(res, sourceModel.metrics || {});
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('./config');
19
- const { createModel, scanSource, computeProgress, updateMarkdownFile, mergeResults,
20
- createTask, createMilestone, createEpic, createSprint, archiveItem } = require('./scanner');
21
- const { setupWatchers } = require('./watcher');
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('./workspace');
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
- const projectPath = path.join(projectDir, 'project');
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
- const projectDirs = [];
375
- const rootDirs = [projectDir];
376
-
377
- if (workspace && sources.length > 0) {
378
- for (const s of sources) {
379
- if (s._resolvedPath && fs.existsSync(s._resolvedPath)) {
380
- projectDirs.push(s._resolvedPath);
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
- } else if (fs.existsSync(projectPath)) {
384
- projectDirs.push(projectPath);
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
- projectDirs,
389
- rootDirs,
390
- onScan: () => scanAll(),
391
- onConfigReload: () => reloadConfig(),
392
- onBroadcast: (data) => broadcast(data),
393
- workspacePath: workspace ? workspace._path : null,
394
- onWorkspaceChange: () => {
395
- workspace = loadWorkspace(projectDir, workspacePath);
396
- sources = workspace ? workspace.sources : [];
397
- scanAll();
398
- broadcast({ type: 'update', timestamp: new Date().toISOString() });
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
- ${projectDirs.length > 0 ? '\n Watching ' + projectDirs.length + ' director' + (projectDirs.length === 1 ? 'y' : 'ies') + ' for changes...' : ''}
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
- // Non-critical
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 };