bmad-viewer 0.3.3 → 0.3.5
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/.claude/launch.json +11 -0
- package/package.json +2 -2
- package/public/client.js +50 -0
- package/public/styles.css +16 -0
- package/src/data/data-model.js +48 -10
- package/src/server/http-server.js +33 -3
- package/src/server/renderer.js +24 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bmad-viewer",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "Visual dashboard for BMAD (Boring Maintainable Agile Development) projects. Wiki browser + sprint status viewer with live reload.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"url": "https://github.com/CamiloValderramaGonzalez/bmad-viewer/issues"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"bmad-viewer": "^0.3.
|
|
30
|
+
"bmad-viewer": "^0.3.4",
|
|
31
31
|
"chokidar": "^4.0.1",
|
|
32
32
|
"fuse.js": "^7.0.0",
|
|
33
33
|
"js-yaml": "^4.1.0",
|
package/public/client.js
CHANGED
|
@@ -491,7 +491,57 @@
|
|
|
491
491
|
// Sidebar
|
|
492
492
|
initSidebarToggles();
|
|
493
493
|
|
|
494
|
+
// Path config panel
|
|
495
|
+
initPathConfig();
|
|
496
|
+
|
|
494
497
|
// WebSocket
|
|
495
498
|
initWebSocket();
|
|
496
499
|
});
|
|
500
|
+
|
|
501
|
+
/* ── Path Config Panel ── */
|
|
502
|
+
function initPathConfig() {
|
|
503
|
+
var btn = document.getElementById('apply-paths-btn');
|
|
504
|
+
if (!btn) return;
|
|
505
|
+
|
|
506
|
+
btn.addEventListener('click', function () {
|
|
507
|
+
var epicsInput = document.getElementById('custom-epics-path');
|
|
508
|
+
var outputInput = document.getElementById('custom-output-path');
|
|
509
|
+
var status = document.getElementById('path-config-status');
|
|
510
|
+
var payload = {};
|
|
511
|
+
|
|
512
|
+
if (outputInput && outputInput.value.trim()) payload.outputPath = outputInput.value.trim();
|
|
513
|
+
if (epicsInput && epicsInput.value.trim()) payload.epicsPath = epicsInput.value.trim();
|
|
514
|
+
|
|
515
|
+
if (!payload.outputPath && !payload.epicsPath) {
|
|
516
|
+
if (status) { status.textContent = 'Enter at least one path'; status.className = 'path-config-panel__status path-config-panel__status--err'; }
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
btn.disabled = true;
|
|
521
|
+
if (status) { status.textContent = 'Applying...'; status.className = 'path-config-panel__status'; }
|
|
522
|
+
|
|
523
|
+
fetch('/api/set-paths', {
|
|
524
|
+
method: 'POST',
|
|
525
|
+
headers: { 'Content-Type': 'application/json' },
|
|
526
|
+
body: JSON.stringify(payload),
|
|
527
|
+
})
|
|
528
|
+
.then(function (r) { return r.json(); })
|
|
529
|
+
.then(function (data) {
|
|
530
|
+
btn.disabled = false;
|
|
531
|
+
if (data.ok) {
|
|
532
|
+
if (status) {
|
|
533
|
+
status.textContent = 'Found ' + data.epics + ' epics, ' + data.stories + ' stories. Reloading...';
|
|
534
|
+
status.className = 'path-config-panel__status path-config-panel__status--ok';
|
|
535
|
+
}
|
|
536
|
+
setTimeout(function () { location.reload(); }, 800);
|
|
537
|
+
} else {
|
|
538
|
+
if (status) { status.textContent = data.error || 'Error applying paths'; status.className = 'path-config-panel__status path-config-panel__status--err'; }
|
|
539
|
+
}
|
|
540
|
+
})
|
|
541
|
+
.catch(function () {
|
|
542
|
+
btn.disabled = false;
|
|
543
|
+
if (status) { status.textContent = 'Network error'; status.className = 'path-config-panel__status path-config-panel__status--err'; }
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
}
|
|
497
547
|
})();
|
package/public/styles.css
CHANGED
|
@@ -149,6 +149,22 @@ mark.search-highlight{background:rgba(245,158,11,.4);color:var(--text);border-ra
|
|
|
149
149
|
/* Warning Banner */
|
|
150
150
|
.warning-banner{background:rgba(245,158,11,.1);border-bottom:1px solid var(--warning);padding:8px var(--gap)}
|
|
151
151
|
.warning-banner__item{font-size:.85rem;color:var(--text)}
|
|
152
|
+
/* Path config panel */
|
|
153
|
+
.path-config-panel{background:var(--accent-soft);border:1px solid var(--border);border-radius:var(--radius);padding:20px 24px;margin-bottom:20px}
|
|
154
|
+
.path-config-panel__header h3{font-size:1rem;margin-bottom:4px}
|
|
155
|
+
.path-config-panel__header p{font-size:.85rem;color:var(--text-muted);margin-bottom:16px}
|
|
156
|
+
.path-config-panel__fields{display:flex;flex-direction:column;gap:12px}
|
|
157
|
+
.path-config-panel__label{display:flex;flex-direction:column;gap:4px}
|
|
158
|
+
.path-config-panel__label>span:first-child{font-size:.85rem;font-weight:600}
|
|
159
|
+
.path-config-panel__input{font-family:var(--font-mono);font-size:.85rem;padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--bg);color:var(--text);width:100%}
|
|
160
|
+
.path-config-panel__input:focus{outline:none;border-color:var(--primary)}
|
|
161
|
+
.path-config-panel__hint{font-size:.75rem;color:var(--text-muted)}
|
|
162
|
+
.path-config-panel__btn{align-self:flex-start;padding:8px 20px;background:var(--primary);color:#fff;border:none;border-radius:var(--radius);cursor:pointer;font-size:.85rem;font-weight:600;transition:background .15s}
|
|
163
|
+
.path-config-panel__btn:hover{background:var(--primary-hover)}
|
|
164
|
+
.path-config-panel__btn:disabled{opacity:.6;cursor:not-allowed}
|
|
165
|
+
.path-config-panel__status{font-size:.85rem;color:var(--text-muted);align-self:center}
|
|
166
|
+
.path-config-panel__status--ok{color:var(--success)}
|
|
167
|
+
.path-config-panel__status--err{color:var(--warning)}
|
|
152
168
|
/* Responsive */
|
|
153
169
|
@media(max-width:768px){.app-layout{grid-template-columns:1fr}.app-layout__sidebar{max-height:none;position:static;border-right:none;border-bottom:1px solid var(--border)}.kanban{grid-template-columns:1fr}.stats-box{grid-template-columns:repeat(2,1fr)}}
|
|
154
170
|
@media(max-width:440px){.header-bar__shortcut{display:none}.stats-box{grid-template-columns:1fr}.lens-tabs__tab{padding:8px 12px;font-size:.85rem}}
|
package/src/data/data-model.js
CHANGED
|
@@ -8,15 +8,16 @@ import { ErrorAggregator } from '../utils/error-aggregator.js';
|
|
|
8
8
|
* Build the complete in-memory data model from a BMAD project.
|
|
9
9
|
*
|
|
10
10
|
* @param {string} bmadDir - Project root containing _bmad/
|
|
11
|
+
* @param {{customEpicsPath?: string, customOutputPath?: string}} [options] - Optional overrides
|
|
11
12
|
* @returns {{wiki: object, project: object, config: object, aggregator: ErrorAggregator}}
|
|
12
13
|
*/
|
|
13
|
-
export function buildDataModel(bmadDir) {
|
|
14
|
+
export function buildDataModel(bmadDir, options) {
|
|
14
15
|
const aggregator = new ErrorAggregator();
|
|
15
16
|
const bmadPath = join(bmadDir, '_bmad');
|
|
16
|
-
const outputPath = join(bmadDir, '_bmad-output');
|
|
17
|
+
const outputPath = options?.customOutputPath || join(bmadDir, '_bmad-output');
|
|
17
18
|
|
|
18
19
|
const wiki = buildWikiData(bmadPath, aggregator);
|
|
19
|
-
const project = buildProjectData(outputPath, aggregator);
|
|
20
|
+
const project = buildProjectData(outputPath, aggregator, options?.customEpicsPath);
|
|
20
21
|
const config = loadConfig(bmadPath, aggregator);
|
|
21
22
|
|
|
22
23
|
return { wiki, project, config, aggregator };
|
|
@@ -278,8 +279,11 @@ function readMarkdownSafe(filePath, aggregator) {
|
|
|
278
279
|
|
|
279
280
|
/**
|
|
280
281
|
* Build project data from _bmad-output directory.
|
|
282
|
+
* @param {string} outputPath
|
|
283
|
+
* @param {ErrorAggregator} aggregator
|
|
284
|
+
* @param {string} [customEpicsPath] - Optional override path to epics file
|
|
281
285
|
*/
|
|
282
|
-
function buildProjectData(outputPath, aggregator) {
|
|
286
|
+
function buildProjectData(outputPath, aggregator, customEpicsPath) {
|
|
283
287
|
const project = {
|
|
284
288
|
sprintStatus: null,
|
|
285
289
|
stories: { total: 0, pending: 0, inProgress: 0, done: 0 },
|
|
@@ -288,7 +292,10 @@ function buildProjectData(outputPath, aggregator) {
|
|
|
288
292
|
artifacts: [],
|
|
289
293
|
};
|
|
290
294
|
|
|
291
|
-
if (!existsSync(outputPath))
|
|
295
|
+
if (!existsSync(outputPath)) {
|
|
296
|
+
loadCustomEpicsFile(customEpicsPath, project, aggregator);
|
|
297
|
+
return project;
|
|
298
|
+
}
|
|
292
299
|
|
|
293
300
|
// Parse sprint-status.yaml
|
|
294
301
|
const sprintStatusPaths = [
|
|
@@ -369,8 +376,8 @@ function buildProjectData(outputPath, aggregator) {
|
|
|
369
376
|
...content,
|
|
370
377
|
});
|
|
371
378
|
|
|
372
|
-
// Parse stories and epics from epics.md
|
|
373
|
-
if (name
|
|
379
|
+
// Parse stories and epics from epics.md or epics-and-stories.md
|
|
380
|
+
if (name.startsWith('epics') && ext === '.md' && content.raw) {
|
|
374
381
|
const storyContents = parseStoriesFromEpics(content.raw, aggregator);
|
|
375
382
|
project.storyContents = storyContents;
|
|
376
383
|
|
|
@@ -388,6 +395,11 @@ function buildProjectData(outputPath, aggregator) {
|
|
|
388
395
|
}
|
|
389
396
|
}
|
|
390
397
|
|
|
398
|
+
// Load custom epics file if provided and no epics were found from normal scan
|
|
399
|
+
if (project.epics.length === 0) {
|
|
400
|
+
loadCustomEpicsFile(customEpicsPath, project, aggregator);
|
|
401
|
+
}
|
|
402
|
+
|
|
391
403
|
// Scan implementation artifact story files (direct .md files in impl dir + stories/ subdir)
|
|
392
404
|
const implDir = join(outputPath, 'implementation-artifacts');
|
|
393
405
|
if (existsSync(implDir)) {
|
|
@@ -502,6 +514,32 @@ function buildProjectData(outputPath, aggregator) {
|
|
|
502
514
|
return project;
|
|
503
515
|
}
|
|
504
516
|
|
|
517
|
+
/**
|
|
518
|
+
* Load a custom epics file and populate project data from it.
|
|
519
|
+
*/
|
|
520
|
+
function loadCustomEpicsFile(customEpicsPath, project, aggregator) {
|
|
521
|
+
if (!customEpicsPath || !existsSync(customEpicsPath)) return;
|
|
522
|
+
const content = readMarkdownSafe(customEpicsPath, aggregator);
|
|
523
|
+
if (!content.raw) return;
|
|
524
|
+
|
|
525
|
+
const name = basename(customEpicsPath, extname(customEpicsPath));
|
|
526
|
+
project.artifacts.push({
|
|
527
|
+
id: `artifact/${name}`,
|
|
528
|
+
name: formatName(name),
|
|
529
|
+
path: customEpicsPath,
|
|
530
|
+
type: 'planning',
|
|
531
|
+
...content,
|
|
532
|
+
});
|
|
533
|
+
const storyContents = parseStoriesFromEpics(content.raw, aggregator);
|
|
534
|
+
project.storyContents = storyContents;
|
|
535
|
+
project.epics = parseEpicsFromMarkdown(content.raw, storyContents);
|
|
536
|
+
for (const epic of project.epics) {
|
|
537
|
+
project.stories.total += epic.stories.length;
|
|
538
|
+
project.stories.pending += epic.stories.length;
|
|
539
|
+
project.storyList.push(...epic.stories);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
505
543
|
/**
|
|
506
544
|
* Parse individual story sections from epics.md.
|
|
507
545
|
* Stories follow the pattern: ### Story X.Y: Title
|
|
@@ -509,8 +547,8 @@ function buildProjectData(outputPath, aggregator) {
|
|
|
509
547
|
*/
|
|
510
548
|
function parseStoriesFromEpics(raw, aggregator) {
|
|
511
549
|
const storyMap = {};
|
|
512
|
-
// Split on story headers
|
|
513
|
-
const storyRegex =
|
|
550
|
+
// Split on story headers (supports ### or #### level)
|
|
551
|
+
const storyRegex = /^#{3,4} Story (\d+)\.(\d+):\s*(.+)$/gm;
|
|
514
552
|
let match;
|
|
515
553
|
const positions = [];
|
|
516
554
|
|
|
@@ -556,7 +594,7 @@ function parseStoriesFromEpics(raw, aggregator) {
|
|
|
556
594
|
* Stories follow the pattern: ### Story N.M: Title
|
|
557
595
|
*/
|
|
558
596
|
function parseEpicsFromMarkdown(raw, storyContents) {
|
|
559
|
-
const epicRegex =
|
|
597
|
+
const epicRegex = /^#{2,3} Epic (\d+):\s*(.+)$/gm;
|
|
560
598
|
const epicMap = {};
|
|
561
599
|
let match;
|
|
562
600
|
|
|
@@ -21,8 +21,11 @@ const PUBLIC_DIR = join(__dirname, '..', '..', 'public');
|
|
|
21
21
|
export async function startServer({ port, bmadDir, open }) {
|
|
22
22
|
const actualPort = await findAvailablePort(port);
|
|
23
23
|
|
|
24
|
+
// Custom path overrides (can be set via API)
|
|
25
|
+
const overrides = { customEpicsPath: null, customOutputPath: null };
|
|
26
|
+
|
|
24
27
|
// Build initial data model
|
|
25
|
-
let dataModel = buildDataModel(bmadDir);
|
|
28
|
+
let dataModel = buildDataModel(bmadDir, overrides);
|
|
26
29
|
|
|
27
30
|
// Create HTTP server
|
|
28
31
|
const server = createServer((req, res) => {
|
|
@@ -33,10 +36,37 @@ export async function startServer({ port, bmadDir, open }) {
|
|
|
33
36
|
res.setHeader('Content-Security-Policy', "default-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://localhost:* ws://127.0.0.1:*");
|
|
34
37
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
35
38
|
|
|
39
|
+
// API endpoint: set custom paths
|
|
40
|
+
if (pathname === '/api/set-paths' && req.method === 'POST') {
|
|
41
|
+
let body = '';
|
|
42
|
+
req.on('data', chunk => { body += chunk; });
|
|
43
|
+
req.on('end', () => {
|
|
44
|
+
try {
|
|
45
|
+
const data = JSON.parse(body);
|
|
46
|
+
if (data.epicsPath !== undefined) overrides.customEpicsPath = data.epicsPath || null;
|
|
47
|
+
if (data.outputPath !== undefined) overrides.customOutputPath = data.outputPath || null;
|
|
48
|
+
dataModel = buildDataModel(bmadDir, overrides);
|
|
49
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
50
|
+
res.end(JSON.stringify({ ok: true, epics: dataModel.project.epics.length, stories: dataModel.project.stories.total }));
|
|
51
|
+
} catch {
|
|
52
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
53
|
+
res.end(JSON.stringify({ ok: false, error: 'Invalid JSON' }));
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// API endpoint: get current overrides
|
|
60
|
+
if (pathname === '/api/get-paths') {
|
|
61
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
62
|
+
res.end(JSON.stringify(overrides));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
36
66
|
// API endpoint: get fresh HTML
|
|
37
67
|
if (pathname === '/api/dashboard') {
|
|
38
68
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
39
|
-
dataModel = buildDataModel(bmadDir);
|
|
69
|
+
dataModel = buildDataModel(bmadDir, overrides);
|
|
40
70
|
const html = renderDashboard(dataModel);
|
|
41
71
|
res.end(JSON.stringify({ html }));
|
|
42
72
|
return;
|
|
@@ -72,7 +102,7 @@ export async function startServer({ port, bmadDir, open }) {
|
|
|
72
102
|
|
|
73
103
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
74
104
|
debounceTimer = setTimeout(() => {
|
|
75
|
-
dataModel = buildDataModel(bmadDir);
|
|
105
|
+
dataModel = buildDataModel(bmadDir, overrides);
|
|
76
106
|
broadcastChange([...pendingChanges]);
|
|
77
107
|
pendingChanges.length = 0;
|
|
78
108
|
}, 150);
|
package/src/server/renderer.js
CHANGED
|
@@ -44,8 +44,32 @@ export function renderDashboard(dataModel) {
|
|
|
44
44
|
const inProgress = storyList.filter((s) => s.status === 'in-progress');
|
|
45
45
|
const done = storyList.filter((s) => s.status === 'done' || s.status === 'review');
|
|
46
46
|
|
|
47
|
+
const noEpicsNotice = project.epics.length === 0
|
|
48
|
+
? `<div class="path-config-panel" id="path-config-panel">
|
|
49
|
+
<div class="path-config-panel__header">
|
|
50
|
+
<h3>No epics found</h3>
|
|
51
|
+
<p>Could not auto-detect epics in your project. You can specify custom paths below.</p>
|
|
52
|
+
</div>
|
|
53
|
+
<div class="path-config-panel__fields">
|
|
54
|
+
<label class="path-config-panel__label">
|
|
55
|
+
<span>Output folder</span>
|
|
56
|
+
<input type="text" id="custom-output-path" class="path-config-panel__input" placeholder="e.g. C:\\project\\_bmad-output" />
|
|
57
|
+
<span class="path-config-panel__hint">Folder containing planning-artifacts, implementation-artifacts, etc.</span>
|
|
58
|
+
</label>
|
|
59
|
+
<label class="path-config-panel__label">
|
|
60
|
+
<span>Epics file</span>
|
|
61
|
+
<input type="text" id="custom-epics-path" class="path-config-panel__input" placeholder="e.g. C:\\project\\docs\\epics.md" />
|
|
62
|
+
<span class="path-config-panel__hint">Markdown file with epic/story definitions (## Epic N: / ### Story N.M:)</span>
|
|
63
|
+
</label>
|
|
64
|
+
<button class="path-config-panel__btn" id="apply-paths-btn">Apply</button>
|
|
65
|
+
<span class="path-config-panel__status" id="path-config-status"></span>
|
|
66
|
+
</div>
|
|
67
|
+
</div>`
|
|
68
|
+
: '';
|
|
69
|
+
|
|
47
70
|
const projectContent = `<div id="project-view" hidden>
|
|
48
71
|
<div id="project-dashboard">
|
|
72
|
+
${noEpicsNotice}
|
|
49
73
|
${StatsBox({
|
|
50
74
|
total: project.stories.total,
|
|
51
75
|
pending: project.stories.pending,
|