mdboard 1.0.0 → 1.2.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/init.js CHANGED
@@ -2,11 +2,13 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const { loadConfig } = require('./config');
5
6
 
6
7
  const cwd = process.cwd();
7
8
  const projectPath = path.join(cwd, 'project');
8
9
  const projectName = path.basename(cwd);
9
10
  const today = new Date().toISOString().split('T')[0];
11
+ const config = loadConfig(cwd);
10
12
 
11
13
  if (fs.existsSync(projectPath)) {
12
14
  console.log(`\n project/ already exists at ${projectPath}`);
@@ -14,9 +16,11 @@ if (fs.existsSync(projectPath)) {
14
16
  process.exit(0);
15
17
  }
16
18
 
19
+ const msDir = config.entities.milestone.dir;
20
+
17
21
  // Create directories
18
22
  fs.mkdirSync(projectPath, { recursive: true });
19
- fs.mkdirSync(path.join(projectPath, 'milestones'), { recursive: true });
23
+ fs.mkdirSync(path.join(projectPath, msDir), { recursive: true });
20
24
  fs.mkdirSync(path.join(projectPath, 'archive'), { recursive: true });
21
25
 
22
26
  // PROJECT.md
@@ -52,17 +56,54 @@ updated: ${today}
52
56
 
53
57
  `, 'utf-8');
54
58
 
59
+ // mdboard.json — example config
60
+ fs.writeFileSync(path.join(projectPath, 'mdboard.json'), `{
61
+ "entities": {
62
+ "task": {
63
+ "singular": "Task",
64
+ "plural": "Tasks",
65
+ "prefix": "TASK"
66
+ }
67
+ }
68
+ }
69
+ `, 'utf-8');
70
+
71
+ // mdboard.css — example custom theme
72
+ fs.writeFileSync(path.join(projectPath, 'mdboard.css'), `/* mdboard custom theme
73
+ Override any CSS variable from the dashboard.
74
+ Example:
75
+ :root {
76
+ --bg: #FFFFFF;
77
+ --surface: #F8F9FA;
78
+ --text: #212529;
79
+ --accent: #0969DA;
80
+ }
81
+ */
82
+ `, 'utf-8');
83
+
55
84
  console.log(`
56
85
  mdboard init — project scaffolded
57
86
 
58
87
  Created:
59
88
  project/PROJECT.md
60
89
  project/metrics.md
61
- project/milestones/
90
+ project/${msDir}/
62
91
  project/archive/
92
+ project/mdboard.json
93
+ project/mdboard.css
63
94
 
64
95
  Next steps:
65
96
  1. Edit project/PROJECT.md with your project details
66
- 2. Create milestones and epics under project/milestones/
67
- 3. Run \`mdboard\` to start the dashboard
97
+ 2. Create milestones and epics under project/${msDir}/
98
+ 3. Customize project/mdboard.json for your workflow
99
+ 4. Run \`mdboard\` to start the dashboard
100
+
101
+ Multi-repo workspace:
102
+ Create a workspace.json to manage multiple repos:
103
+ {
104
+ "sources": [
105
+ { "name": "api", "path": "../api-repo", "icon": "🔧" },
106
+ { "name": "web", "path": "../web-repo", "icon": "🌐" }
107
+ ]
108
+ }
68
109
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mdboard",
3
- "version": "1.0.0",
3
+ "version": "1.2.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
5
  "main": "./server.js",
6
6
  "bin": {
@@ -10,7 +10,14 @@
10
10
  "bin.js",
11
11
  "server.js",
12
12
  "index.html",
13
- "init.js"
13
+ "init.js",
14
+ "config.js",
15
+ "defaults.json",
16
+ "scanner.js",
17
+ "watcher.js",
18
+ "api.js",
19
+ "yaml.js",
20
+ "workspace.js"
14
21
  ],
15
22
  "keywords": [
16
23
  "project-management",
package/scanner.js ADDED
@@ -0,0 +1,491 @@
1
+ /**
2
+ * mdboard — File scanner and in-memory model
3
+ *
4
+ * Scans project directories for markdown files and builds
5
+ * the in-memory model. Supports multi-source scanning.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { parseFrontmatter, serializeYaml } = require('./yaml');
11
+
12
+ function createModel() {
13
+ return {
14
+ project: null,
15
+ milestones: [],
16
+ epics: [],
17
+ tasks: [],
18
+ sprints: [],
19
+ boards: [],
20
+ reviews: [],
21
+ metrics: null,
22
+ };
23
+ }
24
+
25
+ function safeReadFile(filePath) {
26
+ try {
27
+ return fs.readFileSync(filePath, 'utf-8');
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function safeDirEntries(dir) {
34
+ try {
35
+ return fs.readdirSync(dir).filter(e => !e.startsWith('.'));
36
+ } catch {
37
+ return [];
38
+ }
39
+ }
40
+
41
+ function isTaskFile(filename, config) {
42
+ if (!filename.endsWith('.md')) return false;
43
+ const prefix = config.entities.task.prefix;
44
+ if (filename.startsWith(prefix + '-')) return true;
45
+ const legacy = config.entities.task.legacyPrefixes || [];
46
+ for (const lp of legacy) {
47
+ if (filename.startsWith(lp + '-')) return true;
48
+ }
49
+ return false;
50
+ }
51
+
52
+ /**
53
+ * Scan a single source directory and return all items found.
54
+ *
55
+ * @param {string} sourcePath - Absolute path to the project/ directory
56
+ * @param {object} config - mdboard config object
57
+ * @param {object} sourceMeta - { name, label, color, type, readonly }
58
+ * @returns {object} - { project, milestones, epics, tasks, sprints, boards, reviews, metrics }
59
+ */
60
+ function scanSource(sourcePath, config, sourceMeta) {
61
+ const result = {
62
+ project: null,
63
+ milestones: [],
64
+ epics: [],
65
+ tasks: [],
66
+ sprints: [],
67
+ boards: [],
68
+ reviews: [],
69
+ metrics: null,
70
+ };
71
+
72
+ if (!fs.existsSync(sourcePath)) return result;
73
+
74
+ const meta = sourceMeta || {};
75
+ const sourcePrefix = meta.name ? meta.name + ':' : '';
76
+
77
+ function tag(item) {
78
+ if (meta.name) {
79
+ item._source = meta.name;
80
+ item._sourceLabel = meta.label || meta.name;
81
+ item._sourceColor = meta.color || null;
82
+ item._sourceType = meta.type || 'local';
83
+ item._readonly = meta.readonly || false;
84
+ }
85
+ return item;
86
+ }
87
+
88
+ function qualifyId(item) {
89
+ if (sourcePrefix && item.id) {
90
+ item._originalId = item.id;
91
+ item.id = sourcePrefix + item.id;
92
+ }
93
+ return item;
94
+ }
95
+
96
+ const projectMd = safeReadFile(path.join(sourcePath, 'PROJECT.md'));
97
+ if (projectMd) {
98
+ const parsed = parseFrontmatter(projectMd);
99
+ result.project = tag({ ...parsed.frontmatter, content: parsed.content, _file: 'PROJECT.md' });
100
+ }
101
+
102
+ const metricsMd = safeReadFile(path.join(sourcePath, 'metrics.md'));
103
+ if (metricsMd) {
104
+ const parsed = parseFrontmatter(metricsMd);
105
+ result.metrics = tag({ ...parsed.frontmatter, content: parsed.content, _file: 'metrics.md' });
106
+ }
107
+
108
+ const msDir = config.entities.milestone.dir;
109
+ const epicDir = config.entities.epic.dir;
110
+ const taskDir = config.entities.task.dir;
111
+ const sprintDir = config.entities.sprint.dir;
112
+
113
+ const milestonesDir = path.join(sourcePath, msDir);
114
+ if (fs.existsSync(milestonesDir)) {
115
+ for (const ms of safeDirEntries(milestonesDir)) {
116
+ const msPath = path.join(milestonesDir, ms);
117
+ if (!fs.statSync(msPath).isDirectory()) continue;
118
+
119
+ const msReadme = safeReadFile(path.join(msPath, 'README.md'));
120
+ if (msReadme) {
121
+ const parsed = parseFrontmatter(msReadme);
122
+ result.milestones.push(qualifyId(tag({
123
+ ...parsed.frontmatter, content: parsed.content,
124
+ _dir: ms, _file: `${msDir}/${ms}/README.md`
125
+ })));
126
+ }
127
+
128
+ const epicsDir = path.join(msPath, epicDir);
129
+ if (fs.existsSync(epicsDir)) {
130
+ for (const epic of safeDirEntries(epicsDir)) {
131
+ const epicPath = path.join(epicsDir, epic);
132
+ if (!fs.statSync(epicPath).isDirectory()) continue;
133
+
134
+ const epicReadme = safeReadFile(path.join(epicPath, 'README.md'));
135
+ if (epicReadme) {
136
+ const parsed = parseFrontmatter(epicReadme);
137
+ result.epics.push(qualifyId(tag({
138
+ ...parsed.frontmatter, content: parsed.content,
139
+ _dir: epic, _milestone: ms,
140
+ _file: `${msDir}/${ms}/${epicDir}/${epic}/README.md`
141
+ })));
142
+ }
143
+
144
+ const backlogDir = path.join(epicPath, taskDir);
145
+ if (fs.existsSync(backlogDir)) {
146
+ for (const feat of safeDirEntries(backlogDir)) {
147
+ if (!isTaskFile(feat, config)) continue;
148
+
149
+ const featContent = safeReadFile(path.join(backlogDir, feat));
150
+ if (featContent) {
151
+ const parsed = parseFrontmatter(featContent);
152
+ result.tasks.push(qualifyId(tag({
153
+ ...parsed.frontmatter, content: parsed.content,
154
+ _filename: feat, _epic: epic, _milestone: ms,
155
+ _file: `${msDir}/${ms}/${epicDir}/${epic}/${taskDir}/${feat}`,
156
+ })));
157
+ }
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ const sprintsDir = path.join(msPath, sprintDir);
164
+ if (fs.existsSync(sprintsDir)) {
165
+ for (const sp of safeDirEntries(sprintsDir)) {
166
+ const spPath = path.join(sprintsDir, sp);
167
+ if (!fs.statSync(spPath).isDirectory()) continue;
168
+
169
+ const planMd = safeReadFile(path.join(spPath, 'plan.md'));
170
+ if (planMd) {
171
+ const parsed = parseFrontmatter(planMd);
172
+ result.sprints.push(qualifyId(tag({
173
+ ...parsed.frontmatter, content: parsed.content,
174
+ _dir: sp, _milestone: ms,
175
+ _file: `${msDir}/${ms}/${sprintDir}/${sp}/plan.md`
176
+ })));
177
+ }
178
+
179
+ const boardMd = safeReadFile(path.join(spPath, 'board.md'));
180
+ if (boardMd) {
181
+ const parsed = parseFrontmatter(boardMd);
182
+ result.boards.push(tag({
183
+ ...parsed.frontmatter, content: parsed.content,
184
+ _dir: sp, _milestone: ms,
185
+ _file: `${msDir}/${ms}/${sprintDir}/${sp}/board.md`
186
+ }));
187
+ }
188
+
189
+ const reviewMd = safeReadFile(path.join(spPath, 'review.md'));
190
+ if (reviewMd) {
191
+ const parsed = parseFrontmatter(reviewMd);
192
+ result.reviews.push(tag({
193
+ ...parsed.frontmatter, content: parsed.content,
194
+ _dir: sp, _milestone: ms,
195
+ _file: `${msDir}/${ms}/${sprintDir}/${sp}/review.md`
196
+ }));
197
+ }
198
+ }
199
+ }
200
+ }
201
+ }
202
+
203
+ return result;
204
+ }
205
+
206
+ function computeProgress(model, completedStatus) {
207
+ for (const epic of model.epics) {
208
+ const epicTasks = model.tasks.filter(f =>
209
+ f._epic === epic._dir && f._milestone === epic._milestone &&
210
+ (!epic._source || f._source === epic._source)
211
+ );
212
+ const done = epicTasks.filter(f => f.status === completedStatus).length;
213
+ epic._featureCount = epicTasks.length;
214
+ epic._completedCount = done;
215
+ epic._progress = epicTasks.length > 0 ? Math.round((done / epicTasks.length) * 100) : 0;
216
+ epic._totalPoints = epicTasks.reduce((sum, f) => sum + (f.points || 0), 0);
217
+ }
218
+
219
+ for (const ms of model.milestones) {
220
+ const msTasks = model.tasks.filter(f =>
221
+ f._milestone === ms._dir &&
222
+ (!ms._source || f._source === ms._source)
223
+ );
224
+ const done = msTasks.filter(f => f.status === completedStatus).length;
225
+ ms._featureCount = msTasks.length;
226
+ ms._completedCount = done;
227
+ ms._progress = msTasks.length > 0 ? Math.round((done / msTasks.length) * 100) : 0;
228
+ }
229
+ }
230
+
231
+ function updateMarkdownFile(projectPath, relFile, updates) {
232
+ const filePath = path.join(projectPath, relFile);
233
+ if (!fs.existsSync(filePath)) throw new Error('File not found: ' + relFile);
234
+
235
+ const raw = fs.readFileSync(filePath, 'utf-8');
236
+ const parsed = parseFrontmatter(raw);
237
+
238
+ const newFm = { ...parsed.frontmatter };
239
+ let body = parsed.content;
240
+
241
+ for (const [k, v] of Object.entries(updates)) {
242
+ if (k === 'content') { body = v; continue; }
243
+ if (k.startsWith('_')) continue;
244
+ newFm[k] = v;
245
+ }
246
+
247
+ const yaml = serializeYaml(newFm);
248
+ fs.writeFileSync(filePath, '---\n' + yaml + '\n---\n\n' + (body || '') + '\n', 'utf-8');
249
+ }
250
+
251
+ /**
252
+ * Merge results from multiple scanSource calls into a single model.
253
+ */
254
+ function mergeResults(model, results) {
255
+ for (const r of results) {
256
+ if (r.project && !model.project) model.project = r.project;
257
+ model.milestones.push(...r.milestones);
258
+ model.epics.push(...r.epics);
259
+ model.tasks.push(...r.tasks);
260
+ model.sprints.push(...r.sprints);
261
+ model.boards.push(...r.boards);
262
+ model.reviews.push(...r.reviews);
263
+ if (r.metrics && !model.metrics) model.metrics = r.metrics;
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Get the next auto-incremented ID for an entity type.
269
+ *
270
+ * @param {object[]} items - Existing items array
271
+ * @param {string} prefix - ID prefix (e.g. "TASK", "MS", "EPIC", "SP")
272
+ * @returns {string} - Next ID like "TASK-004"
273
+ */
274
+ function getNextId(items, prefix) {
275
+ let max = 0;
276
+ const re = new RegExp('^(?:[\\w]+:)?' + prefix + '-(\\d+)$');
277
+ for (const item of items) {
278
+ const id = item._originalId || item.id || '';
279
+ const m = id.match(re);
280
+ if (m) {
281
+ const n = parseInt(m[1], 10);
282
+ if (n > max) max = n;
283
+ }
284
+ }
285
+ return prefix + '-' + String(max + 1).padStart(3, '0');
286
+ }
287
+
288
+ /**
289
+ * Create a new task markdown file.
290
+ *
291
+ * @param {string} sourcePath - Absolute path to project/ dir
292
+ * @param {object} config - mdboard config
293
+ * @param {object} data - { title, status, priority, points, assigned, sprint, milestone, epic, content }
294
+ * @param {object[]} existingTasks - Current tasks for ID generation
295
+ * @returns {object} - { id, file }
296
+ */
297
+ function createTask(sourcePath, config, data, existingTasks) {
298
+ const prefix = config.entities.task.prefix;
299
+ const id = getNextId(existingTasks, prefix);
300
+ const filename = id + '.md';
301
+
302
+ const msDir = config.entities.milestone.dir;
303
+ const epicDir = config.entities.epic.dir;
304
+ const taskDir = config.entities.task.dir;
305
+
306
+ if (!data.milestone || !data.epic) {
307
+ throw new Error('milestone and epic are required to create a task');
308
+ }
309
+
310
+ const dir = path.join(sourcePath, msDir, data.milestone, epicDir, data.epic, taskDir);
311
+ if (!fs.existsSync(dir)) {
312
+ fs.mkdirSync(dir, { recursive: true });
313
+ }
314
+
315
+ const fm = {
316
+ id: id,
317
+ title: data.title || 'New Task',
318
+ status: data.status || 'backlog',
319
+ };
320
+ if (data.priority) fm.priority = data.priority;
321
+ if (data.points != null) fm.points = data.points;
322
+ if (data.assigned) fm.assigned = data.assigned;
323
+ if (data.sprint) fm.sprint = data.sprint;
324
+ if (data.links) fm.links = data.links;
325
+ fm.created = new Date().toISOString().split('T')[0];
326
+
327
+ const yaml = serializeYaml(fm);
328
+ const content = data.content || '';
329
+ const filePath = path.join(dir, filename);
330
+ fs.writeFileSync(filePath, '---\n' + yaml + '\n---\n\n' + content + '\n', 'utf-8');
331
+
332
+ const relFile = `${msDir}/${data.milestone}/${epicDir}/${data.epic}/${taskDir}/${filename}`;
333
+ return { id, file: relFile };
334
+ }
335
+
336
+ /**
337
+ * Create a new milestone directory with README.md.
338
+ *
339
+ * @param {string} sourcePath - Absolute path to project/ dir
340
+ * @param {object} config - mdboard config
341
+ * @param {object} data - { title, status, deadline, content, tracks }
342
+ * @param {object[]} existingMilestones - Current milestones for ID generation
343
+ * @returns {object} - { id, dir, file }
344
+ */
345
+ function createMilestone(sourcePath, config, data, existingMilestones) {
346
+ const id = getNextId(existingMilestones, 'MS');
347
+ const slug = (data.title || id).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
348
+ const msDir = config.entities.milestone.dir;
349
+ const dirPath = path.join(sourcePath, msDir, slug);
350
+
351
+ if (fs.existsSync(dirPath)) {
352
+ throw new Error('Milestone directory already exists: ' + slug);
353
+ }
354
+ fs.mkdirSync(dirPath, { recursive: true });
355
+
356
+ const fm = {
357
+ id: id,
358
+ title: data.title || 'New Milestone',
359
+ status: data.status || 'planned',
360
+ };
361
+ if (data.deadline) fm.deadline = data.deadline;
362
+ if (data.tracks) fm.tracks = data.tracks;
363
+ fm.created = new Date().toISOString().split('T')[0];
364
+
365
+ const yaml = serializeYaml(fm);
366
+ const content = data.content || '';
367
+ const filePath = path.join(dirPath, 'README.md');
368
+ fs.writeFileSync(filePath, '---\n' + yaml + '\n---\n\n' + content + '\n', 'utf-8');
369
+
370
+ const relFile = `${msDir}/${slug}/README.md`;
371
+ return { id, dir: slug, file: relFile };
372
+ }
373
+
374
+ /**
375
+ * Create a new epic directory with README.md and backlog/.
376
+ *
377
+ * @param {string} sourcePath - Absolute path to project/ dir
378
+ * @param {object} config - mdboard config
379
+ * @param {object} data - { title, status, priority, milestone, content }
380
+ * @param {object[]} existingEpics - Current epics for ID generation
381
+ * @returns {object} - { id, dir, file }
382
+ */
383
+ function createEpic(sourcePath, config, data, existingEpics) {
384
+ if (!data.milestone) throw new Error('milestone is required to create an epic');
385
+
386
+ const id = getNextId(existingEpics, 'EPIC');
387
+ const slug = (data.title || id).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
388
+
389
+ const msDir = config.entities.milestone.dir;
390
+ const epicDir = config.entities.epic.dir;
391
+ const taskDir = config.entities.task.dir;
392
+
393
+ const dirPath = path.join(sourcePath, msDir, data.milestone, epicDir, slug);
394
+ if (fs.existsSync(dirPath)) {
395
+ throw new Error('Epic directory already exists: ' + slug);
396
+ }
397
+ fs.mkdirSync(dirPath, { recursive: true });
398
+ fs.mkdirSync(path.join(dirPath, taskDir), { recursive: true });
399
+
400
+ const fm = {
401
+ id: id,
402
+ title: data.title || 'New Epic',
403
+ status: data.status || 'active',
404
+ };
405
+ if (data.priority) fm.priority = data.priority;
406
+ if (data.dependencies) fm.dependencies = data.dependencies;
407
+ fm.created = new Date().toISOString().split('T')[0];
408
+
409
+ const yaml = serializeYaml(fm);
410
+ const content = data.content || '';
411
+ const filePath = path.join(dirPath, 'README.md');
412
+ fs.writeFileSync(filePath, '---\n' + yaml + '\n---\n\n' + content + '\n', 'utf-8');
413
+
414
+ const relFile = `${msDir}/${data.milestone}/${epicDir}/${slug}/README.md`;
415
+ return { id, dir: slug, file: relFile };
416
+ }
417
+
418
+ /**
419
+ * Create a new sprint directory with plan.md.
420
+ *
421
+ * @param {string} sourcePath - Absolute path to project/ dir
422
+ * @param {object} config - mdboard config
423
+ * @param {object} data - { goal, status, start_date, end_date, milestone, planned_points, features, content }
424
+ * @param {object[]} existingSprints - Current sprints for ID generation
425
+ * @returns {object} - { id, dir, file }
426
+ */
427
+ function createSprint(sourcePath, config, data, existingSprints) {
428
+ if (!data.milestone) throw new Error('milestone is required to create a sprint');
429
+
430
+ const id = getNextId(existingSprints, 'SP');
431
+ const slug = id.toLowerCase();
432
+
433
+ const msDir = config.entities.milestone.dir;
434
+ const sprintDir = config.entities.sprint.dir;
435
+
436
+ const dirPath = path.join(sourcePath, msDir, data.milestone, sprintDir, slug);
437
+ if (fs.existsSync(dirPath)) {
438
+ throw new Error('Sprint directory already exists: ' + slug);
439
+ }
440
+ fs.mkdirSync(dirPath, { recursive: true });
441
+
442
+ const fm = {
443
+ id: id,
444
+ status: data.status || 'planned',
445
+ };
446
+ if (data.goal) fm.goal = data.goal;
447
+ if (data.start_date) fm.start_date = data.start_date;
448
+ if (data.end_date) fm.end_date = data.end_date;
449
+ if (data.planned_points != null) fm.planned_points = data.planned_points;
450
+ if (data.features) fm.features = data.features;
451
+ fm.created = new Date().toISOString().split('T')[0];
452
+
453
+ const yaml = serializeYaml(fm);
454
+ const content = data.content || '';
455
+ const filePath = path.join(dirPath, 'plan.md');
456
+ fs.writeFileSync(filePath, '---\n' + yaml + '\n---\n\n' + content + '\n', 'utf-8');
457
+
458
+ const relFile = `${msDir}/${data.milestone}/${sprintDir}/${slug}/plan.md`;
459
+ return { id, dir: slug, file: relFile };
460
+ }
461
+
462
+ /**
463
+ * Archive an item by moving its file/directory to project/archive/.
464
+ *
465
+ * @param {string} sourcePath - Absolute path to project/ dir
466
+ * @param {object} item - The item with _file property
467
+ * @returns {boolean}
468
+ */
469
+ function archiveItem(sourcePath, item) {
470
+ if (!item || !item._file) throw new Error('Item has no file path');
471
+
472
+ const srcPath = path.join(sourcePath, item._file);
473
+ if (!fs.existsSync(srcPath)) throw new Error('Source file not found: ' + item._file);
474
+
475
+ const archiveDir = path.join(sourcePath, 'archive');
476
+ fs.mkdirSync(archiveDir, { recursive: true });
477
+
478
+ const basename = path.basename(srcPath);
479
+ const timestamp = Date.now();
480
+ const destName = timestamp + '-' + basename;
481
+ const destPath = path.join(archiveDir, destName);
482
+
483
+ fs.renameSync(srcPath, destPath);
484
+ return true;
485
+ }
486
+
487
+ module.exports = {
488
+ createModel, scanSource, computeProgress, isTaskFile,
489
+ safeReadFile, safeDirEntries, updateMarkdownFile, mergeResults,
490
+ getNextId, createTask, createMilestone, createEpic, createSprint, archiveItem
491
+ };