dotmd-cli 0.2.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 +124 -10
- package/bin/dotmd.mjs +71 -0
- package/package.json +1 -1
- package/src/completions.mjs +6 -1
- package/src/config.mjs +48 -0
- package/src/diff.mjs +116 -0
- package/src/git.mjs +17 -0
- package/src/lifecycle.mjs +1 -14
- 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/src/util.mjs +14 -0
- package/src/watch.mjs +48 -0
package/README.md
CHANGED
|
@@ -7,16 +7,28 @@ Index, query, validate, and lifecycle-manage any collection of `.md` files — p
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npm install -g dotmd-cli
|
|
10
|
+
npm install -g dotmd-cli # global — use `dotmd` anywhere
|
|
11
|
+
npm install -D dotmd-cli # project devDep — use via npm scripts
|
|
11
12
|
```
|
|
12
13
|
|
|
13
14
|
## Quick Start
|
|
14
15
|
|
|
15
16
|
```bash
|
|
16
|
-
dotmd init
|
|
17
|
-
dotmd
|
|
18
|
-
dotmd
|
|
19
|
-
dotmd
|
|
17
|
+
dotmd init # creates dotmd.config.mjs, docs/, docs/docs.md
|
|
18
|
+
dotmd new my-feature # scaffold a new doc with frontmatter
|
|
19
|
+
dotmd list # index all docs grouped by status
|
|
20
|
+
dotmd check # validate frontmatter and references
|
|
21
|
+
dotmd context # compact briefing (great for LLM context)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Shell Completion
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# bash
|
|
28
|
+
eval "$(dotmd completions bash)" # add to ~/.bashrc
|
|
29
|
+
|
|
30
|
+
# zsh
|
|
31
|
+
eval "$(dotmd completions zsh)" # add to ~/.zshrc
|
|
20
32
|
```
|
|
21
33
|
|
|
22
34
|
## What It Does
|
|
@@ -27,8 +39,10 @@ dotmd scans a directory of markdown files, parses their YAML frontmatter, and gi
|
|
|
27
39
|
- **Query** — filter by status, keyword, module, surface, owner, staleness
|
|
28
40
|
- **Validate** — check for missing fields, broken references, stale dates
|
|
29
41
|
- **Lifecycle** — transition statuses, auto-archive with `git mv`, bump dates
|
|
30
|
-
- **
|
|
42
|
+
- **Scaffold** — create new docs with frontmatter from the command line
|
|
43
|
+
- **Index generation** — auto-generate a `docs.md` index block
|
|
31
44
|
- **Context briefing** — compact summary designed for AI/LLM consumption
|
|
45
|
+
- **Dry-run** — preview any mutation with `--dry-run` before committing
|
|
32
46
|
|
|
33
47
|
## Document Format
|
|
34
48
|
|
|
@@ -58,7 +72,7 @@ The only required field is `status`. Everything else is optional but unlocks mor
|
|
|
58
72
|
## Commands
|
|
59
73
|
|
|
60
74
|
```
|
|
61
|
-
dotmd list [--verbose] List docs grouped by status
|
|
75
|
+
dotmd list [--verbose] List docs grouped by status (default)
|
|
62
76
|
dotmd json Full index as JSON
|
|
63
77
|
dotmd check Validate frontmatter and references
|
|
64
78
|
dotmd coverage [--json] Metadata coverage report
|
|
@@ -69,7 +83,24 @@ dotmd index [--write] Generate/update docs.md index block
|
|
|
69
83
|
dotmd status <file> <status> Transition document status
|
|
70
84
|
dotmd archive <file> Archive (status + move + index regen)
|
|
71
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
|
|
89
|
+
dotmd watch [command] Re-run a command on file changes
|
|
90
|
+
dotmd diff [file] Show changes since last updated date
|
|
91
|
+
dotmd new <name> Create a new document with frontmatter
|
|
72
92
|
dotmd init Create starter config + docs directory
|
|
93
|
+
dotmd completions <shell> Output shell completion script (bash, zsh)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Global Flags
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
--config <path> Explicit config file path
|
|
100
|
+
--dry-run, -n Preview changes without writing anything
|
|
101
|
+
--verbose Show resolved config details
|
|
102
|
+
--help, -h Show help (per-command with: dotmd <cmd> --help)
|
|
103
|
+
--version, -v Show version
|
|
73
104
|
```
|
|
74
105
|
|
|
75
106
|
### Query Filters
|
|
@@ -83,6 +114,15 @@ dotmd query --surface backend --checklist-open
|
|
|
83
114
|
|
|
84
115
|
Flags: `--status`, `--keyword`, `--module`, `--surface`, `--domain`, `--owner`, `--updated-since`, `--stale`, `--has-next-step`, `--has-blockers`, `--checklist-open`, `--sort`, `--limit`, `--all`, `--git`, `--json`.
|
|
85
116
|
|
|
117
|
+
### Scaffold a Document
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
dotmd new my-feature # creates docs/my-feature.md (status: active)
|
|
121
|
+
dotmd new "API Redesign" --status planned # custom status
|
|
122
|
+
dotmd new auth-refresh --title "Auth Refresh" # custom title
|
|
123
|
+
dotmd new something --dry-run # preview without creating
|
|
124
|
+
```
|
|
125
|
+
|
|
86
126
|
### Preset Aliases
|
|
87
127
|
|
|
88
128
|
Define custom query presets in your config:
|
|
@@ -96,6 +136,67 @@ export const presets = {
|
|
|
96
136
|
|
|
97
137
|
Then run `dotmd stale` or `dotmd mine` as shorthand.
|
|
98
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
|
+
|
|
177
|
+
### Watch Mode
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
dotmd watch # re-run list on every .md change
|
|
181
|
+
dotmd watch check # live validation
|
|
182
|
+
dotmd watch context # live briefing
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Diff & Summarize
|
|
186
|
+
|
|
187
|
+
Show git changes since each document's `updated` frontmatter date:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
dotmd diff # all drifted docs
|
|
191
|
+
dotmd diff docs/plans/auth.md # single file
|
|
192
|
+
dotmd diff --stat # summary stats only
|
|
193
|
+
dotmd diff --since 2026-01-01 # override date
|
|
194
|
+
dotmd diff --summarize # AI summary via local MLX model
|
|
195
|
+
dotmd diff --summarize --model mlx-community/Mistral-7B-Instruct-v0.3-4bit
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The `--summarize` flag requires `uv` and a local MLX-compatible model. No JS dependencies are added.
|
|
199
|
+
|
|
99
200
|
## Configuration
|
|
100
201
|
|
|
101
202
|
Create `dotmd.config.mjs` at your project root (or run `dotmd init`):
|
|
@@ -115,8 +216,8 @@ export const lifecycle = {
|
|
|
115
216
|
skipWarningsFor: ['archived'],
|
|
116
217
|
};
|
|
117
218
|
|
|
118
|
-
export const
|
|
119
|
-
path: 'docs/
|
|
219
|
+
export const index = {
|
|
220
|
+
path: 'docs/docs.md',
|
|
120
221
|
startMarker: '<!-- GENERATED:dotmd:start -->',
|
|
121
222
|
endMarker: '<!-- GENERATED:dotmd:end -->',
|
|
122
223
|
};
|
|
@@ -171,16 +272,29 @@ export function onStatusChange(doc, { oldStatus, newStatus }) {
|
|
|
171
272
|
}
|
|
172
273
|
```
|
|
173
274
|
|
|
174
|
-
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
|
+
```
|
|
175
287
|
|
|
176
288
|
## Features
|
|
177
289
|
|
|
178
290
|
- **Zero dependencies** — pure Node.js builtins (`fs`, `path`, `child_process`)
|
|
179
291
|
- **No build step** — ships as plain ESM, runs directly
|
|
180
292
|
- **Git-aware** — detects frontmatter date drift vs git history, uses `git mv` for archives
|
|
293
|
+
- **Dry-run everything** — preview any mutation with `--dry-run` / `-n`
|
|
181
294
|
- **Configurable everything** — statuses, taxonomy, lifecycle, validation rules, display
|
|
182
295
|
- **Hook system** — extend with JS functions, no plugin framework to learn
|
|
183
296
|
- **LLM-friendly** — `dotmd context` generates compact briefings for AI assistants
|
|
297
|
+
- **Shell completion** — bash and zsh via `dotmd completions`
|
|
184
298
|
|
|
185
299
|
## License
|
|
186
300
|
|
package/bin/dotmd.mjs
CHANGED
|
@@ -12,6 +12,11 @@ import { runStatus, runArchive, runTouch } from '../src/lifecycle.mjs';
|
|
|
12
12
|
import { runInit } from '../src/init.mjs';
|
|
13
13
|
import { runNew } from '../src/new.mjs';
|
|
14
14
|
import { runCompletions } from '../src/completions.mjs';
|
|
15
|
+
import { runWatch } from '../src/watch.mjs';
|
|
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';
|
|
15
20
|
import { die, warn } from '../src/util.mjs';
|
|
16
21
|
|
|
17
22
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -33,6 +38,11 @@ Commands:
|
|
|
33
38
|
status <file> <status> Transition document status
|
|
34
39
|
archive <file> Archive (status + move + index regen)
|
|
35
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
|
|
44
|
+
watch [command] Re-run a command on file changes
|
|
45
|
+
diff [file] Show changes since last updated date
|
|
36
46
|
new <name> Create a new document with frontmatter
|
|
37
47
|
init Create starter config + docs directory
|
|
38
48
|
completions <shell> Output shell completion script (bash, zsh)
|
|
@@ -97,6 +107,54 @@ Options:
|
|
|
97
107
|
The filename is derived from <name> by slugifying it.
|
|
98
108
|
Use --dry-run (-n) to preview without creating the file.`,
|
|
99
109
|
|
|
110
|
+
watch: `dotmd watch [command] — re-run a command on file changes
|
|
111
|
+
|
|
112
|
+
Watches the docs root for .md file changes and re-runs the specified
|
|
113
|
+
command. Defaults to 'list' if no command given.
|
|
114
|
+
|
|
115
|
+
Examples:
|
|
116
|
+
dotmd watch # re-run list on changes
|
|
117
|
+
dotmd watch check # re-run check on changes
|
|
118
|
+
dotmd watch context # live briefing`,
|
|
119
|
+
|
|
120
|
+
diff: `dotmd diff [file] — show changes since last updated date
|
|
121
|
+
|
|
122
|
+
Shows git diffs for docs that changed after their frontmatter updated date.
|
|
123
|
+
Without a file argument, shows all drifted docs.
|
|
124
|
+
|
|
125
|
+
Options:
|
|
126
|
+
--stat Summary only (files changed, insertions/deletions)
|
|
127
|
+
--since <date> Override: diff since this date instead of frontmatter
|
|
128
|
+
--summarize Generate AI summary using local MLX model
|
|
129
|
+
--model <name> MLX model to use (default: mlx-community/Llama-3.2-3B-Instruct-4bit)`,
|
|
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
|
+
|
|
100
158
|
init: `dotmd init — create starter config and docs directory
|
|
101
159
|
|
|
102
160
|
Creates dotmd.config.mjs, docs/, and docs/docs.md in the current
|
|
@@ -154,6 +212,12 @@ async function main() {
|
|
|
154
212
|
warn('No dotmd config found — using defaults. Run `dotmd init` to create one.');
|
|
155
213
|
}
|
|
156
214
|
|
|
215
|
+
if (config.configWarnings && config.configWarnings.length > 0) {
|
|
216
|
+
for (const w of config.configWarnings) {
|
|
217
|
+
warn(w);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
157
221
|
if (verbose) {
|
|
158
222
|
process.stderr.write(`Config: ${config.configPath ?? 'none'}\n`);
|
|
159
223
|
process.stderr.write(`Docs root: ${config.docsRoot}\n`);
|
|
@@ -167,11 +231,18 @@ async function main() {
|
|
|
167
231
|
return;
|
|
168
232
|
}
|
|
169
233
|
|
|
234
|
+
// Watch and diff (handle their own index building)
|
|
235
|
+
if (command === 'watch') { runWatch(restArgs, config); return; }
|
|
236
|
+
if (command === 'diff') { runDiff(restArgs, config); return; }
|
|
237
|
+
|
|
170
238
|
// Lifecycle commands
|
|
171
239
|
if (command === 'status') { runStatus(restArgs, config, { dryRun }); return; }
|
|
172
240
|
if (command === 'archive') { runArchive(restArgs, config, { dryRun }); return; }
|
|
173
241
|
if (command === 'touch') { runTouch(restArgs, config, { dryRun }); return; }
|
|
174
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; }
|
|
175
246
|
|
|
176
247
|
const index = buildIndex(config);
|
|
177
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'];
|
|
@@ -15,6 +16,10 @@ const COMMAND_FLAGS = {
|
|
|
15
16
|
list: ['--verbose'],
|
|
16
17
|
coverage: ['--json'],
|
|
17
18
|
new: ['--status', '--title'],
|
|
19
|
+
diff: ['--stat', '--since', '--summarize', '--model'],
|
|
20
|
+
lint: ['--fix'],
|
|
21
|
+
rename: [],
|
|
22
|
+
migrate: [],
|
|
18
23
|
};
|
|
19
24
|
|
|
20
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
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import { asString, toRepoPath, resolveDocPath, die, warn } from './util.mjs';
|
|
5
|
+
import { gitDiffSince } from './git.mjs';
|
|
6
|
+
import { buildIndex } from './index.mjs';
|
|
7
|
+
import { bold, dim, green } from './color.mjs';
|
|
8
|
+
|
|
9
|
+
export function runDiff(argv, config) {
|
|
10
|
+
// Parse flags
|
|
11
|
+
let file = null;
|
|
12
|
+
let stat = false;
|
|
13
|
+
let sinceOverride = null;
|
|
14
|
+
let summarize = false;
|
|
15
|
+
let model = 'mlx-community/Llama-3.2-3B-Instruct-4bit';
|
|
16
|
+
|
|
17
|
+
for (let i = 0; i < argv.length; i++) {
|
|
18
|
+
if (argv[i] === '--stat') { stat = true; continue; }
|
|
19
|
+
if (argv[i] === '--summarize') { summarize = true; continue; }
|
|
20
|
+
if (argv[i] === '--since' && argv[i + 1]) { sinceOverride = argv[++i]; continue; }
|
|
21
|
+
if (argv[i] === '--model' && argv[i + 1]) { model = argv[++i]; continue; }
|
|
22
|
+
if (!argv[i].startsWith('-')) { file = argv[i]; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (file) {
|
|
26
|
+
// Single file mode
|
|
27
|
+
const filePath = resolveDocPath(file, config);
|
|
28
|
+
if (!filePath) {
|
|
29
|
+
die(`File not found: ${file}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
34
|
+
const { frontmatter } = extractFrontmatter(raw);
|
|
35
|
+
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
36
|
+
const since = sinceOverride ?? asString(parsed.updated);
|
|
37
|
+
|
|
38
|
+
if (!since) {
|
|
39
|
+
die(`No updated date found in ${file} and no --since provided.`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const relPath = toRepoPath(filePath, config.repoRoot);
|
|
44
|
+
const diffOutput = gitDiffSince(relPath, since, config.repoRoot, { stat });
|
|
45
|
+
|
|
46
|
+
if (!diffOutput) {
|
|
47
|
+
process.stdout.write(`No changes since ${since} for ${relPath}\n`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
printFileDiff(relPath, since, diffOutput, { summarize, model, config });
|
|
52
|
+
} else {
|
|
53
|
+
// All drifted docs mode
|
|
54
|
+
const index = buildIndex(config);
|
|
55
|
+
const drifted = [];
|
|
56
|
+
|
|
57
|
+
for (const doc of index.docs) {
|
|
58
|
+
if (!doc.updated) continue;
|
|
59
|
+
const relPath = doc.path;
|
|
60
|
+
const since = sinceOverride ?? doc.updated;
|
|
61
|
+
const diffOutput = gitDiffSince(relPath, since, config.repoRoot, { stat });
|
|
62
|
+
if (diffOutput && diffOutput.trim()) {
|
|
63
|
+
drifted.push({ relPath, since, diffOutput });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (drifted.length === 0) {
|
|
68
|
+
process.stdout.write('No drifted docs.\n');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
process.stdout.write(bold(`${drifted.length} doc(s) with changes since their updated date:\n\n`));
|
|
73
|
+
for (const { relPath, since, diffOutput } of drifted) {
|
|
74
|
+
printFileDiff(relPath, since, diffOutput, { summarize, model, config });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function printFileDiff(relPath, since, diffOutput, opts) {
|
|
80
|
+
process.stdout.write(bold(relPath) + dim(` (updated: ${since})`) + '\n');
|
|
81
|
+
|
|
82
|
+
if (opts.summarize) {
|
|
83
|
+
const summary = opts.config?.hooks?.summarizeDiff
|
|
84
|
+
? opts.config.hooks.summarizeDiff(diffOutput, relPath)
|
|
85
|
+
: summarizeWithMLX(diffOutput, relPath, opts.model);
|
|
86
|
+
if (summary) {
|
|
87
|
+
process.stdout.write(dim(` Summary: ${summary}`) + '\n');
|
|
88
|
+
} else {
|
|
89
|
+
warn(' Summary unavailable (model call failed)');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
process.stdout.write(diffOutput);
|
|
94
|
+
process.stdout.write('\n');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function summarizeWithMLX(diffText, filePath, model) {
|
|
98
|
+
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)}`;
|
|
99
|
+
|
|
100
|
+
const result = spawnSync('uv', [
|
|
101
|
+
'run', '--with', 'mlx-lm',
|
|
102
|
+
'python3', '-m', 'mlx_lm', 'generate',
|
|
103
|
+
'--model', model,
|
|
104
|
+
'--prompt', prompt,
|
|
105
|
+
'--max-tokens', '150',
|
|
106
|
+
'--verbose', 'false',
|
|
107
|
+
], { encoding: 'utf8', timeout: 120000 });
|
|
108
|
+
|
|
109
|
+
if (result.status !== 0) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const output = result.stdout.trim();
|
|
114
|
+
const lines = output.split('\n').filter(l => !l.includes('Fetching') && !l.includes('Warning:') && !l.includes('=========='));
|
|
115
|
+
return lines.join(' ').trim() || null;
|
|
116
|
+
}
|
package/src/git.mjs
CHANGED
|
@@ -16,3 +16,20 @@ export function gitMv(source, target, repoRoot) {
|
|
|
16
16
|
});
|
|
17
17
|
return { status: result.status, stderr: result.stderr };
|
|
18
18
|
}
|
|
19
|
+
|
|
20
|
+
export function gitDiffSince(relPath, sinceDate, repoRoot, opts = {}) {
|
|
21
|
+
// Find the last commit at or before sinceDate
|
|
22
|
+
const baseline = spawnSync('git', [
|
|
23
|
+
'log', '-1', '--before=' + sinceDate + 'T23:59:59', '--format=%H', '--', relPath
|
|
24
|
+
], { cwd: repoRoot, encoding: 'utf8' });
|
|
25
|
+
|
|
26
|
+
const baseRef = baseline.stdout.trim();
|
|
27
|
+
if (!baseRef) return null;
|
|
28
|
+
|
|
29
|
+
const diffArgs = ['diff', baseRef, 'HEAD'];
|
|
30
|
+
if (opts.stat) diffArgs.push('--stat');
|
|
31
|
+
diffArgs.push('--', relPath);
|
|
32
|
+
|
|
33
|
+
const result = spawnSync('git', diffArgs, { cwd: repoRoot, encoding: 'utf8' });
|
|
34
|
+
return result.stdout || null;
|
|
35
|
+
}
|
package/src/lifecycle.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, 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 } from './util.mjs';
|
|
4
|
+
import { asString, toRepoPath, die, resolveDocPath } 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';
|
|
@@ -188,19 +188,6 @@ export function runTouch(argv, config, opts = {}) {
|
|
|
188
188
|
config.hooks.onTouch?.({ path: toRepoPath(filePath, config.repoRoot) }, { path: toRepoPath(filePath, config.repoRoot), date: today });
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
-
function resolveDocPath(input, config) {
|
|
192
|
-
if (!input) return null;
|
|
193
|
-
if (path.isAbsolute(input)) return existsSync(input) ? input : null;
|
|
194
|
-
|
|
195
|
-
let candidate = path.resolve(config.repoRoot, input);
|
|
196
|
-
if (existsSync(candidate)) return candidate;
|
|
197
|
-
|
|
198
|
-
candidate = path.resolve(config.docsRoot, input);
|
|
199
|
-
if (existsSync(candidate)) return candidate;
|
|
200
|
-
|
|
201
|
-
return null;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
191
|
export function updateFrontmatter(filePath, updates) {
|
|
205
192
|
const raw = readFileSync(filePath, 'utf8');
|
|
206
193
|
if (!raw.startsWith('---\n')) throw new Error(`${filePath} has no frontmatter block.`);
|
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
|
+
}
|
package/src/util.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import { dim } from './color.mjs';
|
|
3
4
|
|
|
@@ -58,3 +59,16 @@ export function die(message) {
|
|
|
58
59
|
process.stderr.write(`${message}\n`);
|
|
59
60
|
process.exitCode = 1;
|
|
60
61
|
}
|
|
62
|
+
|
|
63
|
+
export function resolveDocPath(input, config) {
|
|
64
|
+
if (!input) return null;
|
|
65
|
+
if (path.isAbsolute(input)) return existsSync(input) ? input : null;
|
|
66
|
+
|
|
67
|
+
let candidate = path.resolve(config.repoRoot, input);
|
|
68
|
+
if (existsSync(candidate)) return candidate;
|
|
69
|
+
|
|
70
|
+
candidate = path.resolve(config.docsRoot, input);
|
|
71
|
+
if (existsSync(candidate)) return candidate;
|
|
72
|
+
|
|
73
|
+
return null;
|
|
74
|
+
}
|
package/src/watch.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { watch } from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { dim } from './color.mjs';
|
|
5
|
+
|
|
6
|
+
export function runWatch(argv, config) {
|
|
7
|
+
const subCommand = argv.length > 0 ? argv : ['list'];
|
|
8
|
+
const cliPath = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'bin', 'dotmd.mjs');
|
|
9
|
+
|
|
10
|
+
let lastRun = 0;
|
|
11
|
+
const DEBOUNCE = 300;
|
|
12
|
+
|
|
13
|
+
function run() {
|
|
14
|
+
const now = Date.now();
|
|
15
|
+
if (now - lastRun < DEBOUNCE) return;
|
|
16
|
+
lastRun = now;
|
|
17
|
+
|
|
18
|
+
// Clear terminal
|
|
19
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
20
|
+
process.stderr.write(dim(`[${new Date().toLocaleTimeString()}] dotmd ${subCommand.join(' ')}`) + '\n\n');
|
|
21
|
+
|
|
22
|
+
spawnSync(process.execPath, [cliPath, ...subCommand], {
|
|
23
|
+
stdio: 'inherit',
|
|
24
|
+
cwd: process.cwd(),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Run once immediately
|
|
29
|
+
run();
|
|
30
|
+
|
|
31
|
+
process.stderr.write(dim(`\nWatching ${config.docsRoot} for changes... (Ctrl+C to stop)`) + '\n');
|
|
32
|
+
|
|
33
|
+
// Watch for changes
|
|
34
|
+
const watcher = watch(config.docsRoot, { recursive: true }, (eventType, filename) => {
|
|
35
|
+
if (filename && filename.endsWith('.md')) {
|
|
36
|
+
run();
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Clean exit
|
|
41
|
+
process.on('SIGINT', () => {
|
|
42
|
+
watcher.close();
|
|
43
|
+
process.exit(0);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Keep alive
|
|
47
|
+
setInterval(() => {}, 1 << 30);
|
|
48
|
+
}
|