mdboard 1.3.0 → 2.1.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.
Files changed (53) hide show
  1. package/bin.js +117 -59
  2. package/index.html +2161 -1579
  3. package/package.json +7 -5
  4. package/presets/kanban/api.json +91 -0
  5. package/presets/kanban/cli.json +69 -0
  6. package/presets/kanban/docs.json +29 -0
  7. package/presets/kanban/entities.json +128 -0
  8. package/presets/kanban/structure.json +15 -0
  9. package/presets/kanban/ui.json +86 -0
  10. package/presets/scrum/api.json +98 -0
  11. package/presets/scrum/cli.json +120 -0
  12. package/presets/scrum/docs.json +43 -0
  13. package/presets/scrum/entities.json +268 -0
  14. package/presets/scrum/structure.json +32 -0
  15. package/presets/scrum/ui.json +201 -0
  16. package/presets/shape-up/api.json +40 -0
  17. package/presets/shape-up/cli.json +44 -0
  18. package/presets/shape-up/docs.json +32 -0
  19. package/presets/shape-up/entities.json +140 -0
  20. package/presets/shape-up/structure.json +28 -0
  21. package/presets/shape-up/ui.json +114 -0
  22. package/src/cli/cli.js +186 -210
  23. package/src/cli/config.js +234 -0
  24. package/src/cli/init.js +128 -76
  25. package/src/cli/preset.js +849 -0
  26. package/src/cli/skill.js +417 -0
  27. package/src/cli/status.js +126 -96
  28. package/src/core/config.js +491 -38
  29. package/src/core/history.js +17 -1
  30. package/src/core/scanner.js +373 -463
  31. package/src/core/workspace.js +0 -15
  32. package/src/server/api.js +464 -741
  33. package/src/server/server.js +105 -130
  34. package/build.js +0 -44
  35. package/defaults.json +0 -43
  36. package/src/cli/sync.js +0 -194
  37. package/src/cli/theme.js +0 -142
  38. package/src/client/app.js +0 -266
  39. package/src/client/board.js +0 -157
  40. package/src/client/core.js +0 -331
  41. package/src/client/editor.js +0 -318
  42. package/src/client/history.js +0 -137
  43. package/src/client/metrics.js +0 -38
  44. package/src/client/milestones.js +0 -77
  45. package/src/client/notes.js +0 -183
  46. package/src/client/overview.js +0 -104
  47. package/src/client/panel.js +0 -637
  48. package/src/client/styles.css +0 -471
  49. package/src/client/table.js +0 -111
  50. package/src/client/template.html +0 -144
  51. package/src/client/themes.js +0 -261
  52. package/src/client/workspace.js +0 -164
  53. package/src/core/agent-scanner.js +0 -260
@@ -1,19 +1,28 @@
1
1
  /**
2
- * mdboard — Configuration loader
2
+ * mdboard — Configuration Engine
3
+ *
4
+ * Loads and resolves the 6 configuration files (entities, structure, cli, api, ui, docs).
5
+ * Provides the public API that the rest of the system consumes.
3
6
  *
4
7
  * Resolution order (highest priority first):
5
8
  * 1. Explicit path via --config / MDBOARD_CONFIG
6
- * 2. <projectDir>/project/mdboard.json (per-project)
7
- * 3. <projectDir>/mdboard.json (workspace)
8
- * 4. ~/.config/mdboard/mdboard.json (global)
9
- * 5. defaults.json (built-in)
9
+ * 2. <projectDir>/project/<file>.json (per-project overrides)
10
+ * 3. preset directory (built-in)
11
+ *
12
+ * The preset is determined by entities.json methodology field, defaulting to "shape-up".
10
13
  */
11
14
 
15
+ 'use strict';
16
+
12
17
  const fs = require('fs');
13
18
  const path = require('path');
14
19
  const os = require('os');
15
20
 
16
- const defaults = require('../../defaults.json');
21
+ const PRESETS_DIR = path.join(__dirname, '..', '..', 'presets');
22
+ const CONFIG_FILES = ['entities', 'structure', 'cli', 'api', 'ui', 'docs'];
23
+ const DEFAULT_PRESET = 'shape-up';
24
+
25
+ // --- Utilities ---
17
26
 
18
27
  function deepMerge(target, source) {
19
28
  const result = { ...target };
@@ -31,68 +40,512 @@ function deepMerge(target, source) {
31
40
 
32
41
  function tryReadJson(filePath) {
33
42
  try {
34
- const raw = fs.readFileSync(filePath, 'utf-8');
35
- return JSON.parse(raw);
43
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
36
44
  } catch {
37
45
  return null;
38
46
  }
39
47
  }
40
48
 
49
+ // --- Loader ---
50
+
51
+ /**
52
+ * Load all configuration files for a project.
53
+ *
54
+ * @param {string} projectDir - The workspace root directory
55
+ * @param {string} [explicitPath] - Explicit path to a config directory
56
+ * @returns {object} - Merged configuration object with all 6 configs
57
+ */
41
58
  function loadConfig(projectDir, explicitPath) {
42
- const candidates = [];
59
+ // Determine which preset to use
60
+ // Priority: project mdboard.json > project entities.json > env var > default
61
+ let preset = process.env.MDBOARD_PRESET || DEFAULT_PRESET;
43
62
 
44
- if (explicitPath) {
45
- candidates.push(path.resolve(explicitPath));
63
+ if (projectDir) {
64
+ const userMdboard = tryReadJson(path.join(projectDir, 'project', 'mdboard.json'));
65
+ if (userMdboard && userMdboard.preset) {
66
+ preset = userMdboard.preset;
67
+ }
46
68
  }
47
69
 
48
- if (projectDir) {
49
- candidates.push(path.join(projectDir, 'project', 'mdboard.json'));
50
- candidates.push(path.join(projectDir, 'mdboard.json'));
70
+ const userEntities = projectDir
71
+ ? tryReadJson(path.join(projectDir, 'project', 'entities.json'))
72
+ : null;
73
+ if (userEntities && userEntities.methodology) {
74
+ preset = userEntities.methodology;
51
75
  }
52
76
 
53
- const globalPath = path.join(os.homedir(), '.config', 'mdboard', 'mdboard.json');
54
- candidates.push(globalPath);
77
+ const presetDir = path.join(PRESETS_DIR, preset);
78
+ const config = { _preset: preset, _projectDir: projectDir };
55
79
 
56
- let userConfig = null;
57
- let configPath = null;
80
+ for (const name of CONFIG_FILES) {
81
+ // Load preset base
82
+ const presetFile = tryReadJson(path.join(presetDir, name + '.json'));
58
83
 
59
- for (const p of candidates) {
60
- const data = tryReadJson(p);
61
- if (data) {
62
- userConfig = data;
63
- configPath = p;
64
- break;
84
+ // Load user overrides
85
+ const candidates = [];
86
+ if (explicitPath) {
87
+ candidates.push(path.join(path.resolve(explicitPath), name + '.json'));
88
+ }
89
+ if (projectDir) {
90
+ candidates.push(path.join(projectDir, 'project', name + '.json'));
91
+ }
92
+
93
+ let userFile = null;
94
+ let userPath = null;
95
+ for (const p of candidates) {
96
+ const data = tryReadJson(p);
97
+ if (data) {
98
+ userFile = data;
99
+ userPath = p;
100
+ break;
101
+ }
102
+ }
103
+
104
+ if (presetFile && userFile) {
105
+ config[name] = deepMerge(presetFile, userFile);
106
+ } else {
107
+ config[name] = userFile || presetFile || {};
65
108
  }
109
+ config['_' + name + 'Path'] = userPath || path.join(presetDir, name + '.json');
66
110
  }
67
111
 
68
- const config = userConfig ? deepMerge(defaults, userConfig) : { ...defaults };
69
- config._path = configPath;
112
+ // Also load legacy mdboard.json for theme resolution
113
+ config._theme = resolveTheme(projectDir, explicitPath);
114
+
70
115
  return config;
71
116
  }
72
117
 
73
- function resolveTheme(projectDir, explicitPath) {
74
- const candidates = [];
118
+ // --- Entity API ---
119
+
120
+ /**
121
+ * Get entity definition by type name.
122
+ * @param {object} config - Loaded config
123
+ * @param {string} type - Entity type (e.g. "cycle", "task")
124
+ * @returns {object|null}
125
+ */
126
+ function getEntity(config, type) {
127
+ return (config.entities && config.entities.entities && config.entities.entities[type]) || null;
128
+ }
129
+
130
+ /**
131
+ * Get all entity type names.
132
+ * @param {object} config - Loaded config
133
+ * @returns {string[]}
134
+ */
135
+ function getEntityTypes(config) {
136
+ return config.entities && config.entities.entities
137
+ ? Object.keys(config.entities.entities)
138
+ : [];
139
+ }
140
+
141
+ /**
142
+ * Get fields definition for an entity type.
143
+ * @param {object} config - Loaded config
144
+ * @param {string} type - Entity type
145
+ * @returns {object} - Field definitions
146
+ */
147
+ function getFields(config, type) {
148
+ const entity = getEntity(config, type);
149
+ return entity ? entity.fields || {} : {};
150
+ }
151
+
152
+ /**
153
+ * Get status values for an entity type (if it has a status enum field).
154
+ * @param {object} config - Loaded config
155
+ * @param {string} type - Entity type
156
+ * @returns {object[]|null} - Array of status value objects
157
+ */
158
+ function getStatuses(config, type) {
159
+ const fields = getFields(config, type);
160
+ return fields.status && fields.status.type === 'enum' ? fields.status.values : null;
161
+ }
162
+
163
+ /**
164
+ * Check if a status key is considered "completed".
165
+ * @param {object} config - Loaded config
166
+ * @param {string} statusKey - Status key to check
167
+ * @returns {boolean}
168
+ */
169
+ function isCompletedStatus(config, statusKey) {
170
+ const completed = (config.entities && config.entities.completedStatuses) || [];
171
+ return completed.includes(statusKey);
172
+ }
173
+
174
+ /**
175
+ * Get priority definitions.
176
+ * @param {object} config - Loaded config
177
+ * @returns {object[]}
178
+ */
179
+ function getPriorities(config) {
180
+ return (config.entities && config.entities.priorities) || [];
181
+ }
182
+
183
+ // --- Hierarchy API ---
184
+
185
+ /**
186
+ * Get the hierarchy tree from structure config.
187
+ * @param {object} config - Loaded config
188
+ * @returns {object} - Hierarchy tree
189
+ */
190
+ function getHierarchy(config) {
191
+ return (config.structure && config.structure.hierarchy) || {};
192
+ }
193
+
194
+ /**
195
+ * Get the project root directory name.
196
+ * @param {object} config - Loaded config
197
+ * @returns {string}
198
+ */
199
+ function getRoot(config) {
200
+ return (config.structure && config.structure.root) || 'project';
201
+ }
202
+
203
+ /**
204
+ * Flatten the hierarchy into an ordered array of { type, dir, depth, parent }.
205
+ * @param {object} config - Loaded config
206
+ * @returns {object[]}
207
+ */
208
+ function flattenHierarchy(config) {
209
+ const result = [];
210
+
211
+ function walk(node, depth, parentType) {
212
+ for (const [type, def] of Object.entries(node)) {
213
+ result.push({ type, dir: def.dir, depth, parent: parentType });
214
+ if (def.children) {
215
+ walk(def.children, depth + 1, type);
216
+ }
217
+ }
218
+ }
219
+
220
+ walk(getHierarchy(config), 0, null);
75
221
 
76
- if (explicitPath) {
77
- candidates.push(path.resolve(explicitPath));
222
+ // Also add standalone entities
223
+ const standalone = (config.structure && config.structure.standalone) || {};
224
+ for (const [type, def] of Object.entries(standalone)) {
225
+ result.push({ type, dir: def.dir, depth: 0, parent: null, standalone: true });
78
226
  }
79
227
 
228
+ return result;
229
+ }
230
+
231
+ /**
232
+ * Get the parent type for an entity type.
233
+ * @param {object} config - Loaded config
234
+ * @param {string} type - Entity type
235
+ * @returns {string|null}
236
+ */
237
+ function getParent(config, type) {
238
+ const flat = flattenHierarchy(config);
239
+ const entry = flat.find(e => e.type === type);
240
+ return entry ? entry.parent : null;
241
+ }
242
+
243
+ /**
244
+ * Get the direct children types for an entity type.
245
+ * @param {object} config - Loaded config
246
+ * @param {string} type - Entity type
247
+ * @returns {string[]}
248
+ */
249
+ function getChildren(config, type) {
250
+ const flat = flattenHierarchy(config);
251
+ return flat.filter(e => e.parent === type).map(e => e.type);
252
+ }
253
+
254
+ /**
255
+ * Get all ancestor types for an entity type (from immediate parent to root).
256
+ * @param {object} config - Loaded config
257
+ * @param {string} type - Entity type
258
+ * @returns {string[]}
259
+ */
260
+ function getAncestors(config, type) {
261
+ const ancestors = [];
262
+ let current = type;
263
+ while (true) {
264
+ const parent = getParent(config, current);
265
+ if (!parent) break;
266
+ ancestors.push(parent);
267
+ current = parent;
268
+ }
269
+ return ancestors;
270
+ }
271
+
272
+ /**
273
+ * Resolve the filesystem path for an entity instance.
274
+ *
275
+ * @param {object} config - Loaded config
276
+ * @param {string} type - Entity type to resolve
277
+ * @param {object} ids - Map of entity type → slug/id for each ancestor
278
+ * e.g. { cycle: "cycle-01", bet: "config-engine", scope: "schema" }
279
+ * @returns {string} - Resolved path relative to project root
280
+ *
281
+ * Example for task in shape-up:
282
+ * resolveEntityPath(config, "task", { cycle: "cycle-01", bet: "config-engine", scope: "schema" })
283
+ * → "cycles/cycle-01/bets/config-engine/scopes/schema/tasks"
284
+ */
285
+ function resolveEntityPath(config, type, ids) {
286
+ ids = ids || {};
287
+ const root = getRoot(config);
288
+
289
+ // Check standalone first
290
+ const standalone = (config.structure && config.structure.standalone) || {};
291
+ if (standalone[type]) {
292
+ return path.join(root, standalone[type].dir);
293
+ }
294
+
295
+ // Walk the hierarchy to build the path
296
+ const segments = [root];
297
+
298
+ function walk(node, targetType) {
299
+ for (const [nodeType, def] of Object.entries(node)) {
300
+ segments.push(def.dir);
301
+ if (ids[nodeType]) {
302
+ segments.push(ids[nodeType]);
303
+ }
304
+
305
+ if (nodeType === targetType) {
306
+ return true;
307
+ }
308
+
309
+ if (def.children) {
310
+ if (walk(def.children, targetType)) {
311
+ return true;
312
+ }
313
+ }
314
+
315
+ // Backtrack
316
+ if (ids[nodeType]) segments.pop();
317
+ segments.pop();
318
+ }
319
+ return false;
320
+ }
321
+
322
+ walk(getHierarchy(config), type);
323
+ return segments.join('/');
324
+ }
325
+
326
+ /**
327
+ * Resolve the full file path for a new entity instance.
328
+ *
329
+ * @param {object} config - Loaded config
330
+ * @param {string} type - Entity type
331
+ * @param {string} slug - Slug for the new entity
332
+ * @param {object} ids - Ancestor ids map
333
+ * @returns {string} - Full relative file path
334
+ */
335
+ function resolveEntityFile(config, type, slug, ids) {
336
+ const entity = getEntity(config, type);
337
+ if (!entity) return null;
338
+
339
+ const dirPath = resolveEntityPath(config, type, ids);
340
+ const filePattern = entity.file || 'README.md';
341
+
342
+ if (filePattern === 'README.md') {
343
+ // Directory-based entity: create dir/slug/README.md
344
+ return path.join(dirPath, slug, 'README.md');
345
+ }
346
+
347
+ if (filePattern === 'PREFIX-NNN.md') {
348
+ // File-based entity: create dir/PREFIX-NNN.md (caller resolves NNN)
349
+ return dirPath; // Return the directory; caller adds the filename
350
+ }
351
+
352
+ return path.join(dirPath, filePattern);
353
+ }
354
+
355
+ /**
356
+ * Check if an entity type is standalone (not in hierarchy).
357
+ * @param {object} config - Loaded config
358
+ * @param {string} type - Entity type
359
+ * @returns {boolean}
360
+ */
361
+ function isStandalone(config, type) {
362
+ const standalone = (config.structure && config.structure.standalone) || {};
363
+ return !!standalone[type];
364
+ }
365
+
366
+ // --- Ref resolution ---
367
+
368
+ /**
369
+ * Get all ref fields for an entity type (fields that reference other entities).
370
+ * @param {object} config - Loaded config
371
+ * @param {string} type - Entity type
372
+ * @returns {object[]} - Array of { fieldName, targetEntity, multiple }
373
+ */
374
+ function getRefFields(config, type) {
375
+ const fields = getFields(config, type);
376
+ const refs = [];
377
+ for (const [name, def] of Object.entries(fields)) {
378
+ if (def.type === 'ref') {
379
+ refs.push({ fieldName: name, targetEntity: def.entity, multiple: !!def.multiple });
380
+ }
381
+ }
382
+ return refs;
383
+ }
384
+
385
+ /**
386
+ * Get all filterable fields for an entity type (enums + refs + ancestors).
387
+ * Used by API autoFilters and UI auto filters.
388
+ * @param {object} config - Loaded config
389
+ * @param {string} type - Entity type
390
+ * @returns {string[]} - Field names that can be used as filters
391
+ */
392
+ function getFilterableFields(config, type) {
393
+ const fields = getFields(config, type);
394
+ const filters = [];
395
+
396
+ for (const [name, def] of Object.entries(fields)) {
397
+ if (def.type === 'enum' || def.type === 'ref') {
398
+ filters.push(name);
399
+ }
400
+ }
401
+
402
+ // Add ancestor types as implicit filters
403
+ const ancestors = getAncestors(config, type);
404
+ for (const ancestor of ancestors) {
405
+ if (!filters.includes(ancestor)) {
406
+ filters.push(ancestor);
407
+ }
408
+ }
409
+
410
+ return filters;
411
+ }
412
+
413
+ // --- CLI helpers ---
414
+
415
+ /**
416
+ * Get CLI-available entities for a command.
417
+ * @param {object} config - Loaded config
418
+ * @param {string} command - Command name (create, update, delete)
419
+ * @returns {string[]}
420
+ */
421
+ function getCliEntities(config, command) {
422
+ const cmd = config.cli && config.cli.commands && config.cli.commands[command];
423
+ return cmd ? cmd.entities || [] : [];
424
+ }
425
+
426
+ /**
427
+ * Generate CLI flags for an entity type.
428
+ * Returns auto-generated flags from entity fields + ancestor flags from hierarchy.
429
+ * @param {object} config - Loaded config
430
+ * @param {string} type - Entity type
431
+ * @returns {object[]} - Array of { flag, field, type, required, values }
432
+ */
433
+ function getCliFlags(config, type) {
434
+ const flags = [];
435
+ const fields = getFields(config, type);
436
+
437
+ // Flags from entity fields
438
+ for (const [name, def] of Object.entries(fields)) {
439
+ const flag = '--' + name.replace(/_/g, '-');
440
+ flags.push({
441
+ flag,
442
+ field: name,
443
+ type: def.type,
444
+ label: def.label || name,
445
+ values: def.type === 'enum' ? def.values.map(v => v.key) : undefined
446
+ });
447
+ }
448
+
449
+ // Flags from parent entities in hierarchy
450
+ const ancestors = getAncestors(config, type);
451
+ for (const ancestor of ancestors) {
452
+ const flag = '--' + ancestor.replace(/_/g, '-');
453
+ flags.push({
454
+ flag,
455
+ field: ancestor,
456
+ type: 'parent-ref',
457
+ label: getEntity(config, ancestor).singular,
458
+ required: true
459
+ });
460
+ }
461
+
462
+ return flags;
463
+ }
464
+
465
+ // --- UI helpers ---
466
+
467
+ /**
468
+ * Get UI tab definitions.
469
+ * @param {object} config - Loaded config
470
+ * @returns {object[]}
471
+ */
472
+ function getUiTabs(config) {
473
+ return (config.ui && config.ui.tabs) || [];
474
+ }
475
+
476
+ /**
477
+ * Get board columns (status values) for a board view entity.
478
+ * @param {object} config - Loaded config
479
+ * @param {string} type - Entity type displayed in board
480
+ * @returns {object[]}
481
+ */
482
+ function getBoardColumns(config, type) {
483
+ const statuses = getStatuses(config, type);
484
+ if (!statuses) return [];
485
+ return statuses.filter(s => !isCompletedStatus(config, s.key) || s.key === 'done');
486
+ }
487
+
488
+ // --- Theme ---
489
+
490
+ function resolveTheme(projectDir, explicitPath) {
491
+ const candidates = [];
492
+ if (explicitPath) candidates.push(path.join(path.resolve(explicitPath), 'mdboard.json'));
80
493
  if (projectDir) {
81
494
  candidates.push(path.join(projectDir, 'project', 'mdboard.json'));
82
495
  candidates.push(path.join(projectDir, 'mdboard.json'));
83
496
  }
84
-
85
- const globalPath = path.join(os.homedir(), '.config', 'mdboard', 'mdboard.json');
86
- candidates.push(globalPath);
497
+ candidates.push(path.join(os.homedir(), '.config', 'mdboard', 'mdboard.json'));
87
498
 
88
499
  for (const p of candidates) {
89
500
  const data = tryReadJson(p);
90
- if (data && data.theme) {
91
- return data.theme;
92
- }
501
+ if (data && data.theme) return data.theme;
93
502
  }
94
-
95
503
  return null;
96
504
  }
97
505
 
98
- module.exports = { loadConfig, deepMerge, resolveTheme };
506
+ // --- Export ---
507
+
508
+ module.exports = {
509
+ // Loader
510
+ loadConfig,
511
+ deepMerge,
512
+
513
+ // Entity API
514
+ getEntity,
515
+ getEntityTypes,
516
+ getFields,
517
+ getStatuses,
518
+ isCompletedStatus,
519
+ getPriorities,
520
+
521
+ // Hierarchy API
522
+ getHierarchy,
523
+ getRoot,
524
+ flattenHierarchy,
525
+ getParent,
526
+ getChildren,
527
+ getAncestors,
528
+ resolveEntityPath,
529
+ resolveEntityFile,
530
+ isStandalone,
531
+
532
+ // Ref & filter API
533
+ getRefFields,
534
+ getFilterableFields,
535
+
536
+ // CLI helpers
537
+ getCliEntities,
538
+ getCliFlags,
539
+
540
+ // UI helpers
541
+ getUiTabs,
542
+ getBoardColumns,
543
+
544
+ // Theme
545
+ resolveTheme,
546
+
547
+ // Constants
548
+ PRESETS_DIR,
549
+ CONFIG_FILES,
550
+ DEFAULT_PRESET,
551
+ };
@@ -127,4 +127,20 @@ function removeFromHistory(projectPath) {
127
127
  writeHistory(entries);
128
128
  }
129
129
 
130
- module.exports = { readHistory, registerProject, getHistory, isValidProject, removeFromHistory };
130
+ /**
131
+ * Remove history entries whose directories no longer exist on disk.
132
+ * @returns {{ removed: string[], kept: number }}
133
+ */
134
+ function pruneHistory() {
135
+ const entries = readHistory();
136
+ const removed = [];
137
+ const kept = entries.filter(e => {
138
+ if (fs.existsSync(e.path)) return true;
139
+ removed.push(e.path);
140
+ return false;
141
+ });
142
+ if (removed.length > 0) writeHistory(kept);
143
+ return { removed, kept: kept.length };
144
+ }
145
+
146
+ module.exports = { readHistory, registerProject, getHistory, isValidProject, removeFromHistory, pruneHistory };