dotmd-cli 0.3.0 → 0.4.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 +53 -1
- package/bin/dotmd.mjs +42 -0
- package/package.json +1 -1
- package/src/completions.mjs +5 -1
- package/src/config.mjs +48 -0
- package/src/diff.mjs +3 -1
- package/src/lint.mjs +186 -0
- package/src/migrate.mjs +56 -0
- package/src/new.mjs +2 -0
- package/src/rename.mjs +149 -0
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
|
|
package/package.json
CHANGED
package/src/completions.mjs
CHANGED
|
@@ -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', '
|
|
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() {
|
package/src/config.mjs
CHANGED
|
@@ -76,6 +76,50 @@ 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
|
+
// taxonomy.surfaces must be null or array
|
|
104
|
+
if (config.taxonomy?.surfaces !== undefined && config.taxonomy.surfaces !== null && !Array.isArray(config.taxonomy.surfaces)) {
|
|
105
|
+
warnings.push('Config: taxonomy.surfaces must be null or an array.');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// index path file exists (if configured)
|
|
109
|
+
if (indexPath && !existsSync(indexPath)) {
|
|
110
|
+
warnings.push(`Config: index path does not exist: ${indexPath}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Unknown top-level user config keys
|
|
114
|
+
for (const key of Object.keys(userConfig)) {
|
|
115
|
+
if (!VALID_CONFIG_KEYS.has(key)) {
|
|
116
|
+
warnings.push(`Config: unknown key '${key}'.`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return warnings;
|
|
121
|
+
}
|
|
122
|
+
|
|
79
123
|
function deepMerge(defaults, overrides) {
|
|
80
124
|
const result = { ...defaults };
|
|
81
125
|
for (const [key, value] of Object.entries(overrides)) {
|
|
@@ -119,6 +163,7 @@ export async function resolveConfig(cwd, explicitConfigPath) {
|
|
|
119
163
|
indexPath: null, indexStartMarker: '<!-- GENERATED:dotmd:start -->', indexEndMarker: '<!-- GENERATED:dotmd:end -->', archivedHighlightLimit: 8,
|
|
120
164
|
context: defaults.context, display: defaults.display,
|
|
121
165
|
referenceFields: defaults.referenceFields, presets: defaults.presets, hooks: {},
|
|
166
|
+
configWarnings: [],
|
|
122
167
|
};
|
|
123
168
|
}
|
|
124
169
|
|
|
@@ -188,6 +233,8 @@ export async function resolveConfig(cwd, explicitConfigPath) {
|
|
|
188
233
|
const skipStaleFor = new Set(lifecycle.skipStaleFor);
|
|
189
234
|
const skipWarningsFor = new Set(lifecycle.skipWarningsFor);
|
|
190
235
|
|
|
236
|
+
const configWarnings = validateConfig(userConfig, config, validStatuses, indexPath);
|
|
237
|
+
|
|
191
238
|
return {
|
|
192
239
|
raw: config,
|
|
193
240
|
|
|
@@ -219,5 +266,6 @@ export async function resolveConfig(cwd, explicitConfigPath) {
|
|
|
219
266
|
referenceFields: config.referenceFields,
|
|
220
267
|
presets: config.presets,
|
|
221
268
|
hooks,
|
|
269
|
+
configWarnings,
|
|
222
270
|
};
|
|
223
271
|
}
|
package/src/diff.mjs
CHANGED
|
@@ -80,7 +80,9 @@ function printFileDiff(relPath, since, diffOutput, opts) {
|
|
|
80
80
|
process.stdout.write(bold(relPath) + dim(` (updated: ${since})`) + '\n');
|
|
81
81
|
|
|
82
82
|
if (opts.summarize) {
|
|
83
|
-
const summary =
|
|
83
|
+
const summary = opts.config?.hooks?.summarizeDiff
|
|
84
|
+
? opts.config.hooks.summarizeDiff(diffOutput, relPath)
|
|
85
|
+
: summarizeWithMLX(diffOutput, relPath, opts.model);
|
|
84
86
|
if (summary) {
|
|
85
87
|
process.stdout.write(dim(` Summary: ${summary}`) + '\n');
|
|
86
88
|
} else {
|
package/src/lint.mjs
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
3
|
+
import { asString, toRepoPath } 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(`^${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 = raw.replace(`---\n${fm}\n---`, `---\n${newFm}\n---`);
|
|
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
|
+
config.hooks.onLint?.({ path: repoPath, fixes });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
process.stdout.write(`\n${prefix}${totalFixes} fix${totalFixes !== 1 ? 'es' : ''} applied across ${fixable.length} file(s).\n`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function escapeRegex(str) {
|
|
185
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
186
|
+
}
|
package/src/migrate.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const allFiles = collectDocFiles(config);
|
|
28
|
+
const matches = [];
|
|
29
|
+
|
|
30
|
+
for (const filePath of allFiles) {
|
|
31
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
32
|
+
const { frontmatter } = extractFrontmatter(raw);
|
|
33
|
+
if (!frontmatter) continue;
|
|
34
|
+
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
35
|
+
const current = asString(parsed[field]);
|
|
36
|
+
if (current === oldValue) {
|
|
37
|
+
matches.push({ filePath, repoPath: toRepoPath(filePath, config.repoRoot) });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (matches.length === 0) {
|
|
42
|
+
process.stdout.write(`No docs found with ${bold(field)}: ${oldValue}\n`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
47
|
+
|
|
48
|
+
for (const { filePath, repoPath } of matches) {
|
|
49
|
+
if (!dryRun) {
|
|
50
|
+
updateFrontmatter(filePath, { [field]: newValue });
|
|
51
|
+
}
|
|
52
|
+
process.stdout.write(`${prefix}${green('Updated')}: ${repoPath} (${field}: ${oldValue} → ${newValue})\n`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
process.stdout.write(`\n${prefix}${matches.length} file(s) ${dryRun ? 'would be ' : ''}updated.\n`);
|
|
56
|
+
}
|
package/src/new.mjs
CHANGED
package/src/rename.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractFrontmatter, parseSimpleFrontmatter } 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
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Resolve old path
|
|
28
|
+
const oldPath = resolveDocPath(oldInput, config);
|
|
29
|
+
if (!oldPath) {
|
|
30
|
+
die(`File not found: ${oldInput}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Compute new path in same directory as old
|
|
35
|
+
const oldDir = path.dirname(oldPath);
|
|
36
|
+
let newBasename = newInput;
|
|
37
|
+
// If newInput contains a path separator, use just the basename
|
|
38
|
+
if (newInput.includes('/') || newInput.includes(path.sep)) {
|
|
39
|
+
newBasename = path.basename(newInput);
|
|
40
|
+
}
|
|
41
|
+
// Add .md if not present
|
|
42
|
+
if (!newBasename.endsWith('.md')) {
|
|
43
|
+
newBasename += '.md';
|
|
44
|
+
}
|
|
45
|
+
const newPath = path.join(oldDir, newBasename);
|
|
46
|
+
|
|
47
|
+
if (existsSync(newPath)) {
|
|
48
|
+
die(`Target already exists: ${toRepoPath(newPath, config.repoRoot)}`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const oldRepoPath = toRepoPath(oldPath, config.repoRoot);
|
|
53
|
+
const newRepoPath = toRepoPath(newPath, config.repoRoot);
|
|
54
|
+
const oldBasename = path.basename(oldPath);
|
|
55
|
+
|
|
56
|
+
// Scan for references in other docs
|
|
57
|
+
const allFiles = collectDocFiles(config);
|
|
58
|
+
const allRefFields = [
|
|
59
|
+
...(config.referenceFields.bidirectional || []),
|
|
60
|
+
...(config.referenceFields.unidirectional || []),
|
|
61
|
+
];
|
|
62
|
+
const refUpdates = [];
|
|
63
|
+
const bodyWarnings = [];
|
|
64
|
+
|
|
65
|
+
for (const filePath of allFiles) {
|
|
66
|
+
if (filePath === oldPath) continue;
|
|
67
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
68
|
+
const { frontmatter, body } = extractFrontmatter(raw);
|
|
69
|
+
if (!frontmatter) continue;
|
|
70
|
+
|
|
71
|
+
// Check frontmatter reference fields for old basename
|
|
72
|
+
let hasRef = false;
|
|
73
|
+
for (const line of frontmatter.split('\n')) {
|
|
74
|
+
if (line.includes(oldBasename)) {
|
|
75
|
+
hasRef = true;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (hasRef) {
|
|
81
|
+
refUpdates.push(filePath);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Check body for markdown links containing old basename
|
|
85
|
+
if (body && body.includes(oldBasename)) {
|
|
86
|
+
bodyWarnings.push(toRepoPath(filePath, config.repoRoot));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (dryRun) {
|
|
91
|
+
const prefix = dim('[dry-run]');
|
|
92
|
+
process.stdout.write(`${prefix} Would rename: ${oldRepoPath} → ${newRepoPath}\n`);
|
|
93
|
+
if (refUpdates.length > 0) {
|
|
94
|
+
process.stdout.write(`${prefix} Would update references in ${refUpdates.length} file(s):\n`);
|
|
95
|
+
for (const f of refUpdates) {
|
|
96
|
+
process.stdout.write(`${prefix} ${toRepoPath(f, config.repoRoot)}\n`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (bodyWarnings.length > 0) {
|
|
100
|
+
process.stdout.write(`\n${yellow('Body links referencing old name')} (manual update needed):\n`);
|
|
101
|
+
for (const p of bodyWarnings) {
|
|
102
|
+
process.stdout.write(` ${p}\n`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Perform git mv
|
|
109
|
+
const result = gitMv(oldPath, newPath, config.repoRoot);
|
|
110
|
+
if (result.status !== 0) {
|
|
111
|
+
die(result.stderr || 'git mv failed.');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Update references in frontmatter of other docs
|
|
116
|
+
let updatedCount = 0;
|
|
117
|
+
for (const filePath of refUpdates) {
|
|
118
|
+
let raw = readFileSync(filePath, 'utf8');
|
|
119
|
+
const { frontmatter: fm } = extractFrontmatter(raw);
|
|
120
|
+
if (!fm) continue;
|
|
121
|
+
|
|
122
|
+
const newFm = fm.split('\n').map(line => {
|
|
123
|
+
if (line.includes(oldBasename)) {
|
|
124
|
+
return line.split(oldBasename).join(newBasename);
|
|
125
|
+
}
|
|
126
|
+
return line;
|
|
127
|
+
}).join('\n');
|
|
128
|
+
|
|
129
|
+
if (newFm !== fm) {
|
|
130
|
+
raw = raw.replace(`---\n${fm}\n---`, `---\n${newFm}\n---`);
|
|
131
|
+
writeFileSync(filePath, raw, 'utf8');
|
|
132
|
+
updatedCount++;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
process.stdout.write(`${green('Renamed')}: ${oldRepoPath} → ${newRepoPath}\n`);
|
|
137
|
+
if (updatedCount > 0) {
|
|
138
|
+
process.stdout.write(`Updated references in ${updatedCount} file(s).\n`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (bodyWarnings.length > 0) {
|
|
142
|
+
process.stdout.write(`\n${yellow('Body links referencing old name')} (manual update needed):\n`);
|
|
143
|
+
for (const p of bodyWarnings) {
|
|
144
|
+
process.stdout.write(` ${p}\n`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
config.hooks.onRename?.({ oldPath: oldRepoPath, newPath: newRepoPath, referencesUpdated: updatedCount });
|
|
149
|
+
}
|