dotmd-cli 0.69.0 → 0.70.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/README.md +144 -964
  2. package/bin/dotmd.mjs +238 -195
  3. package/dotmd.config.example.mjs +5 -8
  4. package/package.json +6 -10
  5. package/src/agent-context.mjs +132 -0
  6. package/src/atomic-mutation.mjs +1505 -0
  7. package/src/baton.mjs +109 -114
  8. package/src/bulk-tag.mjs +7 -7
  9. package/src/commands.mjs +326 -12
  10. package/src/completions.mjs +38 -98
  11. package/src/config.mjs +18 -3
  12. package/src/diff.mjs +7 -3
  13. package/src/doctor.mjs +12 -5
  14. package/src/export.mjs +154 -25
  15. package/src/fix-refs.mjs +2 -0
  16. package/src/frontmatter-fix.mjs +2 -0
  17. package/src/frontmatter.mjs +3 -2
  18. package/src/git.mjs +531 -14
  19. package/src/graph.mjs +53 -25
  20. package/src/guard.mjs +163 -60
  21. package/src/hud.mjs +65 -76
  22. package/src/index-file.mjs +28 -16
  23. package/src/index.mjs +17 -12
  24. package/src/init.mjs +1 -1
  25. package/src/journal.mjs +145 -12
  26. package/src/lifecycle.mjs +554 -282
  27. package/src/lint.mjs +3 -3
  28. package/src/managed-path.mjs +192 -0
  29. package/src/migrate-prompts.mjs +2 -0
  30. package/src/migrate-template.mjs +2 -0
  31. package/src/migrate.mjs +7 -1
  32. package/src/new.mjs +135 -54
  33. package/src/output-identity.mjs +106 -0
  34. package/src/pickup-card.mjs +24 -10
  35. package/src/pickup.mjs +457 -0
  36. package/src/prompts.mjs +134 -75
  37. package/src/query.mjs +22 -10
  38. package/src/reference-planner.mjs +292 -0
  39. package/src/rename.mjs +65 -73
  40. package/src/render.mjs +17 -8
  41. package/src/runlist.mjs +109 -71
  42. package/src/section.mjs +2 -1
  43. package/src/ship.mjs +39 -20
  44. package/src/stats.mjs +1 -1
  45. package/src/status-metadata.mjs +87 -0
  46. package/src/statuses.mjs +11 -26
  47. package/src/summary.mjs +14 -3
  48. package/src/update.mjs +38 -10
  49. package/src/use.mjs +4 -1
  50. package/src/util.mjs +1 -0
  51. package/src/validate.mjs +14 -6
  52. package/src/watch.mjs +6 -1
  53. package/src/notion.mjs +0 -528
package/src/doctor.mjs CHANGED
@@ -4,7 +4,7 @@ import { fixBrokenRefs } from './fix-refs.mjs';
4
4
  import { runLint } from './lint.mjs';
5
5
  import { runTouch } from './lifecycle.mjs';
6
6
  import { buildIndex, collectDocFiles } from './index.mjs';
7
- import { renderIndexFile, writeIndex } from './index-file.mjs';
7
+ import { writeRenderedIndex } from './index-file.mjs';
8
8
  import { renderCheck, renderManualFixes } from './render.mjs';
9
9
  import { bold, dim, green, yellow } from './color.mjs';
10
10
  import { checkClaudeCommands, removeGeneratedSlashCommands } from './claude-commands.mjs';
@@ -66,13 +66,20 @@ export function runDoctor(argv, config, opts = {}) {
66
66
  return;
67
67
  }
68
68
 
69
- const { dryRun } = opts;
69
+ const { dryRun, testHooks } = opts;
70
70
  // 0.37.0 (F4): the mode banner makes it impossible to mistake a preview run
71
71
  // for a real one — and tells the user the exact flag that flips it.
72
72
  const modeNote = dryRun
73
73
  ? dim('[preview — run with --apply to write]')
74
74
  : dim('[applying changes]');
75
75
  process.stdout.write(bold('dotmd doctor') + ' ' + modeNote + '\n\n');
76
+ if (dryRun) {
77
+ const skippedHooks = ['validate', 'transformDoc', 'formatSnapshot', 'renderCheck']
78
+ .filter(name => typeof config.hooks?.[name] === 'function');
79
+ if (skippedHooks.length > 0) {
80
+ process.stdout.write(dim(`[preview] Custom ${skippedHooks.join(', ')} hook${skippedHooks.length === 1 ? '' : 's'} skipped; diagnostics and rendering below use built-in behavior only.\n\n`));
81
+ }
82
+ }
76
83
 
77
84
  // Step 1: Fix broken references
78
85
  process.stdout.write(bold('1. Fixing broken references...') + '\n');
@@ -95,8 +102,7 @@ export function runDoctor(argv, config, opts = {}) {
95
102
  } else if (dryRun) {
96
103
  process.stdout.write('[dry-run] Would regenerate index.\n');
97
104
  } else {
98
- const index = buildIndex(config);
99
- writeIndex(renderIndexFile(index, config), config);
105
+ writeRenderedIndex(() => buildIndex(config, { fast: true }), config, { testHooks });
100
106
  process.stdout.write('Index updated.\n');
101
107
  }
102
108
 
@@ -126,7 +132,8 @@ export function runDoctor(argv, config, opts = {}) {
126
132
  }
127
133
 
128
134
  // Step 6: Show remaining check
129
- process.stdout.write('\n' + bold('6. Remaining issues:') + '\n');
135
+ const issueLabel = dryRun ? '6. Remaining issues in current tree (preview fixes above were not applied):' : '6. Remaining issues:';
136
+ process.stdout.write('\n' + bold(issueLabel) + '\n');
130
137
  const freshIndex = buildIndex(config);
131
138
  process.stdout.write(renderCheck(freshIndex, config));
132
139
  const manual = renderManualFixes(freshIndex);
package/src/export.mjs CHANGED
@@ -1,9 +1,10 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter } from './frontmatter.mjs';
4
4
  import { buildIndex } from './index.mjs';
5
5
  import { buildGraph } from './graph.mjs';
6
- import { resolveDocPath, toRepoPath, capitalize, die } from './util.mjs';
6
+ import { resolveDocPath, resolveRefPath, toRepoPath, capitalize, die } from './util.mjs';
7
+ import { allocateOutputIdentities } from './output-identity.mjs';
7
8
 
8
9
  export function runExport(argv, config, opts = {}) {
9
10
  const positional = [];
@@ -99,10 +100,11 @@ export function runExport(argv, config, opts = {}) {
99
100
  }
100
101
  } else if (format === 'html') {
101
102
  const outDir = output ?? 'dotmd-export';
103
+ const identities = allocateOutputIdentities(index.docs);
104
+ exportHtml(docsWithBody, config, outDir, identities, { dryRun });
102
105
  if (dryRun) {
103
106
  process.stdout.write(`${prefix}Would export ${docs.length} docs as HTML to ${outDir}/\n`);
104
107
  } else {
105
- exportHtml(docsWithBody, config, outDir);
106
108
  process.stdout.write(`Exported ${docs.length} docs to ${outDir}/\n`);
107
109
  }
108
110
  }
@@ -235,22 +237,30 @@ ul.toc li { padding: 0.3rem 0; }
235
237
  ul.toc .status-group { font-weight: 600; margin-top: 1rem; }
236
238
  `.trim();
237
239
 
238
- function exportHtml(docs, config, outDir) {
239
- mkdirSync(outDir, { recursive: true });
240
+ function exportHtml(docs, config, outDir, identities, { dryRun = false } = {}) {
241
+ const emittedPaths = new Set(docs.map(doc => doc.path));
242
+ const pages = [{ htmlPath: 'index.html', content: buildIndexPage(docs, config, identities) }];
243
+ for (const doc of docs) {
244
+ const identity = identities.get(doc.path);
245
+ if (!identity) throw new Error(`Missing output identity for ${doc.path}`);
246
+ pages.push({
247
+ htmlPath: identity.htmlPath,
248
+ content: buildDocPage(doc, identity, identities, emittedPaths, config),
249
+ });
250
+ }
240
251
 
241
- // Build index page
242
- const indexHtml = buildIndexPage(docs, config);
243
- writeFileSync(path.join(outDir, 'index.html'), indexHtml, 'utf8');
252
+ const guard = createOutputGuard(outDir);
253
+ for (const page of pages) guard.validate(page.htmlPath);
254
+ if (dryRun) return;
244
255
 
245
- // Build individual doc pages
246
- for (const doc of docs) {
247
- const slug = path.basename(doc.path, '.md');
248
- const html = buildDocPage(doc);
249
- writeFileSync(path.join(outDir, slug + '.html'), html, 'utf8');
256
+ for (const page of pages) {
257
+ guard.createParent(page.htmlPath);
258
+ guard.validate(page.htmlPath);
259
+ writeFileSync(path.join(guard.outputRoot, ...page.htmlPath.split('/')), page.content, 'utf8');
250
260
  }
251
261
  }
252
262
 
253
- function buildIndexPage(docs, config) {
263
+ function buildIndexPage(docs, config, identities) {
254
264
  const today = new Date().toISOString().slice(0, 10);
255
265
  const byStatus = {};
256
266
  for (const d of docs) {
@@ -265,8 +275,8 @@ function buildIndexPage(docs, config) {
265
275
  if (!group?.length) continue;
266
276
  toc += `<li class="status-group">${capitalize(status)} (${group.length})</li>\n`;
267
277
  for (const doc of group) {
268
- const slug = path.basename(doc.path, '.md');
269
- toc += `<li><a href="${slug}.html">${escHtml(doc.title)}</a></li>\n`;
278
+ const identity = identities.get(doc.path);
279
+ toc += `<li><a href="${escHtml(identity.htmlUrl)}">${escHtml(doc.title)}</a></li>\n`;
270
280
  }
271
281
  }
272
282
 
@@ -285,12 +295,12 @@ ${toc}</ul>
285
295
  `;
286
296
  }
287
297
 
288
- function buildDocPage(doc) {
289
- const slug = path.basename(doc.path, '.md');
290
- const badgeClass = `badge-${doc.status ?? 'unknown'}`;
298
+ function buildDocPage(doc, identity, identities, emittedPaths, config) {
299
+ const status = doc.status ?? 'unknown';
300
+ const badgeClass = `badge-${status.replace(/[^a-z0-9_-]/gi, '-')}`;
291
301
 
292
302
  let meta = `<table class="meta">`;
293
- meta += `<tr><td>Status</td><td><span class="badge ${badgeClass}">${doc.status ?? 'unknown'}</span></td></tr>`;
303
+ meta += `<tr><td>Status</td><td><span class="badge ${badgeClass}">${escHtml(status)}</span></td></tr>`;
294
304
  if (doc.updated) meta += `<tr><td>Updated</td><td>${escHtml(doc.updated)}</td></tr>`;
295
305
  if (doc.modules?.length) meta += `<tr><td>Module</td><td>${escHtml(doc.modules.join(', '))}</td></tr>`;
296
306
  if (doc.surfaces?.length) meta += `<tr><td>Surface</td><td>${escHtml(doc.surfaces.join(', '))}</td></tr>`;
@@ -298,7 +308,8 @@ function buildDocPage(doc) {
298
308
  meta += `<tr><td>Path</td><td><code>${escHtml(doc.path)}</code></td></tr>`;
299
309
  meta += `</table>`;
300
310
 
301
- const bodyHtml = mdToHtml(doc.body);
311
+ const bodyHtml = mdToHtml(doc.body, href => resolveHtmlLink(href, doc, identity, identities, emittedPaths, config));
312
+ const indexHref = relativeHtmlUrl(identity.htmlPath, 'index.html');
302
313
 
303
314
  return `<!DOCTYPE html>
304
315
  <html lang="en"><head>
@@ -307,7 +318,7 @@ function buildDocPage(doc) {
307
318
  <title>${escHtml(doc.title)}</title>
308
319
  <style>${CSS}</style>
309
320
  </head><body>
310
- <nav><a href="index.html">&larr; Index</a></nav>
321
+ <nav><a href="${escHtml(indexHref)}">&larr; Index</a></nav>
311
322
  <article>
312
323
  <h1>${escHtml(doc.title)}</h1>
313
324
  ${meta}
@@ -317,7 +328,7 @@ ${bodyHtml}
317
328
  `;
318
329
  }
319
330
 
320
- function mdToHtml(body) {
331
+ function mdToHtml(body, resolveLink = href => href) {
321
332
  if (!body?.trim()) return '';
322
333
  let html = escHtml(body);
323
334
 
@@ -340,7 +351,10 @@ function mdToHtml(body) {
340
351
  html = html.replace(/(?<!<code>)`([^`]+)`/g, '<code>$1</code>');
341
352
 
342
353
  // Links
343
- html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
354
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, escapedHref) => {
355
+ const href = resolveLink(unescapeHtml(escapedHref));
356
+ return href == null ? label : `<a href="${escHtml(href)}">${label}</a>`;
357
+ });
344
358
 
345
359
  // Blockquotes
346
360
  html = html.replace(/^&gt; (.+)$/gm, '<blockquote>$1</blockquote>');
@@ -363,5 +377,120 @@ function mdToHtml(body) {
363
377
  }
364
378
 
365
379
  function escHtml(text) {
366
- return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
380
+ return String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
381
+ }
382
+
383
+ function unescapeHtml(text) {
384
+ return text.replace(/&quot;/g, '"').replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&amp;/g, '&');
385
+ }
386
+
387
+ function encodeRelativeUrl(value) {
388
+ return value.split('/').map(segment => segment === '..' || segment === '.' ? segment : encodeURIComponent(segment)).join('/');
389
+ }
390
+
391
+ function relativeHtmlUrl(from, to) {
392
+ const relative = path.posix.relative(path.posix.dirname(from), to) || path.posix.basename(to);
393
+ return encodeRelativeUrl(relative);
394
+ }
395
+
396
+ function resolveHtmlLink(href, doc, identity, identities, emittedPaths, config) {
397
+ if (!href || href.startsWith('#') || href.startsWith('/') || href.startsWith('//') || /^[a-z][a-z0-9+.-]*:/i.test(href)) {
398
+ return href;
399
+ }
400
+
401
+ const hashAt = href.indexOf('#');
402
+ const targetPart = hashAt === -1 ? href : href.slice(0, hashAt);
403
+ const fragment = hashAt === -1 ? '' : href.slice(hashAt);
404
+ if (!targetPart.toLowerCase().endsWith('.md')) return href;
405
+
406
+ const docDir = path.dirname(path.join(config.repoRoot, doc.path));
407
+ const resolved = resolveRefPath(targetPart, docDir, config.repoRoot);
408
+ const targetPath = resolved ? toRepoPath(resolved, config.repoRoot) : null;
409
+ if (!targetPath || !emittedPaths.has(targetPath)) return null;
410
+ return relativeHtmlUrl(identity.htmlPath, identities.get(targetPath).htmlPath) + fragment;
411
+ }
412
+
413
+ function nearestExistingAncestor(value) {
414
+ let current = value;
415
+ while (true) {
416
+ try {
417
+ lstatSync(current);
418
+ return current;
419
+ } catch (err) {
420
+ if (err.code !== 'ENOENT') throw err;
421
+ }
422
+ const parent = path.dirname(current);
423
+ if (parent === current) throw new Error(`Cannot resolve output ancestor: ${value}`);
424
+ current = parent;
425
+ }
426
+ }
427
+
428
+ function createOutputGuard(outDir) {
429
+ const outputRoot = path.resolve(outDir);
430
+ const initialAncestor = nearestExistingAncestor(outputRoot);
431
+ const initialAncestorPhysical = realpathSync(initialAncestor);
432
+ const outputRootPhysical = existsSync(outputRoot)
433
+ ? realpathSync(outputRoot)
434
+ : path.resolve(initialAncestorPhysical, path.relative(initialAncestor, outputRoot));
435
+
436
+ if (existsSync(outputRoot) && !statSync(outputRoot).isDirectory()) {
437
+ throw new Error(`HTML output root is not a directory: ${outputRoot}`);
438
+ }
439
+
440
+ function validate(htmlPath) {
441
+ const destination = path.resolve(outputRoot, ...htmlPath.split('/'));
442
+ if (destination === outputRoot || !destination.startsWith(outputRoot + path.sep)) {
443
+ throw new Error(`HTML output escapes destination: ${htmlPath}`);
444
+ }
445
+
446
+ const currentAncestor = nearestExistingAncestor(outputRoot);
447
+ const currentPhysical = realpathSync(currentAncestor);
448
+ const currentRootPhysical = existsSync(outputRoot)
449
+ ? realpathSync(outputRoot)
450
+ : path.resolve(currentPhysical, path.relative(currentAncestor, outputRoot));
451
+ if (currentRootPhysical !== outputRootPhysical) {
452
+ throw new Error(`HTML output ancestry changed during export: ${outputRoot}`);
453
+ }
454
+
455
+ let current = outputRoot;
456
+ const components = htmlPath.split('/');
457
+ for (let i = 0; i < components.length; i++) {
458
+ const component = components[i];
459
+ current = path.join(current, component);
460
+ let entry;
461
+ try { entry = lstatSync(current); } catch (err) {
462
+ if (err.code === 'ENOENT') break;
463
+ throw err;
464
+ }
465
+ if (entry.isSymbolicLink()) {
466
+ throw new Error(`HTML output path contains a descendant symlink: ${current}`);
467
+ }
468
+ const isDestination = i === components.length - 1;
469
+ if ((!isDestination && !entry.isDirectory()) || (isDestination && entry.isDirectory())) {
470
+ throw new Error(`HTML output path has an incompatible existing entry: ${current}`);
471
+ }
472
+ }
473
+ }
474
+
475
+ function createParent(htmlPath) {
476
+ if (!existsSync(outputRoot)) {
477
+ const ancestor = nearestExistingAncestor(outputRoot);
478
+ let current = ancestor;
479
+ for (const component of path.relative(ancestor, outputRoot).split(path.sep).filter(Boolean)) {
480
+ validate(htmlPath);
481
+ current = path.join(current, component);
482
+ if (!existsSync(current)) mkdirSync(current);
483
+ }
484
+ }
485
+ let current = outputRoot;
486
+ const components = htmlPath.split('/').slice(0, -1);
487
+ for (const component of components) {
488
+ validate(htmlPath);
489
+ current = path.join(current, component);
490
+ if (!existsSync(current)) mkdirSync(current);
491
+ if (!statSync(current).isDirectory()) throw new Error(`HTML output parent is not a directory: ${current}`);
492
+ }
493
+ }
494
+
495
+ return { outputRoot, validate, createParent };
367
496
  }
package/src/fix-refs.mjs CHANGED
@@ -4,6 +4,7 @@ import { extractFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
4
4
  import { toRepoPath, escapeRegex, warn } from './util.mjs';
5
5
  import { buildIndex, collectDocFiles } from './index.mjs';
6
6
  import { green, dim, yellow } from './color.mjs';
7
+ import { authorizeManagedSweep } from './managed-path.mjs';
7
8
 
8
9
  export function runFixRefs(argv, config, opts = {}) {
9
10
  const { dryRun } = opts;
@@ -21,6 +22,7 @@ export function fixBrokenRefs(config, opts = {}) {
21
22
  const { dryRun, quiet } = opts;
22
23
  const index = buildIndex(config);
23
24
  const allFiles = collectDocFiles(config);
25
+ authorizeManagedSweep(allFiles, config, { kind: 'Reference fix source' });
24
26
 
25
27
  // Build a map of basename → absolute path for all docs
26
28
  const basenameMap = new Map();
@@ -3,6 +3,7 @@ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
3
3
  import { asString, toRepoPath, escapeRegex, warn } from './util.mjs';
4
4
  import { collectDocFiles } from './index.mjs';
5
5
  import { bold, green, dim } from './color.mjs';
6
+ import { authorizeManagedSweep } from './managed-path.mjs';
6
7
 
7
8
  // Caps must stay in lockstep with the warnings emitted by validatePlanShape in
8
9
  // src/validate.mjs — that's where the user first sees these numbers. Targets
@@ -16,6 +17,7 @@ const FIELDS = [
16
17
  export function runFrontmatterFix(config, opts = {}) {
17
18
  const { dryRun, out = process.stdout } = opts;
18
19
  const allFiles = collectDocFiles(config);
20
+ authorizeManagedSweep(allFiles, config, { kind: 'Frontmatter fix source' });
19
21
  const results = [];
20
22
 
21
23
  for (const filePath of allFiles) {
@@ -13,17 +13,18 @@ export function normalizeEol(text) {
13
13
  export function extractFrontmatter(raw) {
14
14
  const text = normalizeEol(raw);
15
15
  if (!text.startsWith('---\n')) {
16
- return { frontmatter: '', body: text };
16
+ return { frontmatter: '', body: text, bodyLineOffset: 0 };
17
17
  }
18
18
 
19
19
  const endMarker = text.indexOf('\n---\n', 4);
20
20
  if (endMarker === -1) {
21
- return { frontmatter: '', body: text };
21
+ return { frontmatter: '', body: text, bodyLineOffset: 0 };
22
22
  }
23
23
 
24
24
  return {
25
25
  frontmatter: text.slice(4, endMarker),
26
26
  body: text.slice(endMarker + 5),
27
+ bodyLineOffset: text.slice(0, endMarker + 5).split('\n').length - 1,
27
28
  };
28
29
  }
29
30