mdboard 1.2.0 → 2.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/bin.js +130 -44
- package/index.html +3321 -1195
- package/package.json +10 -11
- package/presets/kanban/api.json +91 -0
- package/presets/kanban/cli.json +69 -0
- package/presets/kanban/docs.json +29 -0
- package/presets/kanban/entities.json +128 -0
- package/presets/kanban/structure.json +15 -0
- package/presets/kanban/ui.json +86 -0
- package/presets/scrum/api.json +98 -0
- package/presets/scrum/cli.json +120 -0
- package/presets/scrum/docs.json +43 -0
- package/presets/scrum/entities.json +268 -0
- package/presets/scrum/structure.json +32 -0
- package/presets/scrum/ui.json +201 -0
- package/presets/shape-up/api.json +40 -0
- package/presets/shape-up/cli.json +44 -0
- package/presets/shape-up/docs.json +32 -0
- package/presets/shape-up/entities.json +140 -0
- package/presets/shape-up/structure.json +28 -0
- package/presets/shape-up/ui.json +114 -0
- package/src/cli/cli.js +338 -0
- package/src/cli/config.js +234 -0
- package/src/cli/init.js +175 -0
- package/src/cli/preset.js +849 -0
- package/src/cli/skill.js +417 -0
- package/src/cli/status.js +180 -0
- package/src/core/config.js +551 -0
- package/src/core/history.js +146 -0
- package/src/core/scanner.js +521 -0
- package/{workspace.js → src/core/workspace.js} +0 -15
- package/{yaml.js → src/core/yaml.js} +5 -1
- package/src/server/api.js +616 -0
- package/{server.js → src/server/server.js} +180 -132
- package/{watcher.js → src/server/watcher.js} +40 -9
- package/api.js +0 -752
- package/config.js +0 -73
- package/defaults.json +0 -43
- package/init.js +0 -109
- package/scanner.js +0 -491
|
@@ -2,25 +2,25 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* mdboard — Project Dashboard Server
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* Supports multi-repo workspaces via workspace.json.
|
|
5
|
+
* Config-driven server that reads markdown project management files
|
|
6
|
+
* and serves a JSON API. No hardcoded entity names.
|
|
9
7
|
*
|
|
10
8
|
* Usage:
|
|
11
9
|
* mdboard --project /path/to/workspace --port 3333
|
|
12
10
|
*/
|
|
13
11
|
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
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,
|
|
20
|
-
|
|
21
|
-
const { setupWatchers } = require('./watcher');
|
|
18
|
+
const { loadConfig, deepMerge } = require('../core/config');
|
|
19
|
+
const { createModel, scanSource, computeProgress, updateEntity, createEntity, deleteEntity, mergeResults } = require('../core/scanner');
|
|
20
|
+
const { setupWatchers, closeWatchers } = require('./watcher');
|
|
22
21
|
const { handleApi } = require('./api');
|
|
23
|
-
const { loadWorkspace, syncAllRemotes, getGitAuthor } = require('
|
|
22
|
+
const { loadWorkspace, syncAllRemotes, getGitAuthor } = require('../core/workspace');
|
|
23
|
+
const { registerProject, getHistory, isValidProject } = require('../core/history');
|
|
24
24
|
|
|
25
25
|
// ---------------------------------------------------------------------------
|
|
26
26
|
// CLI argument parsing
|
|
@@ -52,11 +52,11 @@ for (let i = 0; i < args.length; i++) {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
|
|
56
|
-
const boardDir = __dirname;
|
|
55
|
+
let projectPath = path.join(projectDir, 'project');
|
|
56
|
+
const boardDir = path.resolve(__dirname, '..', '..');
|
|
57
57
|
|
|
58
58
|
// ---------------------------------------------------------------------------
|
|
59
|
-
// Load configuration
|
|
59
|
+
// Load configuration (new config engine)
|
|
60
60
|
// ---------------------------------------------------------------------------
|
|
61
61
|
let config = loadConfig(projectDir, process.env.MDBOARD_CONFIG);
|
|
62
62
|
|
|
@@ -86,11 +86,10 @@ function broadcast(data) {
|
|
|
86
86
|
// ---------------------------------------------------------------------------
|
|
87
87
|
// Model & scanning — sourceStates tracks per-source state
|
|
88
88
|
// ---------------------------------------------------------------------------
|
|
89
|
-
let model = createModel();
|
|
89
|
+
let model = createModel(config);
|
|
90
90
|
const sourceStates = new Map();
|
|
91
91
|
|
|
92
92
|
function loadSourceConfig(source) {
|
|
93
|
-
// Try to load per-source mdboard.json
|
|
94
93
|
if (!source._repoRoot) return config;
|
|
95
94
|
const candidates = [
|
|
96
95
|
path.join(source._resolvedPath, 'mdboard.json'),
|
|
@@ -107,11 +106,10 @@ function loadSourceConfig(source) {
|
|
|
107
106
|
}
|
|
108
107
|
|
|
109
108
|
function scanAll() {
|
|
110
|
-
model = createModel();
|
|
109
|
+
model = createModel(config);
|
|
111
110
|
sourceStates.clear();
|
|
112
111
|
|
|
113
112
|
if (workspace && sources.length > 0) {
|
|
114
|
-
// Multi-source mode
|
|
115
113
|
const results = [];
|
|
116
114
|
for (const source of sources) {
|
|
117
115
|
const sourcePath = source._resolvedPath;
|
|
@@ -126,21 +124,21 @@ function scanAll() {
|
|
|
126
124
|
readonly,
|
|
127
125
|
};
|
|
128
126
|
|
|
129
|
-
// Load per-source config
|
|
130
127
|
const srcConfig = loadSourceConfig(source);
|
|
131
128
|
const result = scanSource(sourcePath, srcConfig, meta);
|
|
132
129
|
|
|
133
|
-
// Add author info if showAuthor is enabled
|
|
134
130
|
if (workspace.settings && workspace.settings.showAuthor) {
|
|
135
131
|
const repoRoot = source._repoRoot;
|
|
136
132
|
if (repoRoot) {
|
|
137
|
-
|
|
138
|
-
|
|
133
|
+
const leafType = getLeafType(srcConfig);
|
|
134
|
+
if (leafType && result.entities[leafType]) {
|
|
135
|
+
for (const item of result.entities[leafType]) {
|
|
136
|
+
item._author = getGitAuthor(repoRoot, item._file);
|
|
137
|
+
}
|
|
139
138
|
}
|
|
140
139
|
}
|
|
141
140
|
}
|
|
142
141
|
|
|
143
|
-
// Store per-source state
|
|
144
142
|
sourceStates.set(source.name, {
|
|
145
143
|
model: result,
|
|
146
144
|
config: srcConfig,
|
|
@@ -153,25 +151,59 @@ function scanAll() {
|
|
|
153
151
|
}
|
|
154
152
|
mergeResults(model, results);
|
|
155
153
|
} else {
|
|
156
|
-
// Legacy single-source mode
|
|
157
154
|
const result = scanSource(projectPath, config, {});
|
|
158
155
|
mergeResults(model, [result]);
|
|
159
156
|
}
|
|
160
157
|
|
|
161
|
-
computeProgress(model, config
|
|
158
|
+
computeProgress(model, config);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function getLeafType(cfg) {
|
|
162
|
+
const { flattenHierarchy } = require('../core/config');
|
|
163
|
+
const flat = flattenHierarchy(cfg);
|
|
164
|
+
const h = flat.filter(e => !e.standalone);
|
|
165
|
+
return h.length > 0 ? h[h.length - 1].type : null;
|
|
162
166
|
}
|
|
163
167
|
|
|
164
168
|
// ---------------------------------------------------------------------------
|
|
165
|
-
// File update helper
|
|
169
|
+
// File update helper
|
|
166
170
|
// ---------------------------------------------------------------------------
|
|
167
171
|
function updateFile(sourceName, relFile, updates) {
|
|
172
|
+
let sourcePath = projectPath;
|
|
173
|
+
if (sourceName && sources.length > 0) {
|
|
174
|
+
const source = sources.find(s => s.name === sourceName);
|
|
175
|
+
if (source && source._resolvedPath) sourcePath = source._resolvedPath;
|
|
176
|
+
}
|
|
177
|
+
return updateEntity(sourcePath, relFile, updates);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// CRUD helpers — dynamic, uses entityType not collection name
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
function createItemFn(sourceName, entityType, data) {
|
|
184
|
+
let sourcePath = projectPath;
|
|
185
|
+
let cfg = config;
|
|
186
|
+
|
|
168
187
|
if (sourceName && sources.length > 0) {
|
|
169
188
|
const source = sources.find(s => s.name === sourceName);
|
|
170
189
|
if (source && source._resolvedPath) {
|
|
171
|
-
|
|
190
|
+
sourcePath = source._resolvedPath;
|
|
191
|
+
const state = sourceStates.get(sourceName);
|
|
192
|
+
if (state) cfg = state.config;
|
|
172
193
|
}
|
|
173
194
|
}
|
|
174
|
-
|
|
195
|
+
|
|
196
|
+
const existingItems = model.entities[entityType] || [];
|
|
197
|
+
return createEntity(sourcePath, cfg, entityType, data, existingItems);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function deleteItemFn(sourceName, item) {
|
|
201
|
+
let sourcePath = projectPath;
|
|
202
|
+
if (sourceName && sources.length > 0) {
|
|
203
|
+
const source = sources.find(s => s.name === sourceName);
|
|
204
|
+
if (source && source._resolvedPath) sourcePath = source._resolvedPath;
|
|
205
|
+
}
|
|
206
|
+
return deleteEntity(sourcePath, item);
|
|
175
207
|
}
|
|
176
208
|
|
|
177
209
|
// ---------------------------------------------------------------------------
|
|
@@ -179,7 +211,6 @@ function updateFile(sourceName, relFile, updates) {
|
|
|
179
211
|
// ---------------------------------------------------------------------------
|
|
180
212
|
async function doSyncRemotes() {
|
|
181
213
|
if (!workspace || sources.length === 0) return;
|
|
182
|
-
|
|
183
214
|
const remoteSources = sources.filter(s => s.type === 'remote');
|
|
184
215
|
if (remoteSources.length === 0) return;
|
|
185
216
|
|
|
@@ -225,6 +256,15 @@ function findCustomCss() {
|
|
|
225
256
|
return null;
|
|
226
257
|
}
|
|
227
258
|
|
|
259
|
+
// Cache index.html at startup
|
|
260
|
+
let _indexHtmlCache = null;
|
|
261
|
+
function getIndexHtml() {
|
|
262
|
+
if (!_indexHtmlCache) {
|
|
263
|
+
try { _indexHtmlCache = fs.readFileSync(path.join(boardDir, 'index.html')); } catch { /* */ }
|
|
264
|
+
}
|
|
265
|
+
return _indexHtmlCache;
|
|
266
|
+
}
|
|
267
|
+
|
|
228
268
|
function serveStatic(req, res) {
|
|
229
269
|
const url = new URL(req.url, `http://localhost:${port}`);
|
|
230
270
|
|
|
@@ -243,108 +283,57 @@ function serveStatic(req, res) {
|
|
|
243
283
|
return;
|
|
244
284
|
}
|
|
245
285
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
if (logoPath.startsWith(projectDir) && fs.existsSync(logoPath)) {
|
|
250
|
-
try {
|
|
251
|
-
const logoContent = fs.readFileSync(logoPath);
|
|
252
|
-
const logoExt = path.extname(logoPath);
|
|
253
|
-
const logoMime = MIME_TYPES[logoExt] || 'application/octet-stream';
|
|
254
|
-
res.writeHead(200, { 'Content-Type': logoMime });
|
|
255
|
-
res.end(logoContent);
|
|
256
|
-
return;
|
|
257
|
-
} catch { /* fall through */ }
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
res.writeHead(404);
|
|
261
|
-
res.end('Not found');
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
let filePath = path.join(boardDir, 'index.html');
|
|
266
|
-
if (!filePath.startsWith(boardDir)) {
|
|
267
|
-
res.writeHead(403);
|
|
268
|
-
res.end('Forbidden');
|
|
269
|
-
return;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const ext = path.extname(filePath);
|
|
273
|
-
const contentType = MIME_TYPES[ext] || 'text/plain';
|
|
274
|
-
|
|
275
|
-
try {
|
|
276
|
-
const content = fs.readFileSync(filePath);
|
|
277
|
-
res.writeHead(200, { 'Content-Type': contentType });
|
|
286
|
+
const content = getIndexHtml();
|
|
287
|
+
if (content) {
|
|
288
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
278
289
|
res.end(content);
|
|
279
|
-
}
|
|
290
|
+
} else {
|
|
280
291
|
res.writeHead(404);
|
|
281
292
|
res.end('Not found');
|
|
282
293
|
}
|
|
283
294
|
}
|
|
284
295
|
|
|
285
296
|
// ---------------------------------------------------------------------------
|
|
286
|
-
//
|
|
287
|
-
// ---------------------------------------------------------------------------
|
|
288
|
-
// ---------------------------------------------------------------------------
|
|
289
|
-
// CRUD helpers — resolve source path and delegate to scanner
|
|
297
|
+
// API context
|
|
290
298
|
// ---------------------------------------------------------------------------
|
|
291
|
-
function createItemFn(sourceName, collection, data) {
|
|
292
|
-
let sourcePath = projectPath;
|
|
293
|
-
let cfg = config;
|
|
294
|
-
|
|
295
|
-
if (sourceName && sources.length > 0) {
|
|
296
|
-
const source = sources.find(s => s.name === sourceName);
|
|
297
|
-
if (source && source._resolvedPath) {
|
|
298
|
-
sourcePath = source._resolvedPath;
|
|
299
|
-
const state = sourceStates.get(sourceName);
|
|
300
|
-
if (state) cfg = state.config;
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// Use ALL items across sources to compute next ID — prevents cross-source collisions
|
|
305
|
-
switch (collection) {
|
|
306
|
-
case 'tasks':
|
|
307
|
-
return createTask(sourcePath, cfg, data, model.tasks);
|
|
308
|
-
case 'milestones':
|
|
309
|
-
return createMilestone(sourcePath, cfg, data, model.milestones);
|
|
310
|
-
case 'epics':
|
|
311
|
-
return createEpic(sourcePath, cfg, data, model.epics);
|
|
312
|
-
case 'sprints':
|
|
313
|
-
return createSprint(sourcePath, cfg, data, model.sprints);
|
|
314
|
-
default:
|
|
315
|
-
throw new Error('Unknown collection: ' + collection);
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
function archiveItemFn(sourceName, item) {
|
|
320
|
-
let sourcePath = projectPath;
|
|
321
|
-
|
|
322
|
-
if (sourceName && sources.length > 0) {
|
|
323
|
-
const source = sources.find(s => s.name === sourceName);
|
|
324
|
-
if (source && source._resolvedPath) {
|
|
325
|
-
sourcePath = source._resolvedPath;
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
return archiveItem(sourcePath, item);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
299
|
const apiCtx = {
|
|
333
300
|
get model() { return model; },
|
|
334
301
|
get config() { return config; },
|
|
335
302
|
port,
|
|
336
|
-
projectDir,
|
|
303
|
+
get projectDir() { return projectDir; },
|
|
337
304
|
get sources() { return sources; },
|
|
338
305
|
get sourceStates() { return sourceStates; },
|
|
339
306
|
sseClients,
|
|
340
307
|
broadcast,
|
|
341
308
|
updateFile,
|
|
342
309
|
rescanAll: () => scanAll(),
|
|
310
|
+
reloadConfig: () => reloadConfig(),
|
|
343
311
|
syncRemotes: doSyncRemotes,
|
|
344
312
|
createItem: createItemFn,
|
|
345
|
-
|
|
313
|
+
deleteItem: deleteItemFn,
|
|
314
|
+
get hasProject() {
|
|
315
|
+
return fs.existsSync(path.join(projectDir, 'project')) ||
|
|
316
|
+
(workspace && sources.length > 0);
|
|
317
|
+
},
|
|
318
|
+
loadProject: loadProject,
|
|
319
|
+
getProjectHistory: () => getHistory(projectDir),
|
|
320
|
+
get workspacePath() {
|
|
321
|
+
return workspace ? workspace._path : (workspacePath || path.join(projectDir, 'workspace.json'));
|
|
322
|
+
},
|
|
323
|
+
onWorkspaceChange() {
|
|
324
|
+
workspace = loadWorkspace(projectDir, workspacePath);
|
|
325
|
+
sources = workspace ? workspace.sources : [];
|
|
326
|
+
closeWatchers(activeWatchers);
|
|
327
|
+
scanAll();
|
|
328
|
+
activeWatchers = setupWatchers(buildWatcherOpts());
|
|
329
|
+
doSyncRemotes().catch(() => {});
|
|
330
|
+
broadcast({ type: 'update', timestamp: new Date().toISOString() });
|
|
331
|
+
},
|
|
346
332
|
};
|
|
347
333
|
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// HTTP server
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
348
337
|
const server = http.createServer(async (req, res) => {
|
|
349
338
|
try {
|
|
350
339
|
if (req.url.startsWith('/api/')) {
|
|
@@ -371,33 +360,71 @@ if (!workspace && !fs.existsSync(projectPath)) {
|
|
|
371
360
|
scanAll();
|
|
372
361
|
|
|
373
362
|
// Setup watchers
|
|
374
|
-
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
if (workspace && sources.length > 0) {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
363
|
+
function buildWatcherOpts() {
|
|
364
|
+
const pDirs = [];
|
|
365
|
+
const rDirs = [projectDir];
|
|
366
|
+
if (workspace && sources.length > 0) {
|
|
367
|
+
for (const s of sources) {
|
|
368
|
+
if (s._resolvedPath && fs.existsSync(s._resolvedPath)) {
|
|
369
|
+
pDirs.push(s._resolvedPath);
|
|
370
|
+
}
|
|
381
371
|
}
|
|
372
|
+
} else if (fs.existsSync(projectPath)) {
|
|
373
|
+
pDirs.push(projectPath);
|
|
382
374
|
}
|
|
383
|
-
|
|
384
|
-
|
|
375
|
+
return {
|
|
376
|
+
projectDirs: pDirs,
|
|
377
|
+
rootDirs: rDirs,
|
|
378
|
+
onScan: () => scanAll(),
|
|
379
|
+
onConfigReload: () => reloadConfig(),
|
|
380
|
+
onBroadcast: (data) => broadcast(data),
|
|
381
|
+
workspacePath: workspace ? workspace._path : null,
|
|
382
|
+
onWorkspaceChange: () => {
|
|
383
|
+
workspace = loadWorkspace(projectDir, workspacePath);
|
|
384
|
+
sources = workspace ? workspace.sources : [];
|
|
385
|
+
scanAll();
|
|
386
|
+
broadcast({ type: 'update', timestamp: new Date().toISOString() });
|
|
387
|
+
},
|
|
388
|
+
};
|
|
385
389
|
}
|
|
386
390
|
|
|
387
|
-
setupWatchers(
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
391
|
+
let activeWatchers = setupWatchers(buildWatcherOpts());
|
|
392
|
+
|
|
393
|
+
// Register current project in history
|
|
394
|
+
if (isValidProject(projectDir)) {
|
|
395
|
+
const projName = (model.project && model.project.name) || path.basename(projectDir);
|
|
396
|
+
registerProject(projectDir, projName);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Load a different project directory dynamically.
|
|
401
|
+
*/
|
|
402
|
+
function loadProject(newProjectDir) {
|
|
403
|
+
closeWatchers(activeWatchers);
|
|
404
|
+
if (syncInterval) clearInterval(syncInterval);
|
|
405
|
+
syncInterval = null;
|
|
406
|
+
|
|
407
|
+
projectDir = path.resolve(newProjectDir);
|
|
408
|
+
projectPath = path.join(projectDir, 'project');
|
|
409
|
+
workspacePath = null;
|
|
410
|
+
|
|
411
|
+
// Clear startup preset so each project resolves its own
|
|
412
|
+
delete process.env.MDBOARD_PRESET;
|
|
413
|
+
|
|
414
|
+
config = loadConfig(projectDir);
|
|
415
|
+
workspace = loadWorkspace(projectDir, null);
|
|
416
|
+
sources = workspace ? workspace.sources : [];
|
|
417
|
+
|
|
418
|
+
scanAll();
|
|
419
|
+
activeWatchers = setupWatchers(buildWatcherOpts());
|
|
420
|
+
|
|
421
|
+
doSyncRemotes().catch(() => {});
|
|
422
|
+
startSyncInterval();
|
|
423
|
+
|
|
424
|
+
const projName = (model.project && model.project.name) || path.basename(projectDir);
|
|
425
|
+
registerProject(projectDir, projName);
|
|
426
|
+
broadcast({ type: 'project-changed', projectDir });
|
|
427
|
+
}
|
|
401
428
|
|
|
402
429
|
// Initial async sync + start periodic sync
|
|
403
430
|
doSyncRemotes().catch(() => {});
|
|
@@ -407,11 +434,27 @@ server.listen(port, () => {
|
|
|
407
434
|
const sourceInfo = workspace && sources.length > 0
|
|
408
435
|
? `\n Sources: ${sources.map(s => s.label || s.name).join(', ')}`
|
|
409
436
|
: '';
|
|
437
|
+
const watchCount = activeWatchers.length;
|
|
438
|
+
|
|
439
|
+
// Resolve leaf type for tips
|
|
440
|
+
const { flattenHierarchy } = require('../core/config');
|
|
441
|
+
const flat = flattenHierarchy(config);
|
|
442
|
+
const hierarchy = flat.filter(e => !e.standalone);
|
|
443
|
+
const leafType = hierarchy.length > 0 ? hierarchy[hierarchy.length - 1].type : null;
|
|
444
|
+
|
|
410
445
|
console.log(`
|
|
411
446
|
mdboard — Project Dashboard
|
|
412
447
|
Project: ${projectDir}
|
|
413
|
-
Server: http://localhost:${port}${config.
|
|
414
|
-
${
|
|
448
|
+
Server: http://localhost:${port}${config._entitiesPath ? '\n Config: ' + config._entitiesPath : ''}${sourceInfo}
|
|
449
|
+
${watchCount > 0 ? '\n Watching ' + watchCount + ' director' + (watchCount === 1 ? 'y' : 'ies') + ' for changes...' : ''}
|
|
450
|
+
Open in browser:
|
|
451
|
+
http://localhost:${port}
|
|
452
|
+
|
|
453
|
+
Quick start:
|
|
454
|
+
mdboard config Setup preferences & AI skill
|
|
455
|
+
mdboard preset list Switch methodology (scrum, kanban, ...)
|
|
456
|
+
mdboard skill Add AI skill to your IDE${leafType ? '\n mdboard create ' + leafType + ' "title" Create a new ' + leafType : ''}
|
|
457
|
+
mdboard --help Show all commands
|
|
415
458
|
`);
|
|
416
459
|
});
|
|
417
460
|
|
|
@@ -426,6 +469,7 @@ server.on('error', (err) => {
|
|
|
426
469
|
process.on('SIGINT', () => {
|
|
427
470
|
console.log('\n Shutting down...');
|
|
428
471
|
if (syncInterval) clearInterval(syncInterval);
|
|
472
|
+
closeWatchers(activeWatchers);
|
|
429
473
|
for (const client of sseClients) {
|
|
430
474
|
try { client.end(); } catch { /* ignore */ }
|
|
431
475
|
}
|
|
@@ -434,5 +478,9 @@ process.on('SIGINT', () => {
|
|
|
434
478
|
|
|
435
479
|
process.on('SIGTERM', () => {
|
|
436
480
|
if (syncInterval) clearInterval(syncInterval);
|
|
481
|
+
closeWatchers(activeWatchers);
|
|
437
482
|
server.close(() => process.exit(0));
|
|
438
483
|
});
|
|
484
|
+
|
|
485
|
+
// Export for programmatic use in tests
|
|
486
|
+
module.exports = { server, scanAll, apiCtx };
|
|
@@ -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 };
|