mdboard 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin.js +83 -10
- package/build.js +44 -0
- package/index.html +2344 -134
- package/package.json +8 -6
- 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/src/core/scanner.js +611 -0
- package/src/core/workspace.js +220 -0
- package/src/core/yaml.js +133 -0
- package/src/server/api.js +893 -0
- package/src/server/server.js +511 -0
- package/src/server/watcher.js +162 -0
- package/init.js +0 -100
- package/server.js +0 -830
|
@@ -0,0 +1,511 @@
|
|
|
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
|
+
* Supports multi-repo workspaces via workspace.json.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* mdboard --project /path/to/workspace --port 3333
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const http = require('http');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const os = require('os');
|
|
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');
|
|
23
|
+
const { handleApi } = require('./api');
|
|
24
|
+
const { loadWorkspace, syncAllRemotes, getGitAuthor } = require('../core/workspace');
|
|
25
|
+
const { registerProject, getHistory, isValidProject } = require('../core/history');
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// CLI argument parsing
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
const args = process.argv.slice(2);
|
|
31
|
+
let projectDir = process.cwd();
|
|
32
|
+
let port = 3333;
|
|
33
|
+
let workspacePath = process.env.MDBOARD_WORKSPACE || null;
|
|
34
|
+
|
|
35
|
+
for (let i = 0; i < args.length; i++) {
|
|
36
|
+
switch (args[i]) {
|
|
37
|
+
case '--project':
|
|
38
|
+
projectDir = path.resolve(args[++i] || '.');
|
|
39
|
+
break;
|
|
40
|
+
case '--port':
|
|
41
|
+
port = parseInt(args[++i], 10) || 3333;
|
|
42
|
+
break;
|
|
43
|
+
case '--config':
|
|
44
|
+
i++;
|
|
45
|
+
break;
|
|
46
|
+
case '--workspace':
|
|
47
|
+
workspacePath = path.resolve(args[++i] || '.');
|
|
48
|
+
break;
|
|
49
|
+
case 'init':
|
|
50
|
+
case '-h': case '--help':
|
|
51
|
+
case '-v': case '--version':
|
|
52
|
+
case 'help': case 'cache':
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let projectPath = path.join(projectDir, 'project');
|
|
58
|
+
const boardDir = path.resolve(__dirname, '..', '..');
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Load configuration
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
let config = loadConfig(projectDir, process.env.MDBOARD_CONFIG);
|
|
64
|
+
|
|
65
|
+
function reloadConfig() {
|
|
66
|
+
config = loadConfig(projectDir, process.env.MDBOARD_CONFIG);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Workspace
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
let workspace = loadWorkspace(projectDir, workspacePath);
|
|
73
|
+
let sources = workspace ? workspace.sources : [];
|
|
74
|
+
let syncInterval = null;
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// SSE clients
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
const sseClients = new Set();
|
|
80
|
+
|
|
81
|
+
function broadcast(data) {
|
|
82
|
+
const payload = `data: ${JSON.stringify(data)}\n\n`;
|
|
83
|
+
for (const res of sseClients) {
|
|
84
|
+
try { res.write(payload); } catch { sseClients.delete(res); }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Model & scanning — sourceStates tracks per-source state
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
let model = createModel();
|
|
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
|
+
}
|
|
99
|
+
|
|
100
|
+
function loadSourceConfig(source) {
|
|
101
|
+
// Try to load per-source mdboard.json
|
|
102
|
+
if (!source._repoRoot) return config;
|
|
103
|
+
const candidates = [
|
|
104
|
+
path.join(source._resolvedPath, 'mdboard.json'),
|
|
105
|
+
path.join(source._repoRoot, 'mdboard.json'),
|
|
106
|
+
];
|
|
107
|
+
for (const p of candidates) {
|
|
108
|
+
try {
|
|
109
|
+
const raw = fs.readFileSync(p, 'utf-8');
|
|
110
|
+
const srcCfg = JSON.parse(raw);
|
|
111
|
+
return deepMerge(config, srcCfg);
|
|
112
|
+
} catch { continue; }
|
|
113
|
+
}
|
|
114
|
+
return config;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function scanAll() {
|
|
118
|
+
model = createModel();
|
|
119
|
+
sourceStates.clear();
|
|
120
|
+
|
|
121
|
+
if (workspace && sources.length > 0) {
|
|
122
|
+
// Multi-source mode
|
|
123
|
+
const results = [];
|
|
124
|
+
for (const source of sources) {
|
|
125
|
+
const sourcePath = source._resolvedPath;
|
|
126
|
+
if (!sourcePath) continue;
|
|
127
|
+
|
|
128
|
+
const readonly = source.readonly != null ? source.readonly : (source.type === 'remote');
|
|
129
|
+
const meta = {
|
|
130
|
+
name: source.name,
|
|
131
|
+
label: source.label || source.name,
|
|
132
|
+
color: source.color || null,
|
|
133
|
+
type: source.type,
|
|
134
|
+
readonly,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Load per-source config
|
|
138
|
+
const srcConfig = loadSourceConfig(source);
|
|
139
|
+
const result = scanSource(sourcePath, srcConfig, meta);
|
|
140
|
+
|
|
141
|
+
// Add author info if showAuthor is enabled
|
|
142
|
+
if (workspace.settings && workspace.settings.showAuthor) {
|
|
143
|
+
const repoRoot = source._repoRoot;
|
|
144
|
+
if (repoRoot) {
|
|
145
|
+
for (const task of result.tasks) {
|
|
146
|
+
task._author = getGitAuthor(repoRoot, task._file);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Store per-source state
|
|
152
|
+
sourceStates.set(source.name, {
|
|
153
|
+
model: result,
|
|
154
|
+
config: srcConfig,
|
|
155
|
+
sourcePath,
|
|
156
|
+
repoRoot: source._repoRoot,
|
|
157
|
+
source,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
results.push(result);
|
|
161
|
+
}
|
|
162
|
+
mergeResults(model, results);
|
|
163
|
+
} else {
|
|
164
|
+
// Legacy single-source mode
|
|
165
|
+
const result = scanSource(projectPath, config, {});
|
|
166
|
+
mergeResults(model, [result]);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
computeProgress(model, config.completedStatus);
|
|
170
|
+
computeAgentProps(model);
|
|
171
|
+
refreshAiSuggestions();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// File update helper — resolves correct projectPath per source
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
function updateFile(sourceName, relFile, updates) {
|
|
178
|
+
if (sourceName && sources.length > 0) {
|
|
179
|
+
const source = sources.find(s => s.name === sourceName);
|
|
180
|
+
if (source && source._resolvedPath) {
|
|
181
|
+
return updateMarkdownFile(source._resolvedPath, relFile, updates);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return updateMarkdownFile(projectPath, relFile, updates);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Remote sync
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
async function doSyncRemotes() {
|
|
191
|
+
if (!workspace || sources.length === 0) return;
|
|
192
|
+
|
|
193
|
+
const remoteSources = sources.filter(s => s.type === 'remote');
|
|
194
|
+
if (remoteSources.length === 0) return;
|
|
195
|
+
|
|
196
|
+
await syncAllRemotes(remoteSources);
|
|
197
|
+
scanAll();
|
|
198
|
+
broadcast({ type: 'update', timestamp: new Date().toISOString() });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function startSyncInterval() {
|
|
202
|
+
if (syncInterval) clearInterval(syncInterval);
|
|
203
|
+
if (!workspace) return;
|
|
204
|
+
|
|
205
|
+
const refreshSeconds = (workspace.settings && workspace.settings.refresh) || 300;
|
|
206
|
+
const remoteSources = sources.filter(s => s.type === 'remote');
|
|
207
|
+
if (remoteSources.length === 0) return;
|
|
208
|
+
|
|
209
|
+
syncInterval = setInterval(() => {
|
|
210
|
+
doSyncRemotes().catch(err => {
|
|
211
|
+
console.warn(' Warning: remote sync failed:', err.message);
|
|
212
|
+
});
|
|
213
|
+
}, refreshSeconds * 1000);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// Static file serving
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
const MIME_TYPES = {
|
|
220
|
+
'.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
|
|
221
|
+
'.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml',
|
|
222
|
+
'.ico': 'image/x-icon', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
223
|
+
'.gif': 'image/gif', '.webp': 'image/webp',
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
function findCustomCss() {
|
|
227
|
+
const candidates = [
|
|
228
|
+
path.join(projectPath, 'mdboard.css'),
|
|
229
|
+
path.join(projectDir, 'mdboard.css'),
|
|
230
|
+
path.join(os.homedir(), '.config', 'mdboard', 'mdboard.css'),
|
|
231
|
+
];
|
|
232
|
+
for (const p of candidates) {
|
|
233
|
+
if (fs.existsSync(p)) return p;
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function serveStatic(req, res) {
|
|
239
|
+
const url = new URL(req.url, `http://localhost:${port}`);
|
|
240
|
+
|
|
241
|
+
if (url.pathname === '/mdboard.css') {
|
|
242
|
+
const cssPath = findCustomCss();
|
|
243
|
+
if (cssPath) {
|
|
244
|
+
try {
|
|
245
|
+
const content = fs.readFileSync(cssPath);
|
|
246
|
+
res.writeHead(200, { 'Content-Type': 'text/css' });
|
|
247
|
+
res.end(content);
|
|
248
|
+
return;
|
|
249
|
+
} catch { /* Fall through */ }
|
|
250
|
+
}
|
|
251
|
+
res.writeHead(204);
|
|
252
|
+
res.end();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (url.pathname === '/logo') {
|
|
257
|
+
if (config.logo && !config.logo.startsWith('http')) {
|
|
258
|
+
const logoPath = path.resolve(projectDir, config.logo);
|
|
259
|
+
if (logoPath.startsWith(projectDir) && fs.existsSync(logoPath)) {
|
|
260
|
+
try {
|
|
261
|
+
const logoContent = fs.readFileSync(logoPath);
|
|
262
|
+
const logoExt = path.extname(logoPath);
|
|
263
|
+
const logoMime = MIME_TYPES[logoExt] || 'application/octet-stream';
|
|
264
|
+
res.writeHead(200, { 'Content-Type': logoMime });
|
|
265
|
+
res.end(logoContent);
|
|
266
|
+
return;
|
|
267
|
+
} catch { /* fall through */ }
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
res.writeHead(404);
|
|
271
|
+
res.end('Not found');
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
let filePath = path.join(boardDir, 'index.html');
|
|
276
|
+
if (!filePath.startsWith(boardDir)) {
|
|
277
|
+
res.writeHead(403);
|
|
278
|
+
res.end('Forbidden');
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const ext = path.extname(filePath);
|
|
283
|
+
const contentType = MIME_TYPES[ext] || 'text/plain';
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
const content = fs.readFileSync(filePath);
|
|
287
|
+
res.writeHead(200, { 'Content-Type': contentType });
|
|
288
|
+
res.end(content);
|
|
289
|
+
} catch {
|
|
290
|
+
res.writeHead(404);
|
|
291
|
+
res.end('Not found');
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// HTTP server
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// CRUD helpers — resolve source path and delegate to scanner
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
function createItemFn(sourceName, collection, data) {
|
|
302
|
+
let sourcePath = projectPath;
|
|
303
|
+
let cfg = config;
|
|
304
|
+
|
|
305
|
+
if (sourceName && sources.length > 0) {
|
|
306
|
+
const source = sources.find(s => s.name === sourceName);
|
|
307
|
+
if (source && source._resolvedPath) {
|
|
308
|
+
sourcePath = source._resolvedPath;
|
|
309
|
+
const state = sourceStates.get(sourceName);
|
|
310
|
+
if (state) cfg = state.config;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Use ALL items across sources to compute next ID — prevents cross-source collisions
|
|
315
|
+
switch (collection) {
|
|
316
|
+
case 'tasks':
|
|
317
|
+
return createTask(sourcePath, cfg, data, model.tasks);
|
|
318
|
+
case 'milestones':
|
|
319
|
+
return createMilestone(sourcePath, cfg, data, model.milestones);
|
|
320
|
+
case 'epics':
|
|
321
|
+
return createEpic(sourcePath, cfg, data, model.epics);
|
|
322
|
+
case 'sprints':
|
|
323
|
+
return createSprint(sourcePath, cfg, data, model.sprints);
|
|
324
|
+
case 'notes':
|
|
325
|
+
return createNote(sourcePath, data, model.notes);
|
|
326
|
+
default:
|
|
327
|
+
throw new Error('Unknown collection: ' + collection);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function archiveItemFn(sourceName, item) {
|
|
332
|
+
let sourcePath = projectPath;
|
|
333
|
+
|
|
334
|
+
if (sourceName && sources.length > 0) {
|
|
335
|
+
const source = sources.find(s => s.name === sourceName);
|
|
336
|
+
if (source && source._resolvedPath) {
|
|
337
|
+
sourcePath = source._resolvedPath;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return archiveItem(sourcePath, item);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const apiCtx = {
|
|
345
|
+
get model() { return model; },
|
|
346
|
+
get config() { return config; },
|
|
347
|
+
port,
|
|
348
|
+
get projectDir() { return projectDir; },
|
|
349
|
+
get sources() { return sources; },
|
|
350
|
+
get sourceStates() { return sourceStates; },
|
|
351
|
+
sseClients,
|
|
352
|
+
broadcast,
|
|
353
|
+
updateFile,
|
|
354
|
+
rescanAll: () => scanAll(),
|
|
355
|
+
syncRemotes: doSyncRemotes,
|
|
356
|
+
createItem: createItemFn,
|
|
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),
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const server = http.createServer(async (req, res) => {
|
|
368
|
+
try {
|
|
369
|
+
if (req.url.startsWith('/api/')) {
|
|
370
|
+
await handleApi(req, res, apiCtx);
|
|
371
|
+
} else {
|
|
372
|
+
serveStatic(req, res);
|
|
373
|
+
}
|
|
374
|
+
} catch (err) {
|
|
375
|
+
if (!res.headersSent) {
|
|
376
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
377
|
+
res.end(JSON.stringify({ error: 'Internal server error' }));
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// Startup
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
if (!workspace && !fs.existsSync(projectPath)) {
|
|
386
|
+
console.warn(`\n Warning: project/ directory not found at ${projectPath}`);
|
|
387
|
+
console.warn(' Run `mdboard init` to scaffold a new project.\n');
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
scanAll();
|
|
391
|
+
|
|
392
|
+
// Setup watchers
|
|
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
|
+
}
|
|
401
|
+
}
|
|
402
|
+
} else if (fs.existsSync(projectPath)) {
|
|
403
|
+
pDirs.push(projectPath);
|
|
404
|
+
}
|
|
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
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
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
|
+
}
|
|
471
|
+
|
|
472
|
+
// Initial async sync + start periodic sync
|
|
473
|
+
doSyncRemotes().catch(() => {});
|
|
474
|
+
startSyncInterval();
|
|
475
|
+
|
|
476
|
+
server.listen(port, () => {
|
|
477
|
+
const sourceInfo = workspace && sources.length > 0
|
|
478
|
+
? `\n Sources: ${sources.map(s => s.label || s.name).join(', ')}`
|
|
479
|
+
: '';
|
|
480
|
+
const watchCount = activeWatchers.length;
|
|
481
|
+
console.log(`
|
|
482
|
+
mdboard — Project Dashboard
|
|
483
|
+
Project: ${projectDir}
|
|
484
|
+
Server: http://localhost:${port}${config._path ? '\n Config: ' + config._path : ''}${sourceInfo}
|
|
485
|
+
${watchCount > 0 ? '\n Watching ' + watchCount + ' director' + (watchCount === 1 ? 'y' : 'ies') + ' for changes...' : ''}
|
|
486
|
+
`);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
server.on('error', (err) => {
|
|
490
|
+
if (err.code === 'EADDRINUSE') {
|
|
491
|
+
console.error(`\n Error: Port ${port} is already in use. Use --port <number> to pick a different port.\n`);
|
|
492
|
+
process.exit(1);
|
|
493
|
+
}
|
|
494
|
+
throw err;
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
process.on('SIGINT', () => {
|
|
498
|
+
console.log('\n Shutting down...');
|
|
499
|
+
if (syncInterval) clearInterval(syncInterval);
|
|
500
|
+
closeWatchers(activeWatchers);
|
|
501
|
+
for (const client of sseClients) {
|
|
502
|
+
try { client.end(); } catch { /* ignore */ }
|
|
503
|
+
}
|
|
504
|
+
server.close(() => process.exit(0));
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
process.on('SIGTERM', () => {
|
|
508
|
+
if (syncInterval) clearInterval(syncInterval);
|
|
509
|
+
closeWatchers(activeWatchers);
|
|
510
|
+
server.close(() => process.exit(0));
|
|
511
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mdboard — File watcher
|
|
3
|
+
*
|
|
4
|
+
* Watches project directories for changes to .md, config, and CSS files.
|
|
5
|
+
* Supports watching multiple directories for multi-source workspaces.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
|
|
10
|
+
const watchTimers = new Map();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Setup recursive watcher on a directory for .md, mdboard.json, and mdboard.css changes.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} dir - Directory to watch recursively
|
|
16
|
+
* @param {object} callbacks - { onChange(filename), onConfigChange(), onCssChange(filename) }
|
|
17
|
+
* @returns {fs.FSWatcher|null}
|
|
18
|
+
*/
|
|
19
|
+
function watchDir(dir, callbacks) {
|
|
20
|
+
if (!fs.existsSync(dir)) return null;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const watcher = fs.watch(dir, { recursive: true }, (eventType, filename) => {
|
|
24
|
+
if (!filename) return;
|
|
25
|
+
|
|
26
|
+
const isMd = filename.endsWith('.md');
|
|
27
|
+
const isConfig = filename === 'mdboard.json' || filename.endsWith('/mdboard.json');
|
|
28
|
+
const isCss = filename === 'mdboard.css' || filename.endsWith('/mdboard.css');
|
|
29
|
+
|
|
30
|
+
if (!isMd && !isConfig && !isCss) return;
|
|
31
|
+
|
|
32
|
+
const key = dir + ':' + filename;
|
|
33
|
+
if (watchTimers.has(key)) clearTimeout(watchTimers.get(key));
|
|
34
|
+
|
|
35
|
+
watchTimers.set(key, setTimeout(() => {
|
|
36
|
+
watchTimers.delete(key);
|
|
37
|
+
if (isConfig && callbacks.onConfigChange) callbacks.onConfigChange();
|
|
38
|
+
if (callbacks.onChange) callbacks.onChange(filename, isCss);
|
|
39
|
+
}, 200));
|
|
40
|
+
});
|
|
41
|
+
return watcher;
|
|
42
|
+
} catch {
|
|
43
|
+
console.warn(' Warning: file watching unavailable for ' + dir);
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Watch a single file for changes (e.g., workspace.json).
|
|
50
|
+
*
|
|
51
|
+
* @param {string} filePath - Absolute path to file
|
|
52
|
+
* @param {function} onChange - Callback on change
|
|
53
|
+
* @returns {fs.FSWatcher|null}
|
|
54
|
+
*/
|
|
55
|
+
function watchFile(filePath, onChange) {
|
|
56
|
+
if (!fs.existsSync(filePath)) return null;
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const watcher = fs.watch(filePath, (eventType) => {
|
|
60
|
+
const key = 'file:' + filePath;
|
|
61
|
+
if (watchTimers.has(key)) clearTimeout(watchTimers.get(key));
|
|
62
|
+
|
|
63
|
+
watchTimers.set(key, setTimeout(() => {
|
|
64
|
+
watchTimers.delete(key);
|
|
65
|
+
if (onChange) onChange();
|
|
66
|
+
}, 200));
|
|
67
|
+
});
|
|
68
|
+
return watcher;
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
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
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Setup watchers for project directories and workspace-level config.
|
|
95
|
+
*
|
|
96
|
+
* @param {object} opts
|
|
97
|
+
* @param {string[]} opts.projectDirs - List of project/ directories to watch recursively
|
|
98
|
+
* @param {string[]} opts.rootDirs - List of root directories to watch for mdboard.json/css
|
|
99
|
+
* @param {function} opts.onScan - Called when files change and a rescan is needed
|
|
100
|
+
* @param {function} opts.onConfigReload - Called when mdboard.json changes
|
|
101
|
+
* @param {function} opts.onBroadcast - Called with event data to broadcast via SSE
|
|
102
|
+
* @param {string} [opts.workspacePath] - Path to workspace.json to watch
|
|
103
|
+
* @param {function} [opts.onWorkspaceChange] - Called when workspace.json changes
|
|
104
|
+
* @returns {Array<fs.FSWatcher|null>} - Array of active watchers
|
|
105
|
+
*/
|
|
106
|
+
function setupWatchers(opts) {
|
|
107
|
+
const { projectDirs, rootDirs, onScan, onConfigReload, onBroadcast, workspacePath, onWorkspaceChange } = opts;
|
|
108
|
+
const watchers = [];
|
|
109
|
+
|
|
110
|
+
// Watch each project directory recursively
|
|
111
|
+
for (const dir of projectDirs) {
|
|
112
|
+
const w = watchDir(dir, {
|
|
113
|
+
onConfigChange: () => {
|
|
114
|
+
if (onConfigReload) onConfigReload();
|
|
115
|
+
},
|
|
116
|
+
onChange: (filename, isCss) => {
|
|
117
|
+
if (onScan) onScan();
|
|
118
|
+
const eventData = { type: 'update', file: filename, timestamp: new Date().toISOString() };
|
|
119
|
+
if (isCss) eventData.cssReload = true;
|
|
120
|
+
if (onBroadcast) onBroadcast(eventData);
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
if (w) watchers.push(w);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Watch root directories for workspace-level mdboard.json and mdboard.css
|
|
127
|
+
for (const dir of rootDirs) {
|
|
128
|
+
try {
|
|
129
|
+
const w = fs.watch(dir, (eventType, filename) => {
|
|
130
|
+
if (!filename) return;
|
|
131
|
+
if (filename !== 'mdboard.json' && filename !== 'mdboard.css') return;
|
|
132
|
+
|
|
133
|
+
const key = 'root:' + dir + ':' + filename;
|
|
134
|
+
if (watchTimers.has(key)) clearTimeout(watchTimers.get(key));
|
|
135
|
+
|
|
136
|
+
watchTimers.set(key, setTimeout(() => {
|
|
137
|
+
watchTimers.delete(key);
|
|
138
|
+
if (filename === 'mdboard.json') {
|
|
139
|
+
if (onConfigReload) onConfigReload();
|
|
140
|
+
if (onScan) onScan();
|
|
141
|
+
}
|
|
142
|
+
const eventData = { type: 'update', file: filename, timestamp: new Date().toISOString() };
|
|
143
|
+
if (filename === 'mdboard.css') eventData.cssReload = true;
|
|
144
|
+
if (onBroadcast) onBroadcast(eventData);
|
|
145
|
+
}, 200));
|
|
146
|
+
});
|
|
147
|
+
watchers.push(w);
|
|
148
|
+
} catch {
|
|
149
|
+
// Non-critical
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Watch workspace.json
|
|
154
|
+
if (workspacePath && onWorkspaceChange) {
|
|
155
|
+
const w = watchFile(workspacePath, onWorkspaceChange);
|
|
156
|
+
if (w) watchers.push(w);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return watchers;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = { setupWatchers, closeWatchers, watchDir, watchFile };
|