dotmd-cli 0.3.0 → 0.5.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/README.md CHANGED
@@ -83,6 +83,9 @@ dotmd index [--write] Generate/update docs.md index block
83
83
  dotmd status <file> <status> Transition document status
84
84
  dotmd archive <file> Archive (status + move + index regen)
85
85
  dotmd touch <file> Bump updated date
86
+ dotmd lint [--fix] Check and auto-fix frontmatter issues
87
+ dotmd rename <old> <new> Rename doc and update references
88
+ dotmd migrate <f> <old> <new> Batch update a frontmatter field
86
89
  dotmd watch [command] Re-run a command on file changes
87
90
  dotmd diff [file] Show changes since last updated date
88
91
  dotmd new <name> Create a new document with frontmatter
@@ -133,6 +136,44 @@ export const presets = {
133
136
 
134
137
  Then run `dotmd stale` or `dotmd mine` as shorthand.
135
138
 
139
+ ### Lint
140
+
141
+ Check docs for fixable frontmatter issues and optionally auto-fix them:
142
+
143
+ ```bash
144
+ dotmd lint # report issues
145
+ dotmd lint --fix # fix all issues
146
+ dotmd lint --fix --dry-run # preview fixes without writing
147
+ ```
148
+
149
+ Detected issues:
150
+ - Missing `updated` date on non-archived docs
151
+ - Status casing mismatch (e.g., `Active` → `active`)
152
+ - camelCase frontmatter keys (e.g., `nextStep` → `next_step`)
153
+ - Trailing whitespace in frontmatter values
154
+ - Missing newline at end of file
155
+
156
+ ### Rename
157
+
158
+ Rename a document and update all frontmatter references across your docs:
159
+
160
+ ```bash
161
+ dotmd rename old-name.md new-name # renames + updates refs
162
+ dotmd rename old-name.md new-name -n # preview without writing
163
+ ```
164
+
165
+ Uses `git mv` for the rename and scans all reference fields for the old filename. Body markdown links are warned about but not auto-fixed.
166
+
167
+ ### Migrate
168
+
169
+ Batch update a frontmatter field value across all docs:
170
+
171
+ ```bash
172
+ dotmd migrate status research exploration # rename a status
173
+ dotmd migrate module auth identity # rename a module
174
+ dotmd migrate module auth identity -n # preview
175
+ ```
176
+
136
177
  ### Watch Mode
137
178
 
138
179
  ```bash
@@ -231,7 +272,18 @@ export function onStatusChange(doc, { oldStatus, newStatus }) {
231
272
  }
232
273
  ```
233
274
 
234
- Available: `onArchive`, `onStatusChange`, `onTouch`.
275
+ Available: `onArchive`, `onStatusChange`, `onTouch`, `onNew`, `onRename`, `onLint`.
276
+
277
+ ### Summarize Hook
278
+
279
+ Override the diff summarizer (replaces the default MLX model call):
280
+
281
+ ```js
282
+ export function summarizeDiff(diffOutput, filePath) {
283
+ // call your preferred LLM, return a string summary
284
+ return `Changes in ${filePath}: ...`;
285
+ }
286
+ ```
235
287
 
236
288
  ## Features
237
289
 
package/bin/dotmd.mjs CHANGED
@@ -14,6 +14,9 @@ import { runNew } from '../src/new.mjs';
14
14
  import { runCompletions } from '../src/completions.mjs';
15
15
  import { runWatch } from '../src/watch.mjs';
16
16
  import { runDiff } from '../src/diff.mjs';
17
+ import { runLint } from '../src/lint.mjs';
18
+ import { runRename } from '../src/rename.mjs';
19
+ import { runMigrate } from '../src/migrate.mjs';
17
20
  import { die, warn } from '../src/util.mjs';
18
21
 
19
22
  const __filename = fileURLToPath(import.meta.url);
@@ -35,6 +38,9 @@ Commands:
35
38
  status <file> <status> Transition document status
36
39
  archive <file> Archive (status + move + index regen)
37
40
  touch <file> Bump updated date
41
+ lint [--fix] Check and auto-fix frontmatter issues
42
+ rename <old> <new> Rename doc and update references
43
+ migrate <f> <old> <new> Batch update a frontmatter field
38
44
  watch [command] Re-run a command on file changes
39
45
  diff [file] Show changes since last updated date
40
46
  new <name> Create a new document with frontmatter
@@ -122,6 +128,33 @@ Options:
122
128
  --summarize Generate AI summary using local MLX model
123
129
  --model <name> MLX model to use (default: mlx-community/Llama-3.2-3B-Instruct-4bit)`,
124
130
 
131
+ lint: `dotmd lint [--fix] — check and auto-fix frontmatter issues
132
+
133
+ Scans all docs for fixable problems: missing updated dates, status casing,
134
+ camelCase key names, trailing whitespace in values, missing EOF newline.
135
+
136
+ Without --fix, reports all issues. With --fix, applies fixes in place.
137
+ Use --dry-run (-n) with --fix to preview without writing anything.`,
138
+
139
+ rename: `dotmd rename <old> <new> — rename doc and update references
140
+
141
+ Renames a document using git mv and updates all frontmatter references
142
+ in other docs that point to the old filename.
143
+
144
+ Body markdown links are warned about but not auto-fixed.
145
+ Use --dry-run (-n) to preview changes without writing anything.`,
146
+
147
+ migrate: `dotmd migrate <field> <old-value> <new-value> — batch update a frontmatter field
148
+
149
+ Finds all docs where the given field equals old-value and updates it
150
+ to new-value.
151
+
152
+ Examples:
153
+ dotmd migrate status research exploration
154
+ dotmd migrate module auth identity
155
+
156
+ Use --dry-run (-n) to preview changes without writing anything.`,
157
+
125
158
  init: `dotmd init — create starter config and docs directory
126
159
 
127
160
  Creates dotmd.config.mjs, docs/, and docs/docs.md in the current
@@ -179,6 +212,12 @@ async function main() {
179
212
  warn('No dotmd config found — using defaults. Run `dotmd init` to create one.');
180
213
  }
181
214
 
215
+ if (config.configWarnings && config.configWarnings.length > 0) {
216
+ for (const w of config.configWarnings) {
217
+ warn(w);
218
+ }
219
+ }
220
+
182
221
  if (verbose) {
183
222
  process.stderr.write(`Config: ${config.configPath ?? 'none'}\n`);
184
223
  process.stderr.write(`Docs root: ${config.docsRoot}\n`);
@@ -201,6 +240,9 @@ async function main() {
201
240
  if (command === 'archive') { runArchive(restArgs, config, { dryRun }); return; }
202
241
  if (command === 'touch') { runTouch(restArgs, config, { dryRun }); return; }
203
242
  if (command === 'new') { runNew(restArgs, config, { dryRun }); return; }
243
+ if (command === 'lint') { runLint(restArgs, config, { dryRun }); return; }
244
+ if (command === 'rename') { runRename(restArgs, config, { dryRun }); return; }
245
+ if (command === 'migrate') { runMigrate(restArgs, config, { dryRun }); return; }
204
246
 
205
247
  const index = buildIndex(config);
206
248
 
@@ -240,7 +282,6 @@ async function main() {
240
282
  if (command === 'index') {
241
283
  if (!config.indexPath) {
242
284
  die('Index generation is not configured. Add an `index` section to your dotmd.config.mjs.');
243
- return;
244
285
  }
245
286
  const write = args.includes('--write');
246
287
  const rendered = renderIndexFile(index, config);
@@ -264,5 +305,6 @@ async function main() {
264
305
  }
265
306
 
266
307
  main().catch(err => {
267
- die(err.message);
308
+ process.stderr.write(`${err.message}\n`);
309
+ process.exitCode = 1;
268
310
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Zero-dependency CLI for managing markdown documents with YAML frontmatter — index, query, validate, lifecycle.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -2,7 +2,8 @@ import { die } from './util.mjs';
2
2
 
3
3
  const COMMANDS = [
4
4
  'list', 'json', 'check', 'coverage', 'context', 'focus', 'query',
5
- 'index', 'status', 'archive', 'touch', 'watch', 'diff', 'init', 'new', 'completions',
5
+ 'index', 'status', 'archive', 'touch', 'lint', 'rename', 'migrate',
6
+ 'watch', 'diff', 'init', 'new', 'completions',
6
7
  ];
7
8
 
8
9
  const GLOBAL_FLAGS = ['--config', '--dry-run', '--verbose', '--help', '--version'];
@@ -16,6 +17,9 @@ const COMMAND_FLAGS = {
16
17
  coverage: ['--json'],
17
18
  new: ['--status', '--title'],
18
19
  diff: ['--stat', '--since', '--summarize', '--model'],
20
+ lint: ['--fix'],
21
+ rename: [],
22
+ migrate: [],
19
23
  };
20
24
 
21
25
  function bashCompletion() {
@@ -86,7 +90,6 @@ export function runCompletions(argv) {
86
90
  const shell = argv[0];
87
91
  if (!shell) {
88
92
  die('Usage: dotmd completions <bash|zsh>');
89
- return;
90
93
  }
91
94
  if (shell === 'bash') {
92
95
  process.stdout.write(bashCompletion() + '\n');
package/src/config.mjs CHANGED
@@ -76,6 +76,59 @@ function findConfigFile(startDir) {
76
76
  return null;
77
77
  }
78
78
 
79
+ const VALID_CONFIG_KEYS = new Set(Object.keys(DEFAULTS));
80
+
81
+ function validateConfig(userConfig, config, validStatuses, indexPath) {
82
+ const warnings = [];
83
+
84
+ // statuses.order must be array
85
+ if (config.statuses && config.statuses.order !== undefined && !Array.isArray(config.statuses.order)) {
86
+ warnings.push('Config: statuses.order must be an array.');
87
+ }
88
+
89
+ // archiveDir must be string
90
+ if (config.archiveDir !== undefined && typeof config.archiveDir !== 'string') {
91
+ warnings.push('Config: archiveDir must be a string.');
92
+ }
93
+
94
+ // lifecycle.archiveStatuses values must exist in validStatuses
95
+ if (config.lifecycle?.archiveStatuses) {
96
+ for (const s of config.lifecycle.archiveStatuses) {
97
+ if (!validStatuses.has(s)) {
98
+ warnings.push(`Config: lifecycle.archiveStatuses contains unknown status '${s}'.`);
99
+ }
100
+ }
101
+ }
102
+
103
+ // staleDays keys must exist in validStatuses
104
+ if (config.statuses?.staleDays) {
105
+ for (const key of Object.keys(config.statuses.staleDays)) {
106
+ if (!validStatuses.has(key)) {
107
+ warnings.push(`Config: statuses.staleDays contains unknown status '${key}'.`);
108
+ }
109
+ }
110
+ }
111
+
112
+ // taxonomy.surfaces must be null or array
113
+ if (config.taxonomy?.surfaces !== undefined && config.taxonomy.surfaces !== null && !Array.isArray(config.taxonomy.surfaces)) {
114
+ warnings.push('Config: taxonomy.surfaces must be null or an array.');
115
+ }
116
+
117
+ // index path file exists (if configured)
118
+ if (indexPath && !existsSync(indexPath)) {
119
+ warnings.push(`Config: index path does not exist: ${indexPath}`);
120
+ }
121
+
122
+ // Unknown top-level user config keys
123
+ for (const key of Object.keys(userConfig)) {
124
+ if (!VALID_CONFIG_KEYS.has(key)) {
125
+ warnings.push(`Config: unknown key '${key}'.`);
126
+ }
127
+ }
128
+
129
+ return warnings;
130
+ }
131
+
79
132
  function deepMerge(defaults, overrides) {
80
133
  const result = { ...defaults };
81
134
  for (const [key, value] of Object.entries(overrides)) {
@@ -105,21 +158,6 @@ export async function resolveConfig(cwd, explicitConfigPath) {
105
158
  mod = await import(configUrl);
106
159
  } catch (err) {
107
160
  die('Failed to load config: ' + configPath + '\n' + err.message + '\nRun `dotmd init` to create a starter config.');
108
- // Return defaults so caller can still function
109
- const defaults = deepMerge(DEFAULTS, {});
110
- const defaultStatusOrder = defaults.statuses.order;
111
- return {
112
- raw: defaults, docsRoot: cwd, repoRoot: cwd, configDir: cwd,
113
- configPath: configPath ?? null, configFound: Boolean(configPath),
114
- archiveDir: defaults.archiveDir, excludeDirs: new Set(defaults.excludeDirs),
115
- docsRootPrefix: '', statusOrder: defaultStatusOrder,
116
- validStatuses: new Set(defaultStatusOrder), staleDaysByStatus: {},
117
- lifecycle: { archiveStatuses: new Set(defaults.lifecycle.archiveStatuses), skipStaleFor: new Set(defaults.lifecycle.skipStaleFor), skipWarningsFor: new Set(defaults.lifecycle.skipWarningsFor) },
118
- validSurfaces: null, moduleRequiredStatuses: new Set(),
119
- indexPath: null, indexStartMarker: '<!-- GENERATED:dotmd:start -->', indexEndMarker: '<!-- GENERATED:dotmd:end -->', archivedHighlightLimit: 8,
120
- context: defaults.context, display: defaults.display,
121
- referenceFields: defaults.referenceFields, presets: defaults.presets, hooks: {},
122
- };
123
161
  }
124
162
 
125
163
  configDir = path.dirname(configPath);
@@ -144,8 +182,9 @@ export async function resolveConfig(cwd, explicitConfigPath) {
144
182
 
145
183
  const docsRoot = path.resolve(configDir, config.root);
146
184
 
185
+ const earlyWarnings = [];
147
186
  if (!existsSync(docsRoot)) {
148
- warn('Docs root does not exist: ' + docsRoot);
187
+ earlyWarnings.push('Config: docs root does not exist: ' + docsRoot);
149
188
  }
150
189
 
151
190
  // Find repo root by walking up looking for .git
@@ -188,6 +227,8 @@ export async function resolveConfig(cwd, explicitConfigPath) {
188
227
  const skipStaleFor = new Set(lifecycle.skipStaleFor);
189
228
  const skipWarningsFor = new Set(lifecycle.skipWarningsFor);
190
229
 
230
+ const configWarnings = [...earlyWarnings, ...validateConfig(userConfig, config, validStatuses, indexPath)];
231
+
191
232
  return {
192
233
  raw: config,
193
234
 
@@ -219,5 +260,6 @@ export async function resolveConfig(cwd, explicitConfigPath) {
219
260
  referenceFields: config.referenceFields,
220
261
  presets: config.presets,
221
262
  hooks,
263
+ configWarnings,
222
264
  };
223
265
  }
package/src/diff.mjs CHANGED
@@ -27,7 +27,6 @@ export function runDiff(argv, config) {
27
27
  const filePath = resolveDocPath(file, config);
28
28
  if (!filePath) {
29
29
  die(`File not found: ${file}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`);
30
- return;
31
30
  }
32
31
 
33
32
  const raw = readFileSync(filePath, 'utf8');
@@ -37,7 +36,6 @@ export function runDiff(argv, config) {
37
36
 
38
37
  if (!since) {
39
38
  die(`No updated date found in ${file} and no --since provided.`);
40
- return;
41
39
  }
42
40
 
43
41
  const relPath = toRepoPath(filePath, config.repoRoot);
@@ -80,7 +78,15 @@ function printFileDiff(relPath, since, diffOutput, opts) {
80
78
  process.stdout.write(bold(relPath) + dim(` (updated: ${since})`) + '\n');
81
79
 
82
80
  if (opts.summarize) {
83
- const summary = summarizeWithMLX(diffOutput, relPath, opts.model);
81
+ let summary;
82
+ try {
83
+ summary = opts.config?.hooks?.summarizeDiff
84
+ ? opts.config.hooks.summarizeDiff(diffOutput, relPath)
85
+ : summarizeWithMLX(diffOutput, relPath, opts.model);
86
+ } catch (err) {
87
+ warn(`Hook 'summarizeDiff' threw: ${err.message}`);
88
+ summary = null;
89
+ }
84
90
  if (summary) {
85
91
  process.stdout.write(dim(` Summary: ${summary}`) + '\n');
86
92
  } else {
@@ -93,6 +99,12 @@ function printFileDiff(relPath, since, diffOutput, opts) {
93
99
  }
94
100
 
95
101
  function summarizeWithMLX(diffText, filePath, model) {
102
+ const uvCheck = spawnSync('uv', ['--version'], { encoding: 'utf8' });
103
+ if (uvCheck.error) {
104
+ warn('uv is not installed. Install it to enable --summarize: https://docs.astral.sh/uv/');
105
+ return null;
106
+ }
107
+
96
108
  const prompt = `Summarize this git diff in 1-2 sentences. Focus on what changed semantically, not line counts.\n\nFile: ${filePath}\n\n${diffText.slice(0, 4000)}`;
97
109
 
98
110
  const result = spawnSync('uv', [
@@ -14,6 +14,14 @@ export function extractFrontmatter(raw) {
14
14
  };
15
15
  }
16
16
 
17
+ export function replaceFrontmatter(raw, newFrontmatter) {
18
+ if (!raw.startsWith('---\n')) return raw;
19
+ const endMarker = raw.indexOf('\n---\n', 4);
20
+ if (endMarker === -1) return raw;
21
+ const body = raw.slice(endMarker + 5);
22
+ return `---\n${newFrontmatter}\n---\n${body}`;
23
+ }
24
+
17
25
  export function parseSimpleFrontmatter(text) {
18
26
  const data = {};
19
27
  let currentArrayKey = null;
@@ -25,6 +33,10 @@ export function parseSimpleFrontmatter(text) {
25
33
  const keyMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
26
34
  if (keyMatch) {
27
35
  const [, key, rawValue] = keyMatch;
36
+ if (Object.prototype.hasOwnProperty.call(data, key)) {
37
+ currentArrayKey = null;
38
+ continue;
39
+ }
28
40
  if (!rawValue.trim()) {
29
41
  data[key] = [];
30
42
  currentArrayKey = key;
@@ -48,7 +60,12 @@ export function parseSimpleFrontmatter(text) {
48
60
  }
49
61
 
50
62
  function parseScalar(value) {
51
- const unquoted = value.replace(/^['"]|['"]$/g, '');
63
+ let unquoted = value;
64
+ if (value.length > 1 &&
65
+ ((value.startsWith("'") && value.endsWith("'")) ||
66
+ (value.startsWith('"') && value.endsWith('"')))) {
67
+ unquoted = value.slice(1, -1);
68
+ }
52
69
  if (unquoted === 'true') return true;
53
70
  if (unquoted === 'false') return false;
54
71
  return unquoted;
package/src/git.mjs CHANGED
@@ -1,15 +1,26 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
 
3
+ let gitChecked = false;
4
+ function ensureGit() {
5
+ if (gitChecked) return;
6
+ const result = spawnSync('git', ['--version'], { encoding: 'utf8' });
7
+ if (result.error) {
8
+ throw new Error('git is not installed or not found in PATH. dotmd requires git for this operation.');
9
+ }
10
+ gitChecked = true;
11
+ }
12
+
3
13
  export function getGitLastModified(relPath, repoRoot) {
4
14
  const result = spawnSync('git', ['log', '-1', '--format=%aI', '--', relPath], {
5
15
  cwd: repoRoot,
6
16
  encoding: 'utf8',
7
17
  });
8
- if (result.status !== 0 || !result.stdout.trim()) return null;
18
+ if (result.error || result.status !== 0 || !result.stdout.trim()) return null;
9
19
  return result.stdout.trim();
10
20
  }
11
21
 
12
22
  export function gitMv(source, target, repoRoot) {
23
+ ensureGit();
13
24
  const result = spawnSync('git', ['mv', source, target], {
14
25
  cwd: repoRoot,
15
26
  encoding: 'utf8',
@@ -18,6 +29,7 @@ export function gitMv(source, target, repoRoot) {
18
29
  }
19
30
 
20
31
  export function gitDiffSince(relPath, sinceDate, repoRoot, opts = {}) {
32
+ ensureGit();
21
33
  // Find the last commit at or before sinceDate
22
34
  const baseline = spawnSync('git', [
23
35
  'log', '-1', '--before=' + sinceDate + 'T23:59:59', '--format=%H', '--', relPath
package/src/index.mjs CHANGED
@@ -2,7 +2,7 @@ import { readdirSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
4
  import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts } from './extractors.mjs';
5
- import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath } from './util.mjs';
5
+ import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn } from './util.mjs';
6
6
  import { validateDoc, checkBidirectionalReferences, checkGitStaleness, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate } from './validate.mjs';
7
7
  import { checkIndex } from './index-file.mjs';
8
8
 
@@ -19,20 +19,32 @@ export function buildIndex(config) {
19
19
  if (config.hooks.validate) {
20
20
  const ctx = { config, allDocs: docs, repoRoot: config.repoRoot };
21
21
  for (const doc of docs) {
22
- const result = config.hooks.validate(doc, ctx);
23
- if (result?.errors) {
24
- doc.errors.push(...result.errors);
25
- errors.push(...result.errors);
26
- }
27
- if (result?.warnings) {
28
- doc.warnings.push(...result.warnings);
29
- warnings.push(...result.warnings);
22
+ try {
23
+ const result = config.hooks.validate(doc, ctx);
24
+ if (result?.errors) {
25
+ doc.errors.push(...result.errors);
26
+ errors.push(...result.errors);
27
+ }
28
+ if (result?.warnings) {
29
+ doc.warnings.push(...result.warnings);
30
+ warnings.push(...result.warnings);
31
+ }
32
+ } catch (err) {
33
+ const hookError = { path: doc.path, level: 'error', message: `Hook 'validate' threw: ${err.message}` };
34
+ doc.errors.push(hookError);
35
+ errors.push(hookError);
30
36
  }
31
37
  }
32
38
  }
33
39
 
34
40
  const transformedDocs = config.hooks.transformDoc
35
- ? docs.map(d => config.hooks.transformDoc(d) ?? d)
41
+ ? docs.map(d => {
42
+ try { return config.hooks.transformDoc(d) ?? d; }
43
+ catch (err) {
44
+ warnings.push({ path: d.path, level: 'warning', message: `Hook 'transformDoc' threw: ${err.message}` });
45
+ return d;
46
+ }
47
+ })
36
48
  : docs;
37
49
 
38
50
  const countsByStatus = Object.fromEntries(config.statusOrder.map(status => [
package/src/lifecycle.mjs CHANGED
@@ -1,7 +1,7 @@
1
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
- import { asString, toRepoPath, die, resolveDocPath } from './util.mjs';
4
+ import { asString, toRepoPath, die, warn, resolveDocPath, escapeRegex } from './util.mjs';
5
5
  import { gitMv } from './git.mjs';
6
6
  import { buildIndex, collectDocFiles } from './index.mjs';
7
7
  import { renderIndexFile, writeIndex } from './index-file.mjs';
@@ -12,11 +12,11 @@ export function runStatus(argv, config, opts = {}) {
12
12
  const input = argv[0];
13
13
  const newStatus = argv[1];
14
14
 
15
- if (!input || !newStatus) { die('Usage: dotmd status <file> <new-status>'); return; }
16
- if (!config.validStatuses.has(newStatus)) { die(`Invalid status: ${newStatus}\nValid: ${[...config.validStatuses].join(', ')}`); return; }
15
+ if (!input || !newStatus) { die('Usage: dotmd status <file> <new-status>'); }
16
+ if (!config.validStatuses.has(newStatus)) { die(`Invalid status: ${newStatus}\nValid: ${[...config.validStatuses].join(', ')}`); }
17
17
 
18
18
  const filePath = resolveDocPath(input, config);
19
- if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); return; }
19
+ if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
20
20
 
21
21
  const raw = readFileSync(filePath, 'utf8');
22
22
  const { frontmatter } = extractFrontmatter(raw);
@@ -57,18 +57,19 @@ export function runStatus(argv, config, opts = {}) {
57
57
  updateFrontmatter(filePath, { status: newStatus, updated: today });
58
58
 
59
59
  if (isArchiving) {
60
+ mkdirSync(archiveDir, { recursive: true });
60
61
  const targetPath = path.join(archiveDir, path.basename(filePath));
61
- if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); return; }
62
+ if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
62
63
  const result = gitMv(filePath, targetPath, config.repoRoot);
63
- if (result.status !== 0) { die(result.stderr || 'git mv failed.'); return; }
64
+ if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
64
65
  finalPath = targetPath;
65
66
  }
66
67
 
67
68
  if (isUnarchiving) {
68
69
  const targetPath = path.join(config.docsRoot, path.basename(filePath));
69
- if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); return; }
70
+ if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
70
71
  const result = gitMv(filePath, targetPath, config.repoRoot);
71
- if (result.status !== 0) { die(result.stderr || 'git mv failed.'); return; }
72
+ if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
72
73
  finalPath = targetPath;
73
74
  }
74
75
 
@@ -79,21 +80,21 @@ export function runStatus(argv, config, opts = {}) {
79
80
 
80
81
  process.stdout.write(`${green(toRepoPath(finalPath, config.repoRoot))}: ${oldStatus ?? 'unknown'} → ${newStatus}\n`);
81
82
 
82
- config.hooks.onStatusChange?.({ path: toRepoPath(finalPath, config.repoRoot), oldStatus, newStatus }, {
83
+ try { config.hooks.onStatusChange?.({ path: toRepoPath(finalPath, config.repoRoot), oldStatus, newStatus }, {
83
84
  oldPath: toRepoPath(filePath, config.repoRoot),
84
85
  newPath: toRepoPath(finalPath, config.repoRoot),
85
- });
86
+ }); } catch (err) { warn(`Hook 'onStatusChange' threw: ${err.message}`); }
86
87
  }
87
88
 
88
89
  export function runArchive(argv, config, opts = {}) {
89
90
  const { dryRun } = opts;
90
91
  const input = argv[0];
91
92
 
92
- if (!input) { die('Usage: dotmd archive <file>'); return; }
93
+ if (!input) { die('Usage: dotmd archive <file>'); }
93
94
 
94
95
  const filePath = resolveDocPath(input, config);
95
- if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); return; }
96
- if (filePath.includes(`/${config.archiveDir}/`)) { die(`Already archived: ${toRepoPath(filePath, config.repoRoot)}`); return; }
96
+ if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
97
+ if (filePath.includes(`/${config.archiveDir}/`)) { die(`Already archived: ${toRepoPath(filePath, config.repoRoot)}`); }
97
98
 
98
99
  const raw = readFileSync(filePath, 'utf8');
99
100
  const { frontmatter } = extractFrontmatter(raw);
@@ -109,7 +110,7 @@ export function runArchive(argv, config, opts = {}) {
109
110
  if (dryRun) {
110
111
  const prefix = dim('[dry-run]');
111
112
  process.stdout.write(`${prefix} Would update frontmatter: status: ${oldStatus} → archived, updated: ${today}\n`);
112
- if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); return; }
113
+ if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
113
114
  process.stdout.write(`${prefix} Would move: ${oldRepoPath} → ${newRepoPath}\n`);
114
115
  if (config.indexPath) process.stdout.write(`${prefix} Would regenerate index\n`);
115
116
 
@@ -133,10 +134,11 @@ export function runArchive(argv, config, opts = {}) {
133
134
 
134
135
  updateFrontmatter(filePath, { status: 'archived', updated: today });
135
136
 
136
- if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); return; }
137
+ mkdirSync(targetDir, { recursive: true });
138
+ if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
137
139
 
138
140
  const result = gitMv(filePath, targetPath, config.repoRoot);
139
- if (result.status !== 0) { die(result.stderr || 'git mv failed.'); return; }
141
+ if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
140
142
 
141
143
  if (config.indexPath) {
142
144
  const index = buildIndex(config);
@@ -163,17 +165,17 @@ export function runArchive(argv, config, opts = {}) {
163
165
  }
164
166
 
165
167
  process.stdout.write('\nNext: commit, then update references if needed.\n');
166
- config.hooks.onArchive?.({ path: newRepoPath, oldStatus }, { oldPath: oldRepoPath, newPath: newRepoPath });
168
+ try { config.hooks.onArchive?.({ path: newRepoPath, oldStatus }, { oldPath: oldRepoPath, newPath: newRepoPath }); } catch (err) { warn(`Hook 'onArchive' threw: ${err.message}`); }
167
169
  }
168
170
 
169
171
  export function runTouch(argv, config, opts = {}) {
170
172
  const { dryRun } = opts;
171
173
  const input = argv[0];
172
174
 
173
- if (!input) { die('Usage: dotmd touch <file>'); return; }
175
+ if (!input) { die('Usage: dotmd touch <file>'); }
174
176
 
175
177
  const filePath = resolveDocPath(input, config);
176
- if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); return; }
178
+ if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
177
179
 
178
180
  const today = new Date().toISOString().slice(0, 10);
179
181
 
@@ -185,7 +187,7 @@ export function runTouch(argv, config, opts = {}) {
185
187
  updateFrontmatter(filePath, { updated: today });
186
188
  process.stdout.write(`${green('Touched')}: ${toRepoPath(filePath, config.repoRoot)} (updated → ${today})\n`);
187
189
 
188
- config.hooks.onTouch?.({ path: toRepoPath(filePath, config.repoRoot) }, { path: toRepoPath(filePath, config.repoRoot), date: today });
190
+ try { config.hooks.onTouch?.({ path: toRepoPath(filePath, config.repoRoot) }, { path: toRepoPath(filePath, config.repoRoot), date: today }); } catch (err) { warn(`Hook 'onTouch' threw: ${err.message}`); }
189
191
  }
190
192
 
191
193
  export function updateFrontmatter(filePath, updates) {
@@ -199,7 +201,7 @@ export function updateFrontmatter(filePath, updates) {
199
201
  const body = raw.slice(endMarker + 5);
200
202
 
201
203
  for (const [key, value] of Object.entries(updates)) {
202
- const regex = new RegExp(`^${key}:.*$`, 'm');
204
+ const regex = new RegExp(`^${escapeRegex(key)}:.*$`, 'm');
203
205
  if (regex.test(frontmatter)) {
204
206
  frontmatter = frontmatter.replace(regex, `${key}: ${value}`);
205
207
  } else {
package/src/lint.mjs ADDED
@@ -0,0 +1,183 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
3
+ import { asString, toRepoPath, escapeRegex, warn } from './util.mjs';
4
+ import { buildIndex, collectDocFiles } from './index.mjs';
5
+ import { updateFrontmatter } from './lifecycle.mjs';
6
+ import { bold, green, yellow, dim } from './color.mjs';
7
+
8
+ const KEY_RENAMES = {
9
+ nextStep: 'next_step',
10
+ currentState: 'current_state',
11
+ auditLevel: 'audit_level',
12
+ sourceOfTruth: 'source_of_truth',
13
+ relatedPlans: 'related_plans',
14
+ supportsPlans: 'supports_plans',
15
+ };
16
+
17
+ export function runLint(argv, config, opts = {}) {
18
+ const { dryRun } = opts;
19
+ const fix = argv.includes('--fix');
20
+ const allFiles = collectDocFiles(config);
21
+ const fixable = [];
22
+
23
+ for (const filePath of allFiles) {
24
+ const raw = readFileSync(filePath, 'utf8');
25
+ const { frontmatter } = extractFrontmatter(raw);
26
+ if (!frontmatter) continue;
27
+ const parsed = parseSimpleFrontmatter(frontmatter);
28
+ const repoPath = toRepoPath(filePath, config.repoRoot);
29
+ const fixes = [];
30
+
31
+ // Missing updated
32
+ if (!asString(parsed.updated) && asString(parsed.status) && !config.lifecycle.skipWarningsFor.has(asString(parsed.status))) {
33
+ const today = new Date().toISOString().slice(0, 10);
34
+ fixes.push({ field: 'updated', oldValue: null, newValue: today, type: 'add' });
35
+ }
36
+
37
+ // Status casing
38
+ const status = asString(parsed.status);
39
+ if (status && status !== status.toLowerCase() && config.validStatuses.has(status.toLowerCase())) {
40
+ fixes.push({ field: 'status', oldValue: status, newValue: status.toLowerCase(), type: 'update' });
41
+ }
42
+
43
+ // Key renames
44
+ for (const [oldKey, newKey] of Object.entries(KEY_RENAMES)) {
45
+ if (parsed[oldKey] !== undefined && parsed[newKey] === undefined) {
46
+ fixes.push({ field: oldKey, oldValue: oldKey, newValue: newKey, type: 'rename-key' });
47
+ }
48
+ }
49
+
50
+ // Trailing whitespace in values
51
+ for (const line of frontmatter.split('\n')) {
52
+ const m = line.match(/^([A-Za-z0-9_-]+):(.+\S)\s+$/);
53
+ if (m) {
54
+ fixes.push({ field: m[1], oldValue: m[2] + line.slice(line.indexOf(m[2]) + m[2].length), newValue: m[2], type: 'trim' });
55
+ }
56
+ }
57
+
58
+ // Missing newline at EOF
59
+ if (!raw.endsWith('\n')) {
60
+ fixes.push({ field: '(eof)', oldValue: 'missing', newValue: 'newline', type: 'eof' });
61
+ }
62
+
63
+ if (fixes.length > 0) {
64
+ fixable.push({ filePath, repoPath, fixes });
65
+ }
66
+ }
67
+
68
+ // Also get non-fixable issues from index
69
+ const index = buildIndex(config);
70
+ const nonFixable = [...index.errors, ...index.warnings];
71
+
72
+ if (!fix) {
73
+ // Report mode
74
+ if (fixable.length > 0) {
75
+ process.stdout.write(bold(`${fixable.length} file(s) with fixable issues:\n\n`));
76
+ for (const { repoPath, fixes } of fixable) {
77
+ process.stdout.write(` ${repoPath}\n`);
78
+ for (const f of fixes) {
79
+ if (f.type === 'rename-key') {
80
+ process.stdout.write(dim(` ${f.oldValue} → ${f.newValue}\n`));
81
+ } else if (f.type === 'eof') {
82
+ process.stdout.write(dim(` missing newline at end of file\n`));
83
+ } else if (f.type === 'add') {
84
+ process.stdout.write(dim(` add ${f.field}: ${f.newValue}\n`));
85
+ } else {
86
+ process.stdout.write(dim(` ${f.field}: ${f.oldValue} → ${f.newValue}\n`));
87
+ }
88
+ }
89
+ }
90
+ process.stdout.write(`\nRun ${bold('dotmd lint --fix')} to auto-fix.\n`);
91
+ }
92
+
93
+ if (nonFixable.length > 0) {
94
+ process.stdout.write(`\n${yellow(`${nonFixable.length} non-fixable issue(s)`)} (manual attention needed):\n`);
95
+ for (const issue of nonFixable) {
96
+ process.stdout.write(` ${issue.path}: ${issue.message}\n`);
97
+ }
98
+ }
99
+
100
+ if (fixable.length === 0 && nonFixable.length === 0) {
101
+ process.stdout.write(green('No issues found.') + '\n');
102
+ }
103
+ return;
104
+ }
105
+
106
+ // Fix mode
107
+ const prefix = dryRun ? dim('[dry-run] ') : '';
108
+ let totalFixes = 0;
109
+
110
+ for (const { filePath, repoPath, fixes } of fixable) {
111
+ const updates = {};
112
+ const keyRenames = [];
113
+ let needsEofFix = false;
114
+ const trimFixes = [];
115
+
116
+ for (const f of fixes) {
117
+ if (f.type === 'rename-key') {
118
+ keyRenames.push(f);
119
+ } else if (f.type === 'eof') {
120
+ needsEofFix = true;
121
+ } else if (f.type === 'trim') {
122
+ trimFixes.push(f);
123
+ } else {
124
+ updates[f.field] = f.newValue;
125
+ }
126
+ }
127
+
128
+ if (!dryRun) {
129
+ // Apply key renames and trim fixes via raw string manipulation
130
+ if (keyRenames.length > 0 || trimFixes.length > 0) {
131
+ let raw = readFileSync(filePath, 'utf8');
132
+ const { frontmatter: fm } = extractFrontmatter(raw);
133
+ let newFm = fm;
134
+ for (const kr of keyRenames) {
135
+ const regex = new RegExp(`^${escapeRegex(kr.oldValue)}:`, 'm');
136
+ newFm = newFm.replace(regex, `${kr.newValue}:`);
137
+ }
138
+ for (const tf of trimFixes) {
139
+ const regex = new RegExp(`^(${escapeRegex(tf.field)}:)${escapeRegex(tf.oldValue)}$`, 'm');
140
+ newFm = newFm.replace(regex, `$1${tf.newValue}`);
141
+ }
142
+ if (newFm !== fm) {
143
+ raw = replaceFrontmatter(raw, newFm);
144
+ writeFileSync(filePath, raw, 'utf8');
145
+ }
146
+ }
147
+
148
+ // Apply value updates via updateFrontmatter
149
+ if (Object.keys(updates).length > 0) {
150
+ updateFrontmatter(filePath, updates);
151
+ }
152
+
153
+ // EOF fix
154
+ if (needsEofFix) {
155
+ const current = readFileSync(filePath, 'utf8');
156
+ if (!current.endsWith('\n')) {
157
+ writeFileSync(filePath, current + '\n', 'utf8');
158
+ }
159
+ }
160
+ }
161
+
162
+ process.stdout.write(`${prefix}${green('Fixed')}: ${repoPath} (${fixes.length} issue${fixes.length > 1 ? 's' : ''})\n`);
163
+ for (const f of fixes) {
164
+ if (f.type === 'rename-key') {
165
+ process.stdout.write(`${prefix} ${dim(`${f.oldValue} → ${f.newValue}`)}\n`);
166
+ } else if (f.type === 'eof') {
167
+ process.stdout.write(`${prefix} ${dim('added newline at EOF')}\n`);
168
+ } else if (f.type === 'add') {
169
+ process.stdout.write(`${prefix} ${dim(`add ${f.field}: ${f.newValue}`)}\n`);
170
+ } else {
171
+ process.stdout.write(`${prefix} ${dim(`${f.field}: ${f.oldValue} → ${f.newValue}`)}\n`);
172
+ }
173
+ }
174
+ totalFixes += fixes.length;
175
+
176
+ if (!dryRun) {
177
+ try { config.hooks.onLint?.({ path: repoPath, fixes }); } catch (err) { warn(`Hook 'onLint' threw: ${err.message}`); }
178
+ }
179
+ }
180
+
181
+ process.stdout.write(`\n${prefix}${totalFixes} fix${totalFixes !== 1 ? 'es' : ''} applied across ${fixable.length} file(s).\n`);
182
+ }
183
+
@@ -0,0 +1,55 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
3
+ import { asString, toRepoPath, die } from './util.mjs';
4
+ import { collectDocFiles } from './index.mjs';
5
+ import { updateFrontmatter } from './lifecycle.mjs';
6
+ import { bold, green, dim } from './color.mjs';
7
+
8
+ export function runMigrate(argv, config, opts = {}) {
9
+ const { dryRun } = opts;
10
+
11
+ // Parse positional args (skip flags)
12
+ const positional = [];
13
+ for (let i = 0; i < argv.length; i++) {
14
+ if (argv[i].startsWith('-')) continue;
15
+ positional.push(argv[i]);
16
+ }
17
+
18
+ const field = positional[0];
19
+ const oldValue = positional[1];
20
+ const newValue = positional[2];
21
+
22
+ if (!field || !oldValue || !newValue) {
23
+ die('Usage: dotmd migrate <field> <old-value> <new-value>');
24
+ }
25
+
26
+ const allFiles = collectDocFiles(config);
27
+ const matches = [];
28
+
29
+ for (const filePath of allFiles) {
30
+ const raw = readFileSync(filePath, 'utf8');
31
+ const { frontmatter } = extractFrontmatter(raw);
32
+ if (!frontmatter) continue;
33
+ const parsed = parseSimpleFrontmatter(frontmatter);
34
+ const current = asString(parsed[field]);
35
+ if (current === oldValue) {
36
+ matches.push({ filePath, repoPath: toRepoPath(filePath, config.repoRoot) });
37
+ }
38
+ }
39
+
40
+ if (matches.length === 0) {
41
+ process.stdout.write(`No docs found with ${bold(field)}: ${oldValue}\n`);
42
+ return;
43
+ }
44
+
45
+ const prefix = dryRun ? dim('[dry-run] ') : '';
46
+
47
+ for (const { filePath, repoPath } of matches) {
48
+ if (!dryRun) {
49
+ updateFrontmatter(filePath, { [field]: newValue });
50
+ }
51
+ process.stdout.write(`${prefix}${green('Updated')}: ${repoPath} (${field}: ${oldValue} → ${newValue})\n`);
52
+ }
53
+
54
+ process.stdout.write(`\n${prefix}${matches.length} file(s) ${dryRun ? 'would be ' : ''}updated.\n`);
55
+ }
package/src/new.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { toRepoPath, die } from './util.mjs';
3
+ import { toRepoPath, die, warn } from './util.mjs';
4
4
  import { green, dim } from './color.mjs';
5
5
 
6
6
  export function runNew(argv, config, opts = {}) {
@@ -17,17 +17,16 @@ export function runNew(argv, config, opts = {}) {
17
17
  }
18
18
 
19
19
  const name = positional[0];
20
- if (!name) { die('Usage: dotmd new <name> [--status <s>] [--title <t>]'); return; }
20
+ if (!name) { die('Usage: dotmd new <name> [--status <s>] [--title <t>]'); }
21
21
 
22
22
  // Validate status
23
23
  if (!config.validStatuses.has(status)) {
24
24
  die(`Invalid status: ${status}\nValid: ${[...config.validStatuses].join(', ')}`);
25
- return;
26
25
  }
27
26
 
28
27
  // Slugify
29
28
  const slug = name.toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
30
- if (!slug) { die('Name resolves to empty slug: ' + name); return; }
29
+ if (!slug) { die('Name resolves to empty slug: ' + name); }
31
30
 
32
31
  // Title
33
32
  const docTitle = title ?? name.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
@@ -38,7 +37,6 @@ export function runNew(argv, config, opts = {}) {
38
37
 
39
38
  if (existsSync(filePath)) {
40
39
  die(`File already exists: ${repoPath}`);
41
- return;
42
40
  }
43
41
 
44
42
  if (dryRun) {
@@ -51,4 +49,6 @@ export function runNew(argv, config, opts = {}) {
51
49
 
52
50
  writeFileSync(filePath, content, 'utf8');
53
51
  process.stdout.write(`${green('Created')}: ${repoPath}\n`);
52
+
53
+ try { config.hooks.onNew?.({ path: repoPath, status, title: docTitle }); } catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
54
54
  }
package/src/rename.mjs ADDED
@@ -0,0 +1,146 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
4
+ import { toRepoPath, resolveDocPath, die, warn } from './util.mjs';
5
+ import { collectDocFiles } from './index.mjs';
6
+ import { gitMv } from './git.mjs';
7
+ import { green, dim, yellow } from './color.mjs';
8
+
9
+ export function runRename(argv, config, opts = {}) {
10
+ const { dryRun } = opts;
11
+
12
+ // Parse positional args (skip flags)
13
+ const positional = [];
14
+ for (let i = 0; i < argv.length; i++) {
15
+ if (argv[i].startsWith('-')) continue;
16
+ positional.push(argv[i]);
17
+ }
18
+
19
+ const oldInput = positional[0];
20
+ const newInput = positional[1];
21
+
22
+ if (!oldInput || !newInput) {
23
+ die('Usage: dotmd rename <old> <new>');
24
+ }
25
+
26
+ // Resolve old path
27
+ const oldPath = resolveDocPath(oldInput, config);
28
+ if (!oldPath) {
29
+ die(`File not found: ${oldInput}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`);
30
+ }
31
+
32
+ // Compute new path in same directory as old
33
+ const oldDir = path.dirname(oldPath);
34
+ let newBasename = newInput;
35
+ // If newInput contains a path separator, use just the basename
36
+ if (newInput.includes('/') || newInput.includes(path.sep)) {
37
+ newBasename = path.basename(newInput);
38
+ }
39
+ // Add .md if not present
40
+ if (!newBasename.endsWith('.md')) {
41
+ newBasename += '.md';
42
+ }
43
+ const newPath = path.join(oldDir, newBasename);
44
+
45
+ if (existsSync(newPath)) {
46
+ die(`Target already exists: ${toRepoPath(newPath, config.repoRoot)}`);
47
+ return;
48
+ }
49
+
50
+ const oldRepoPath = toRepoPath(oldPath, config.repoRoot);
51
+ const newRepoPath = toRepoPath(newPath, config.repoRoot);
52
+ const oldBasename = path.basename(oldPath);
53
+
54
+ // Scan for references in other docs
55
+ const allFiles = collectDocFiles(config);
56
+ const allRefFields = [
57
+ ...(config.referenceFields.bidirectional || []),
58
+ ...(config.referenceFields.unidirectional || []),
59
+ ];
60
+ const refUpdates = [];
61
+ const bodyWarnings = [];
62
+
63
+ for (const filePath of allFiles) {
64
+ if (filePath === oldPath) continue;
65
+ const raw = readFileSync(filePath, 'utf8');
66
+ const { frontmatter, body } = extractFrontmatter(raw);
67
+ if (!frontmatter) continue;
68
+
69
+ // Check frontmatter reference fields for old basename
70
+ let hasRef = false;
71
+ for (const line of frontmatter.split('\n')) {
72
+ if (line.includes(oldBasename)) {
73
+ hasRef = true;
74
+ break;
75
+ }
76
+ }
77
+
78
+ if (hasRef) {
79
+ refUpdates.push(filePath);
80
+ }
81
+
82
+ // Check body for markdown links containing old basename
83
+ if (body && body.includes(oldBasename)) {
84
+ bodyWarnings.push(toRepoPath(filePath, config.repoRoot));
85
+ }
86
+ }
87
+
88
+ if (dryRun) {
89
+ const prefix = dim('[dry-run]');
90
+ process.stdout.write(`${prefix} Would rename: ${oldRepoPath} → ${newRepoPath}\n`);
91
+ if (refUpdates.length > 0) {
92
+ process.stdout.write(`${prefix} Would update references in ${refUpdates.length} file(s):\n`);
93
+ for (const f of refUpdates) {
94
+ process.stdout.write(`${prefix} ${toRepoPath(f, config.repoRoot)}\n`);
95
+ }
96
+ }
97
+ if (bodyWarnings.length > 0) {
98
+ process.stdout.write(`\n${yellow('Body links referencing old name')} (manual update needed):\n`);
99
+ for (const p of bodyWarnings) {
100
+ process.stdout.write(` ${p}\n`);
101
+ }
102
+ }
103
+ return;
104
+ }
105
+
106
+ // Perform git mv
107
+ const result = gitMv(oldPath, newPath, config.repoRoot);
108
+ if (result.status !== 0) {
109
+ die(result.stderr || 'git mv failed.');
110
+ }
111
+
112
+ // Update references in frontmatter of other docs
113
+ let updatedCount = 0;
114
+ for (const filePath of refUpdates) {
115
+ let raw = readFileSync(filePath, 'utf8');
116
+ const { frontmatter: fm } = extractFrontmatter(raw);
117
+ if (!fm) continue;
118
+
119
+ const newFm = fm.split('\n').map(line => {
120
+ if (line.includes(oldBasename)) {
121
+ return line.split(oldBasename).join(newBasename);
122
+ }
123
+ return line;
124
+ }).join('\n');
125
+
126
+ if (newFm !== fm) {
127
+ raw = replaceFrontmatter(raw, newFm);
128
+ writeFileSync(filePath, raw, 'utf8');
129
+ updatedCount++;
130
+ }
131
+ }
132
+
133
+ process.stdout.write(`${green('Renamed')}: ${oldRepoPath} → ${newRepoPath}\n`);
134
+ if (updatedCount > 0) {
135
+ process.stdout.write(`Updated references in ${updatedCount} file(s).\n`);
136
+ }
137
+
138
+ if (bodyWarnings.length > 0) {
139
+ process.stdout.write(`\n${yellow('Body links referencing old name')} (manual update needed):\n`);
140
+ for (const p of bodyWarnings) {
141
+ process.stdout.write(` ${p}\n`);
142
+ }
143
+ }
144
+
145
+ try { config.hooks.onRename?.({ oldPath: oldRepoPath, newPath: newRepoPath, referencesUpdated: updatedCount }); } catch (err) { warn(`Hook 'onRename' threw: ${err.message}`); }
146
+ }
package/src/render.mjs CHANGED
@@ -1,10 +1,11 @@
1
- import { capitalize, toSlug, truncate } from './util.mjs';
1
+ import { capitalize, toSlug, truncate, warn } from './util.mjs';
2
2
  import { bold, red, yellow, green } from './color.mjs';
3
3
 
4
4
  export function renderCompactList(index, config) {
5
5
  const defaultRenderer = (idx) => _renderCompactList(idx, config);
6
6
  if (config.hooks.renderCompactList) {
7
- return config.hooks.renderCompactList(index, defaultRenderer);
7
+ try { return config.hooks.renderCompactList(index, defaultRenderer); }
8
+ catch (err) { warn(`Hook 'renderCompactList' threw: ${err.message}`); }
8
9
  }
9
10
  return defaultRenderer(index);
10
11
  }
@@ -63,7 +64,8 @@ export function renderVerboseList(index, config) {
63
64
  export function renderContext(index, config) {
64
65
  const defaultRenderer = (idx) => _renderContext(idx, config);
65
66
  if (config.hooks.renderContext) {
66
- return config.hooks.renderContext(index, defaultRenderer);
67
+ try { return config.hooks.renderContext(index, defaultRenderer); }
68
+ catch (err) { warn(`Hook 'renderContext' threw: ${err.message}`); }
67
69
  }
68
70
  return defaultRenderer(index);
69
71
  }
@@ -144,7 +146,8 @@ function _renderContext(index, config) {
144
146
  export function renderCheck(index, config) {
145
147
  const defaultRenderer = (idx) => _renderCheck(idx);
146
148
  if (config.hooks.renderCheck) {
147
- return config.hooks.renderCheck(index, defaultRenderer);
149
+ try { return config.hooks.renderCheck(index, defaultRenderer); }
150
+ catch (err) { warn(`Hook 'renderCheck' threw: ${err.message}`); }
148
151
  }
149
152
  return defaultRenderer(index);
150
153
  }
@@ -234,7 +237,8 @@ export function renderProgressBar(checklist) {
234
237
  export function formatSnapshot(doc, config) {
235
238
  const defaultFormatter = (d) => _formatSnapshot(d);
236
239
  if (config.hooks.formatSnapshot) {
237
- return config.hooks.formatSnapshot(doc, defaultFormatter);
240
+ try { return config.hooks.formatSnapshot(doc, defaultFormatter); }
241
+ catch (err) { warn(`Hook 'formatSnapshot' threw: ${err.message}`); }
238
242
  }
239
243
  return defaultFormatter(doc);
240
244
  }
package/src/util.mjs CHANGED
@@ -47,6 +47,10 @@ export function mergeUniqueStrings(...lists) {
47
47
  return [...new Set(lists.flat().filter(Boolean))];
48
48
  }
49
49
 
50
+ export function escapeRegex(str) {
51
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
52
+ }
53
+
50
54
  export function toRepoPath(absolutePath, repoRoot) {
51
55
  return path.relative(repoRoot, absolutePath).split(path.sep).join('/');
52
56
  }
@@ -55,9 +59,15 @@ export function warn(message) {
55
59
  process.stderr.write(`${dim(message)}\n`);
56
60
  }
57
61
 
62
+ export class DotmdError extends Error {
63
+ constructor(message) {
64
+ super(message);
65
+ this.name = 'DotmdError';
66
+ }
67
+ }
68
+
58
69
  export function die(message) {
59
- process.stderr.write(`${message}\n`);
60
- process.exitCode = 1;
70
+ throw new DotmdError(message);
61
71
  }
62
72
 
63
73
  export function resolveDocPath(input, config) {