mdboard 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin.js +44 -16
- package/build.js +44 -0
- package/index.html +1835 -216
- package/package.json +7 -10
- package/src/cli/cli.js +362 -0
- package/src/cli/init.js +123 -0
- package/src/cli/status.js +150 -0
- package/src/cli/sync.js +194 -0
- package/src/cli/theme.js +142 -0
- package/src/client/app.js +266 -0
- package/src/client/board.js +157 -0
- package/src/client/core.js +331 -0
- package/src/client/editor.js +318 -0
- package/src/client/history.js +137 -0
- package/src/client/metrics.js +38 -0
- package/src/client/milestones.js +77 -0
- package/src/client/notes.js +183 -0
- package/src/client/overview.js +104 -0
- package/src/client/panel.js +637 -0
- package/src/client/styles.css +471 -0
- package/src/client/table.js +111 -0
- package/src/client/template.html +144 -0
- package/src/client/themes.js +261 -0
- package/src/client/workspace.js +164 -0
- package/src/core/agent-scanner.js +260 -0
- package/{config.js → src/core/config.js} +27 -2
- package/src/core/history.js +130 -0
- package/{scanner.js → src/core/scanner.js} +141 -21
- package/{yaml.js → src/core/yaml.js} +5 -1
- package/{api.js → src/server/api.js} +150 -9
- package/{server.js → src/server/server.js} +105 -32
- package/{watcher.js → src/server/watcher.js} +40 -9
- package/init.js +0 -109
- /package/{workspace.js → src/core/workspace.js} +0 -0
package/package.json
CHANGED
|
@@ -1,23 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mdboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Git-based project management dashboard. Reads markdown files with YAML frontmatter and serves a visual kanban board, table, milestones, and metrics views.",
|
|
5
|
-
"main": "./server.js",
|
|
5
|
+
"main": "./src/server/server.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"mdboard": "bin.js"
|
|
8
8
|
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "node build.js"
|
|
11
|
+
},
|
|
9
12
|
"files": [
|
|
10
13
|
"bin.js",
|
|
11
|
-
"
|
|
14
|
+
"build.js",
|
|
12
15
|
"index.html",
|
|
13
|
-
"init.js",
|
|
14
|
-
"config.js",
|
|
15
16
|
"defaults.json",
|
|
16
|
-
"
|
|
17
|
-
"watcher.js",
|
|
18
|
-
"api.js",
|
|
19
|
-
"yaml.js",
|
|
20
|
-
"workspace.js"
|
|
17
|
+
"src/"
|
|
21
18
|
],
|
|
22
19
|
"keywords": [
|
|
23
20
|
"project-management",
|
package/src/cli/cli.js
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mdboard — CLI utilities + CRUD handlers
|
|
3
|
+
*
|
|
4
|
+
* Provides buildModel() for shared use by status/sync/CRUD,
|
|
5
|
+
* parseFlags() for CLI argument parsing, and
|
|
6
|
+
* create/update/delete handlers with reference cleanup.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { loadConfig } = require('../core/config');
|
|
12
|
+
const { loadWorkspace } = require('../core/workspace');
|
|
13
|
+
const {
|
|
14
|
+
createModel, scanSource, computeProgress, mergeResults,
|
|
15
|
+
updateMarkdownFile, createTask, createMilestone, createEpic,
|
|
16
|
+
createSprint, archiveItem,
|
|
17
|
+
} = require('../core/scanner');
|
|
18
|
+
const { parseFrontmatter, serializeYaml } = require('../core/yaml');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build the full project model from disk.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} projectDir - Workspace root directory
|
|
24
|
+
* @returns {{ model, config, projectPath, workspace, sources }}
|
|
25
|
+
*/
|
|
26
|
+
function buildModel(projectDir) {
|
|
27
|
+
const projectPath = path.join(projectDir, 'project');
|
|
28
|
+
const config = loadConfig(projectDir, process.env.MDBOARD_CONFIG);
|
|
29
|
+
const wsPath = process.env.MDBOARD_WORKSPACE || null;
|
|
30
|
+
const workspace = loadWorkspace(projectDir, wsPath);
|
|
31
|
+
const sources = workspace ? workspace.sources : [];
|
|
32
|
+
|
|
33
|
+
const model = createModel();
|
|
34
|
+
|
|
35
|
+
if (workspace && sources.length > 0) {
|
|
36
|
+
const results = [];
|
|
37
|
+
for (const source of sources) {
|
|
38
|
+
const sourcePath = source._resolvedPath;
|
|
39
|
+
if (!sourcePath) continue;
|
|
40
|
+
const readonly = source.readonly != null ? source.readonly : (source.type === 'remote');
|
|
41
|
+
const meta = {
|
|
42
|
+
name: source.name,
|
|
43
|
+
label: source.label || source.name,
|
|
44
|
+
color: source.color || null,
|
|
45
|
+
type: source.type,
|
|
46
|
+
readonly,
|
|
47
|
+
};
|
|
48
|
+
results.push(scanSource(sourcePath, config, meta));
|
|
49
|
+
}
|
|
50
|
+
mergeResults(model, results);
|
|
51
|
+
} else {
|
|
52
|
+
mergeResults(model, [scanSource(projectPath, config, {})]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
computeProgress(model, config.completedStatus);
|
|
56
|
+
|
|
57
|
+
return { model, config, projectPath, workspace, sources };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parse CLI flags and positional arguments.
|
|
62
|
+
*
|
|
63
|
+
* @param {string[]} args - Raw CLI arguments
|
|
64
|
+
* @returns {{ flags: object, positional: string[] }}
|
|
65
|
+
*/
|
|
66
|
+
const GLOBAL_FLAGS = new Set(['project', 'config', 'workspace']);
|
|
67
|
+
|
|
68
|
+
function parseFlags(args) {
|
|
69
|
+
const flags = {};
|
|
70
|
+
const positional = [];
|
|
71
|
+
|
|
72
|
+
for (let i = 0; i < args.length; i++) {
|
|
73
|
+
if (args[i].startsWith('--')) {
|
|
74
|
+
const key = args[i].slice(2);
|
|
75
|
+
const next = args[i + 1];
|
|
76
|
+
if (GLOBAL_FLAGS.has(key)) {
|
|
77
|
+
// Skip global flags (consumed by resolveProjectDir / bin.js)
|
|
78
|
+
if (next && !next.startsWith('--')) i++;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (next && !next.startsWith('--')) {
|
|
82
|
+
flags[key] = next;
|
|
83
|
+
i++;
|
|
84
|
+
} else {
|
|
85
|
+
flags[key] = true;
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
positional.push(args[i]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { flags, positional };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolve the project directory from args.
|
|
97
|
+
*
|
|
98
|
+
* @param {string[]} args - Raw CLI arguments
|
|
99
|
+
* @returns {string}
|
|
100
|
+
*/
|
|
101
|
+
function resolveProjectDir(args) {
|
|
102
|
+
for (let i = 0; i < args.length; i++) {
|
|
103
|
+
if (args[i] === '--project' && args[i + 1]) {
|
|
104
|
+
return path.resolve(args[i + 1]);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return process.cwd();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
111
|
+
// CRUD handlers
|
|
112
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* mdboard create <entity> <title> [flags]
|
|
116
|
+
*/
|
|
117
|
+
function handleCreate(projectDir, args) {
|
|
118
|
+
const { flags, positional } = parseFlags(args);
|
|
119
|
+
const entity = positional[0];
|
|
120
|
+
const title = positional.slice(1).join(' ');
|
|
121
|
+
|
|
122
|
+
if (!entity) {
|
|
123
|
+
console.log(`
|
|
124
|
+
mdboard create — Create a new entity
|
|
125
|
+
|
|
126
|
+
Usage:
|
|
127
|
+
mdboard create milestone <title>
|
|
128
|
+
mdboard create epic <title> --milestone <slug>
|
|
129
|
+
mdboard create task <title> --milestone <slug> --epic <slug> [--priority p] [--points n] [--sprint id]
|
|
130
|
+
mdboard create sprint --milestone <slug> [--goal "text"]
|
|
131
|
+
`);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const { model, config, projectPath } = buildModel(projectDir);
|
|
136
|
+
|
|
137
|
+
switch (entity) {
|
|
138
|
+
case 'milestone': {
|
|
139
|
+
if (!title) { console.error(' Error: title is required'); process.exit(1); }
|
|
140
|
+
const result = createMilestone(projectPath, config, { title }, model.milestones);
|
|
141
|
+
console.log(` Created milestone ${result.id}: ${result.file}`);
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
case 'epic': {
|
|
146
|
+
if (!title) { console.error(' Error: title is required'); process.exit(1); }
|
|
147
|
+
if (!flags.milestone) { console.error(' Error: --milestone is required'); process.exit(1); }
|
|
148
|
+
const result = createEpic(projectPath, config, {
|
|
149
|
+
title,
|
|
150
|
+
milestone: flags.milestone,
|
|
151
|
+
}, model.epics);
|
|
152
|
+
console.log(` Created epic ${result.id}: ${result.file}`);
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
case 'task': {
|
|
157
|
+
if (!title) { console.error(' Error: title is required'); process.exit(1); }
|
|
158
|
+
if (!flags.milestone) { console.error(' Error: --milestone is required'); process.exit(1); }
|
|
159
|
+
if (!flags.epic) { console.error(' Error: --epic is required'); process.exit(1); }
|
|
160
|
+
const data = {
|
|
161
|
+
title,
|
|
162
|
+
milestone: flags.milestone,
|
|
163
|
+
epic: flags.epic,
|
|
164
|
+
};
|
|
165
|
+
if (flags.priority) data.priority = flags.priority;
|
|
166
|
+
if (flags.points) data.points = parseInt(flags.points, 10);
|
|
167
|
+
if (flags.sprint) data.sprint = flags.sprint;
|
|
168
|
+
if (flags.assigned) data.assigned = flags.assigned;
|
|
169
|
+
if (flags.status) data.status = flags.status;
|
|
170
|
+
const result = createTask(projectPath, config, data, model.tasks);
|
|
171
|
+
console.log(` Created task ${result.id}: ${result.file}`);
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
case 'sprint': {
|
|
176
|
+
if (!flags.milestone) { console.error(' Error: --milestone is required'); process.exit(1); }
|
|
177
|
+
const data = { milestone: flags.milestone };
|
|
178
|
+
if (flags.goal) data.goal = flags.goal;
|
|
179
|
+
if (flags.status) data.status = flags.status;
|
|
180
|
+
const result = createSprint(projectPath, config, data, model.sprints);
|
|
181
|
+
console.log(` Created sprint ${result.id}: ${result.file}`);
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
default:
|
|
186
|
+
console.error(` Error: unknown entity "${entity}". Use: milestone, epic, task, sprint`);
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* mdboard update <entity> <id> [--key value ...]
|
|
193
|
+
*/
|
|
194
|
+
function handleUpdate(projectDir, args) {
|
|
195
|
+
const { flags, positional } = parseFlags(args);
|
|
196
|
+
const entity = positional[0];
|
|
197
|
+
const id = positional[1];
|
|
198
|
+
|
|
199
|
+
if (!entity || !id) {
|
|
200
|
+
console.log(`
|
|
201
|
+
mdboard update — Update an entity's frontmatter
|
|
202
|
+
|
|
203
|
+
Usage:
|
|
204
|
+
mdboard update task TASK-005 --status in-progress --assigned john
|
|
205
|
+
mdboard update milestone MS-001 --status active
|
|
206
|
+
mdboard update epic EPIC-002 --priority high
|
|
207
|
+
mdboard update sprint SP-003 --status active --goal "New goal"
|
|
208
|
+
`);
|
|
209
|
+
process.exit(1);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const { model, projectPath } = buildModel(projectDir);
|
|
213
|
+
|
|
214
|
+
const collectionMap = {
|
|
215
|
+
task: 'tasks',
|
|
216
|
+
milestone: 'milestones',
|
|
217
|
+
epic: 'epics',
|
|
218
|
+
sprint: 'sprints',
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const collection = collectionMap[entity];
|
|
222
|
+
if (!collection) {
|
|
223
|
+
console.error(` Error: unknown entity "${entity}". Use: task, milestone, epic, sprint`);
|
|
224
|
+
process.exit(1);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const items = model[collection];
|
|
228
|
+
const item = items.find(x => x.id === id) || items.find(x => x._originalId === id);
|
|
229
|
+
|
|
230
|
+
if (!item) {
|
|
231
|
+
console.error(` Error: ${entity} "${id}" not found`);
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (Object.keys(flags).length === 0) {
|
|
236
|
+
console.error(' Error: no fields to update. Use --key value flags.');
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Convert numeric fields
|
|
241
|
+
const updates = { ...flags };
|
|
242
|
+
if (updates.points) updates.points = parseInt(updates.points, 10);
|
|
243
|
+
if (updates.planned_points) updates.planned_points = parseInt(updates.planned_points, 10);
|
|
244
|
+
|
|
245
|
+
updateMarkdownFile(projectPath, item._file, updates);
|
|
246
|
+
console.log(` Updated ${entity} ${id}: ${item._file}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* mdboard delete <entity> <id>
|
|
251
|
+
*/
|
|
252
|
+
function handleDelete(projectDir, args) {
|
|
253
|
+
const { positional } = parseFlags(args);
|
|
254
|
+
const entity = positional[0];
|
|
255
|
+
const id = positional[1];
|
|
256
|
+
|
|
257
|
+
if (!entity || !id) {
|
|
258
|
+
console.log(`
|
|
259
|
+
mdboard delete — Delete an entity
|
|
260
|
+
|
|
261
|
+
Usage:
|
|
262
|
+
mdboard delete task TASK-005
|
|
263
|
+
mdboard delete sprint SP-003
|
|
264
|
+
mdboard delete epic EPIC-002
|
|
265
|
+
mdboard delete milestone MS-001
|
|
266
|
+
`);
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const { model, config, projectPath } = buildModel(projectDir);
|
|
271
|
+
|
|
272
|
+
const collectionMap = {
|
|
273
|
+
task: 'tasks',
|
|
274
|
+
milestone: 'milestones',
|
|
275
|
+
epic: 'epics',
|
|
276
|
+
sprint: 'sprints',
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const collection = collectionMap[entity];
|
|
280
|
+
if (!collection) {
|
|
281
|
+
console.error(` Error: unknown entity "${entity}". Use: task, milestone, epic, sprint`);
|
|
282
|
+
process.exit(1);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const items = model[collection];
|
|
286
|
+
const item = items.find(x => x.id === id) || items.find(x => x._originalId === id);
|
|
287
|
+
|
|
288
|
+
if (!item) {
|
|
289
|
+
console.error(` Error: ${entity} "${id}" not found`);
|
|
290
|
+
process.exit(1);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
archiveItem(projectPath, item);
|
|
294
|
+
console.log(` Deleted ${entity} ${id}: ${item._file}`);
|
|
295
|
+
|
|
296
|
+
// Clean up references
|
|
297
|
+
const cleaned = cleanReferences(projectPath, model, config, entity, item);
|
|
298
|
+
if (cleaned > 0) {
|
|
299
|
+
console.log(` Cleaned ${cleaned} reference${cleaned > 1 ? 's' : ''}.`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Clean up dangling references after deleting an entity.
|
|
305
|
+
*
|
|
306
|
+
* @param {string} projectPath - Absolute path to project/ dir
|
|
307
|
+
* @param {object} model - Current model
|
|
308
|
+
* @param {object} config - Config object
|
|
309
|
+
* @param {string} entity - Entity type deleted (task, sprint, epic)
|
|
310
|
+
* @param {object} deletedItem - The deleted item
|
|
311
|
+
* @returns {number} - Number of references cleaned
|
|
312
|
+
*/
|
|
313
|
+
function cleanReferences(projectPath, model, config, entity, deletedItem) {
|
|
314
|
+
let cleaned = 0;
|
|
315
|
+
const deletedId = deletedItem.id;
|
|
316
|
+
|
|
317
|
+
if (entity === 'task') {
|
|
318
|
+
// Remove task from sprint.features[]
|
|
319
|
+
for (const sprint of model.sprints) {
|
|
320
|
+
if (sprint.features && Array.isArray(sprint.features) && sprint.features.includes(deletedId)) {
|
|
321
|
+
const newFeatures = sprint.features.filter(f => f !== deletedId);
|
|
322
|
+
updateMarkdownFile(projectPath, sprint._file, { features: newFeatures });
|
|
323
|
+
cleaned++;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (entity === 'sprint') {
|
|
329
|
+
// Clear task.sprint references
|
|
330
|
+
for (const task of model.tasks) {
|
|
331
|
+
if (task.sprint === deletedId) {
|
|
332
|
+
updateMarkdownFile(projectPath, task._file, { sprint: null });
|
|
333
|
+
cleaned++;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (entity === 'epic') {
|
|
339
|
+
// Clean epic.dependencies[] references
|
|
340
|
+
for (const epic of model.epics) {
|
|
341
|
+
if (epic.dependencies && Array.isArray(epic.dependencies) && epic.dependencies.includes(deletedId)) {
|
|
342
|
+
const newDeps = epic.dependencies.filter(d => d !== deletedId);
|
|
343
|
+
updateMarkdownFile(projectPath, epic._file, { dependencies: newDeps });
|
|
344
|
+
cleaned++;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// Warn about orphaned tasks
|
|
348
|
+
const orphanedTasks = model.tasks.filter(t =>
|
|
349
|
+
t._epic === deletedItem._dir && t._milestone === deletedItem._milestone
|
|
350
|
+
);
|
|
351
|
+
if (orphanedTasks.length > 0) {
|
|
352
|
+
console.log(` Warning: ${orphanedTasks.length} task(s) under deleted epic may be orphaned.`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return cleaned;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
module.exports = {
|
|
360
|
+
buildModel, parseFlags, resolveProjectDir,
|
|
361
|
+
handleCreate, handleUpdate, handleDelete, cleanReferences,
|
|
362
|
+
};
|
package/src/cli/init.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const readline = require('readline');
|
|
6
|
+
const { loadConfig } = require('../core/config');
|
|
7
|
+
|
|
8
|
+
const cwd = process.cwd();
|
|
9
|
+
const projectPath = path.join(cwd, 'project');
|
|
10
|
+
const today = new Date().toISOString().split('T')[0];
|
|
11
|
+
const config = loadConfig(cwd);
|
|
12
|
+
|
|
13
|
+
if (fs.existsSync(projectPath)) {
|
|
14
|
+
console.log(`\n project/ already exists at ${projectPath}`);
|
|
15
|
+
console.log(' Skipping init to avoid overwriting existing data.\n');
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const nameArg = (process.env.MDBOARD_INIT_NAME || '').trim();
|
|
20
|
+
|
|
21
|
+
if (nameArg) {
|
|
22
|
+
scaffold(nameArg);
|
|
23
|
+
} else {
|
|
24
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
25
|
+
rl.question(' Workspace name: ', function (answer) {
|
|
26
|
+
rl.close();
|
|
27
|
+
const name = answer.trim() || path.basename(cwd);
|
|
28
|
+
scaffold(name);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function scaffold(name) {
|
|
33
|
+
const msDir = config.entities.milestone.dir;
|
|
34
|
+
|
|
35
|
+
// Create directories
|
|
36
|
+
fs.mkdirSync(projectPath, { recursive: true });
|
|
37
|
+
fs.mkdirSync(path.join(projectPath, msDir), { recursive: true });
|
|
38
|
+
// PROJECT.md
|
|
39
|
+
fs.writeFileSync(path.join(projectPath, 'PROJECT.md'), `---
|
|
40
|
+
name: "${name}"
|
|
41
|
+
description: ""
|
|
42
|
+
created: ${today}
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
# ${name}
|
|
46
|
+
|
|
47
|
+
## Overview
|
|
48
|
+
|
|
49
|
+
Describe your project here.
|
|
50
|
+
|
|
51
|
+
## Objectives
|
|
52
|
+
|
|
53
|
+
-
|
|
54
|
+
|
|
55
|
+
## Scope
|
|
56
|
+
|
|
57
|
+
-
|
|
58
|
+
|
|
59
|
+
`, 'utf-8');
|
|
60
|
+
|
|
61
|
+
// mdboard.json — example config
|
|
62
|
+
fs.writeFileSync(path.join(projectPath, 'mdboard.json'), `{
|
|
63
|
+
"entities": {
|
|
64
|
+
"task": {
|
|
65
|
+
"singular": "Task",
|
|
66
|
+
"plural": "Tasks",
|
|
67
|
+
"prefix": "TASK"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
`, 'utf-8');
|
|
72
|
+
|
|
73
|
+
// mdboard.css — example custom theme
|
|
74
|
+
fs.writeFileSync(path.join(projectPath, 'mdboard.css'), `/* mdboard custom theme
|
|
75
|
+
Override any CSS variable from the dashboard.
|
|
76
|
+
Example:
|
|
77
|
+
:root {
|
|
78
|
+
--bg: #FFFFFF;
|
|
79
|
+
--surface: #F8F9FA;
|
|
80
|
+
--text: #212529;
|
|
81
|
+
--accent: #0969DA;
|
|
82
|
+
}
|
|
83
|
+
*/
|
|
84
|
+
`, 'utf-8');
|
|
85
|
+
|
|
86
|
+
// workspace.json — default workspace with current project as source
|
|
87
|
+
const wsPath = path.join(cwd, 'workspace.json');
|
|
88
|
+
if (!fs.existsSync(wsPath)) {
|
|
89
|
+
fs.writeFileSync(wsPath, JSON.stringify({
|
|
90
|
+
name: name,
|
|
91
|
+
sources: [
|
|
92
|
+
{ name: name.toLowerCase().replace(/\s+/g, '-'), label: name, path: ".", color: "#5B6EF5" }
|
|
93
|
+
],
|
|
94
|
+
settings: {}
|
|
95
|
+
}, null, 2) + '\n', 'utf-8');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
console.log(`
|
|
99
|
+
mdboard init — workspace "${name}" scaffolded
|
|
100
|
+
|
|
101
|
+
Created:
|
|
102
|
+
project/PROJECT.md
|
|
103
|
+
project/${msDir}/
|
|
104
|
+
project/mdboard.json
|
|
105
|
+
project/mdboard.css
|
|
106
|
+
workspace.json
|
|
107
|
+
|
|
108
|
+
Next steps:
|
|
109
|
+
1. Edit project/PROJECT.md with your project details
|
|
110
|
+
2. Create milestones and epics under project/${msDir}/
|
|
111
|
+
3. Customize project/mdboard.json for your workflow
|
|
112
|
+
4. Run \`mdboard\` to start the dashboard
|
|
113
|
+
|
|
114
|
+
Add sources to workspace.json to manage multiple repos:
|
|
115
|
+
{
|
|
116
|
+
"name": "${name}",
|
|
117
|
+
"sources": [
|
|
118
|
+
{ "name": "api", "path": "../api-repo", "icon": "🔧" },
|
|
119
|
+
{ "name": "web", "path": "../web-repo", "icon": "🌐" }
|
|
120
|
+
]
|
|
121
|
+
}
|
|
122
|
+
`);
|
|
123
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mdboard — Status generator
|
|
3
|
+
*
|
|
4
|
+
* Generates project/status.md with computed project status
|
|
5
|
+
* from the real model on disk.
|
|
6
|
+
*
|
|
7
|
+
* Usage: mdboard status
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { buildModel } = require('./cli');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Generate project/status.md
|
|
16
|
+
*
|
|
17
|
+
* @param {string} projectDir - Workspace root directory
|
|
18
|
+
*/
|
|
19
|
+
function generateStatus(projectDir) {
|
|
20
|
+
const { model, config, projectPath } = buildModel(projectDir);
|
|
21
|
+
|
|
22
|
+
const projectName = (model.project && model.project.name) || path.basename(projectDir);
|
|
23
|
+
const completedStatus = config.completedStatus;
|
|
24
|
+
const now = new Date().toISOString();
|
|
25
|
+
|
|
26
|
+
const lines = [];
|
|
27
|
+
|
|
28
|
+
// Frontmatter
|
|
29
|
+
lines.push('---');
|
|
30
|
+
lines.push(`generated: ${now}`);
|
|
31
|
+
lines.push('---');
|
|
32
|
+
lines.push('');
|
|
33
|
+
lines.push(`# Project Status: ${projectName}`);
|
|
34
|
+
lines.push('');
|
|
35
|
+
lines.push('> Auto-generated by `mdboard status`. Do not edit manually.');
|
|
36
|
+
lines.push('');
|
|
37
|
+
|
|
38
|
+
// Summary
|
|
39
|
+
const totalTasks = model.tasks.length;
|
|
40
|
+
const doneTasks = model.tasks.filter(t => t.status === completedStatus).length;
|
|
41
|
+
const donePercent = totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0;
|
|
42
|
+
const totalPoints = model.tasks.reduce((sum, t) => sum + (t.points || 0), 0);
|
|
43
|
+
const donePoints = model.tasks.filter(t => t.status === completedStatus)
|
|
44
|
+
.reduce((sum, t) => sum + (t.points || 0), 0);
|
|
45
|
+
const pointsPercent = totalPoints > 0 ? Math.round((donePoints / totalPoints) * 100) : 0;
|
|
46
|
+
const activeMilestones = model.milestones.filter(m => m.status === 'active');
|
|
47
|
+
const activeSprint = model.sprints.find(s => s.status === 'active');
|
|
48
|
+
|
|
49
|
+
lines.push('## Summary');
|
|
50
|
+
lines.push('');
|
|
51
|
+
lines.push('| Metric | Value |');
|
|
52
|
+
lines.push('|--------|-------|');
|
|
53
|
+
lines.push(`| Milestones | ${model.milestones.length} (${activeMilestones.length} active) |`);
|
|
54
|
+
lines.push(`| Tasks | ${totalTasks} total, ${doneTasks} done (${donePercent}%) |`);
|
|
55
|
+
lines.push(`| Points | ${donePoints}/${totalPoints} (${pointsPercent}%) |`);
|
|
56
|
+
lines.push(`| Active Sprint | ${activeSprint ? activeSprint.id : 'none'} |`);
|
|
57
|
+
lines.push('');
|
|
58
|
+
|
|
59
|
+
// Milestones
|
|
60
|
+
if (model.milestones.length > 0) {
|
|
61
|
+
lines.push('## Milestones');
|
|
62
|
+
lines.push('');
|
|
63
|
+
for (const ms of model.milestones) {
|
|
64
|
+
const msEpics = model.epics.filter(e => e._milestone === ms._dir);
|
|
65
|
+
const msTasks = model.tasks.filter(t => t._milestone === ms._dir);
|
|
66
|
+
const msDone = msTasks.filter(t => t.status === completedStatus).length;
|
|
67
|
+
lines.push(`### ${ms.id}: ${ms.title || ms._dir} [${ms.status}] (${ms._progress || 0}%)`);
|
|
68
|
+
lines.push(`- File: [${ms._file}](${ms._file})`);
|
|
69
|
+
lines.push(`- Epics: ${msEpics.length}, Tasks: ${msDone}/${msTasks.length} done`);
|
|
70
|
+
lines.push('');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Active Sprint
|
|
75
|
+
if (activeSprint) {
|
|
76
|
+
lines.push('## Active Sprint');
|
|
77
|
+
lines.push('');
|
|
78
|
+
lines.push(`### ${activeSprint.id}`);
|
|
79
|
+
lines.push(`- File: [${activeSprint._file}](${activeSprint._file})`);
|
|
80
|
+
if (activeSprint.goal) {
|
|
81
|
+
lines.push(`- Goal: ${activeSprint.goal}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const sprintFeatureIds = activeSprint.features || [];
|
|
85
|
+
const sprintTasks = sprintFeatureIds.length > 0
|
|
86
|
+
? model.tasks.filter(t => sprintFeatureIds.includes(t.id))
|
|
87
|
+
: model.tasks.filter(t => t.sprint === activeSprint.id);
|
|
88
|
+
|
|
89
|
+
if (sprintTasks.length > 0) {
|
|
90
|
+
lines.push('- Tasks:');
|
|
91
|
+
for (const t of sprintTasks) {
|
|
92
|
+
lines.push(` - ${t.id} [${t.status}] ${t.title || ''} -> [${t._file}](${t._file})`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
lines.push('');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Tasks by status
|
|
99
|
+
const statusGroups = {};
|
|
100
|
+
for (const task of model.tasks) {
|
|
101
|
+
const s = task.status || 'unknown';
|
|
102
|
+
if (!statusGroups[s]) statusGroups[s] = [];
|
|
103
|
+
statusGroups[s].push(task);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const statusOrder = Object.keys(statusGroups).sort((a, b) => {
|
|
107
|
+
// Put completed last, in-progress first
|
|
108
|
+
if (a === completedStatus) return 1;
|
|
109
|
+
if (b === completedStatus) return -1;
|
|
110
|
+
if (a === 'in-progress') return -1;
|
|
111
|
+
if (b === 'in-progress') return 1;
|
|
112
|
+
return a.localeCompare(b);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (statusOrder.length > 0) {
|
|
116
|
+
lines.push('## Tasks by Status');
|
|
117
|
+
lines.push('');
|
|
118
|
+
|
|
119
|
+
for (const status of statusOrder) {
|
|
120
|
+
const tasks = statusGroups[status];
|
|
121
|
+
lines.push(`### ${capitalize(status)} (${tasks.length})`);
|
|
122
|
+
lines.push('');
|
|
123
|
+
lines.push('| ID | Title | Epic | Priority | File |');
|
|
124
|
+
lines.push('|----|-------|------|----------|------|');
|
|
125
|
+
for (const t of tasks) {
|
|
126
|
+
const title = t.title || '';
|
|
127
|
+
const epic = t._epic || '';
|
|
128
|
+
const priority = t.priority || '';
|
|
129
|
+
lines.push(`| ${t.id} | ${title} | ${epic} | ${priority} | [${t._file}](${t._file}) |`);
|
|
130
|
+
}
|
|
131
|
+
lines.push('');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const content = lines.join('\n') + '\n';
|
|
136
|
+
|
|
137
|
+
// Write to project/status.md
|
|
138
|
+
const statusPath = path.join(projectPath, 'status.md');
|
|
139
|
+
fs.mkdirSync(projectPath, { recursive: true });
|
|
140
|
+
fs.writeFileSync(statusPath, content, 'utf-8');
|
|
141
|
+
|
|
142
|
+
console.log(` mdboard status — Generated ${path.relative(projectDir, statusPath)}`);
|
|
143
|
+
console.log(` ${totalTasks} tasks, ${model.milestones.length} milestones, ${model.sprints.length} sprints`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function capitalize(str) {
|
|
147
|
+
return str.replace(/(^|-)(\w)/g, (_, sep, c) => (sep ? ' ' : '') + c.toUpperCase());
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = { generateStatus };
|