mdboard 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/bin.js +130 -44
  2. package/index.html +3321 -1195
  3. package/package.json +10 -11
  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 +338 -0
  23. package/src/cli/config.js +234 -0
  24. package/src/cli/init.js +175 -0
  25. package/src/cli/preset.js +849 -0
  26. package/src/cli/skill.js +417 -0
  27. package/src/cli/status.js +180 -0
  28. package/src/core/config.js +551 -0
  29. package/src/core/history.js +146 -0
  30. package/src/core/scanner.js +521 -0
  31. package/{workspace.js → src/core/workspace.js} +0 -15
  32. package/{yaml.js → src/core/yaml.js} +5 -1
  33. package/src/server/api.js +616 -0
  34. package/{server.js → src/server/server.js} +180 -132
  35. package/{watcher.js → src/server/watcher.js} +40 -9
  36. package/api.js +0 -752
  37. package/config.js +0 -73
  38. package/defaults.json +0 -43
  39. package/init.js +0 -109
  40. package/scanner.js +0 -491
@@ -0,0 +1,551 @@
1
+ /**
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.
6
+ *
7
+ * Resolution order (highest priority first):
8
+ * 1. Explicit path via --config / MDBOARD_CONFIG
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".
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const os = require('os');
20
+
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 ---
26
+
27
+ function deepMerge(target, source) {
28
+ const result = { ...target };
29
+ for (const key of Object.keys(source)) {
30
+ const sv = source[key];
31
+ const tv = target[key];
32
+ if (sv && typeof sv === 'object' && !Array.isArray(sv) && tv && typeof tv === 'object' && !Array.isArray(tv)) {
33
+ result[key] = deepMerge(tv, sv);
34
+ } else {
35
+ result[key] = sv;
36
+ }
37
+ }
38
+ return result;
39
+ }
40
+
41
+ function tryReadJson(filePath) {
42
+ try {
43
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
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
+ */
58
+ function loadConfig(projectDir, explicitPath) {
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;
62
+
63
+ if (projectDir) {
64
+ const userMdboard = tryReadJson(path.join(projectDir, 'project', 'mdboard.json'));
65
+ if (userMdboard && userMdboard.preset) {
66
+ preset = userMdboard.preset;
67
+ }
68
+ }
69
+
70
+ const userEntities = projectDir
71
+ ? tryReadJson(path.join(projectDir, 'project', 'entities.json'))
72
+ : null;
73
+ if (userEntities && userEntities.methodology) {
74
+ preset = userEntities.methodology;
75
+ }
76
+
77
+ const presetDir = path.join(PRESETS_DIR, preset);
78
+ const config = { _preset: preset, _projectDir: projectDir };
79
+
80
+ for (const name of CONFIG_FILES) {
81
+ // Load preset base
82
+ const presetFile = tryReadJson(path.join(presetDir, name + '.json'));
83
+
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 || {};
108
+ }
109
+ config['_' + name + 'Path'] = userPath || path.join(presetDir, name + '.json');
110
+ }
111
+
112
+ // Also load legacy mdboard.json for theme resolution
113
+ config._theme = resolveTheme(projectDir, explicitPath);
114
+
115
+ return config;
116
+ }
117
+
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);
221
+
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 });
226
+ }
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'));
493
+ if (projectDir) {
494
+ candidates.push(path.join(projectDir, 'project', 'mdboard.json'));
495
+ candidates.push(path.join(projectDir, 'mdboard.json'));
496
+ }
497
+ candidates.push(path.join(os.homedir(), '.config', 'mdboard', 'mdboard.json'));
498
+
499
+ for (const p of candidates) {
500
+ const data = tryReadJson(p);
501
+ if (data && data.theme) return data.theme;
502
+ }
503
+ return null;
504
+ }
505
+
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
+ };
@@ -0,0 +1,146 @@
1
+ /**
2
+ * mdboard — Project History
3
+ *
4
+ * Persistent history of visited projects stored in
5
+ * ~/.config/mdboard/history.json. Allows switching between
6
+ * projects without restarting the server.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const os = require('os');
12
+
13
+ const CONFIG_DIR = path.join(os.homedir(), '.config', 'mdboard');
14
+ const HISTORY_FILE = path.join(CONFIG_DIR, 'history.json');
15
+ const MAX_ENTRIES = 50;
16
+
17
+ /**
18
+ * Read the history file.
19
+ * @returns {Array<{path:string, name:string, lastOpened:string}>}
20
+ */
21
+ function readHistory() {
22
+ try {
23
+ const raw = fs.readFileSync(HISTORY_FILE, 'utf-8');
24
+ const data = JSON.parse(raw);
25
+ return Array.isArray(data) ? data : [];
26
+ } catch {
27
+ return [];
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Write history to disk.
33
+ * @param {Array} entries
34
+ */
35
+ function writeHistory(entries) {
36
+ try {
37
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
38
+ fs.writeFileSync(HISTORY_FILE, JSON.stringify(entries, null, 2), 'utf-8');
39
+ } catch {
40
+ // Non-critical — silently ignore write failures
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Register (or update) a project in the history.
46
+ * Moves the entry to the front of the list.
47
+ *
48
+ * @param {string} projectDir - Absolute path to the project directory
49
+ * @param {string} name - Human-friendly project name
50
+ */
51
+ function registerProject(projectDir, name) {
52
+ const absPath = path.resolve(projectDir);
53
+ let entries = readHistory();
54
+
55
+ // Remove existing entry for this path
56
+ entries = entries.filter(e => e.path !== absPath);
57
+
58
+ // Prepend new entry
59
+ entries.unshift({
60
+ path: absPath,
61
+ name: name || path.basename(absPath),
62
+ lastOpened: new Date().toISOString(),
63
+ });
64
+
65
+ // Enforce limit
66
+ if (entries.length > MAX_ENTRIES) {
67
+ entries = entries.slice(0, MAX_ENTRIES);
68
+ }
69
+
70
+ writeHistory(entries);
71
+ }
72
+
73
+ /**
74
+ * Get the history list with a `isCurrent` flag.
75
+ *
76
+ * @param {string} currentProjectDir - The currently-active project dir
77
+ * @returns {Array<{path:string, name:string, lastOpened:string, isCurrent:boolean}>}
78
+ */
79
+ function getHistory(currentProjectDir) {
80
+ const absCurrentDir = currentProjectDir ? path.resolve(currentProjectDir) : null;
81
+ const entries = readHistory();
82
+
83
+ return entries.map(e => ({
84
+ ...e,
85
+ isCurrent: e.path === absCurrentDir,
86
+ }));
87
+ }
88
+
89
+ /**
90
+ * Detect whether a directory looks like a valid mdboard project.
91
+ * A project has either a `project/` subdirectory or a `workspace.json`
92
+ * with sources.
93
+ *
94
+ * @param {string} dir - Directory to check
95
+ * @returns {boolean}
96
+ */
97
+ function isValidProject(dir) {
98
+ try {
99
+ const absDir = path.resolve(dir);
100
+
101
+ // Check for project/ sub-directory
102
+ if (fs.existsSync(path.join(absDir, 'project'))) return true;
103
+
104
+ // Check for workspace.json with sources
105
+ const wsPath = path.join(absDir, 'workspace.json');
106
+ if (fs.existsSync(wsPath)) {
107
+ const raw = fs.readFileSync(wsPath, 'utf-8');
108
+ const ws = JSON.parse(raw);
109
+ if (ws && Array.isArray(ws.sources) && ws.sources.length > 0) return true;
110
+ }
111
+
112
+ return false;
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Remove an entry from the history.
120
+ *
121
+ * @param {string} projectPath - Absolute path to remove
122
+ */
123
+ function removeFromHistory(projectPath) {
124
+ const absPath = path.resolve(projectPath);
125
+ let entries = readHistory();
126
+ entries = entries.filter(e => e.path !== absPath);
127
+ writeHistory(entries);
128
+ }
129
+
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 };