dotmd-cli 0.1.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/LICENSE +21 -0
- package/README.md +187 -0
- package/bin/dotmd.mjs +202 -0
- package/dotmd.config.example.mjs +116 -0
- package/package.json +37 -0
- package/src/color.mjs +10 -0
- package/src/config.mjs +197 -0
- package/src/extractors.mjs +60 -0
- package/src/frontmatter.mjs +55 -0
- package/src/git.mjs +18 -0
- package/src/index-file.mjs +104 -0
- package/src/index.mjs +151 -0
- package/src/init.mjs +60 -0
- package/src/lifecycle.mjs +224 -0
- package/src/query.mjs +178 -0
- package/src/render.mjs +248 -0
- package/src/util.mjs +55 -0
- package/src/validate.mjs +157 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import { asString, toRepoPath, die } from './util.mjs';
|
|
5
|
+
import { gitMv } from './git.mjs';
|
|
6
|
+
import { buildIndex, collectDocFiles } from './index.mjs';
|
|
7
|
+
import { renderIndexFile, writeIndex } from './index-file.mjs';
|
|
8
|
+
import { green, dim } from './color.mjs';
|
|
9
|
+
|
|
10
|
+
export function runStatus(argv, config, opts = {}) {
|
|
11
|
+
const { dryRun } = opts;
|
|
12
|
+
const input = argv[0];
|
|
13
|
+
const newStatus = argv[1];
|
|
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; }
|
|
17
|
+
|
|
18
|
+
const filePath = resolveDocPath(input, config);
|
|
19
|
+
if (!filePath) { die(`File not found: ${input}`); return; }
|
|
20
|
+
|
|
21
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
22
|
+
const { frontmatter } = extractFrontmatter(raw);
|
|
23
|
+
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
24
|
+
const oldStatus = asString(parsed.status);
|
|
25
|
+
|
|
26
|
+
if (oldStatus === newStatus) {
|
|
27
|
+
process.stdout.write(`${toRepoPath(filePath, config.repoRoot)}: already ${newStatus}, no changes made.\n`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
32
|
+
const archiveDir = path.join(config.docsRoot, config.archiveDir);
|
|
33
|
+
const isArchiving = config.lifecycle.archiveStatuses.has(newStatus) && !filePath.includes(`/${config.archiveDir}/`);
|
|
34
|
+
const isUnarchiving = !config.lifecycle.archiveStatuses.has(newStatus) && filePath.includes(`/${config.archiveDir}/`);
|
|
35
|
+
let finalPath = filePath;
|
|
36
|
+
|
|
37
|
+
if (dryRun) {
|
|
38
|
+
const prefix = dim('[dry-run]');
|
|
39
|
+
process.stdout.write(`${prefix} Would update frontmatter: status: ${oldStatus ?? 'unknown'} → ${newStatus}, updated: ${today}\n`);
|
|
40
|
+
if (isArchiving) {
|
|
41
|
+
const targetPath = path.join(archiveDir, path.basename(filePath));
|
|
42
|
+
process.stdout.write(`${prefix} Would move: ${toRepoPath(filePath, config.repoRoot)} → ${toRepoPath(targetPath, config.repoRoot)}\n`);
|
|
43
|
+
finalPath = targetPath;
|
|
44
|
+
}
|
|
45
|
+
if (isUnarchiving) {
|
|
46
|
+
const targetPath = path.join(config.docsRoot, path.basename(filePath));
|
|
47
|
+
process.stdout.write(`${prefix} Would move: ${toRepoPath(filePath, config.repoRoot)} → ${toRepoPath(targetPath, config.repoRoot)}\n`);
|
|
48
|
+
finalPath = targetPath;
|
|
49
|
+
}
|
|
50
|
+
if ((isArchiving || isUnarchiving) && config.indexPath) {
|
|
51
|
+
process.stdout.write(`${prefix} Would regenerate index\n`);
|
|
52
|
+
}
|
|
53
|
+
process.stdout.write(`${prefix} ${toRepoPath(finalPath, config.repoRoot)}: ${oldStatus ?? 'unknown'} → ${newStatus}\n`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
updateFrontmatter(filePath, { status: newStatus, updated: today });
|
|
58
|
+
|
|
59
|
+
if (isArchiving) {
|
|
60
|
+
const targetPath = path.join(archiveDir, path.basename(filePath));
|
|
61
|
+
if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); return; }
|
|
62
|
+
const result = gitMv(filePath, targetPath, config.repoRoot);
|
|
63
|
+
if (result.status !== 0) { die(result.stderr || 'git mv failed.'); return; }
|
|
64
|
+
finalPath = targetPath;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (isUnarchiving) {
|
|
68
|
+
const targetPath = path.join(config.docsRoot, path.basename(filePath));
|
|
69
|
+
if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); return; }
|
|
70
|
+
const result = gitMv(filePath, targetPath, config.repoRoot);
|
|
71
|
+
if (result.status !== 0) { die(result.stderr || 'git mv failed.'); return; }
|
|
72
|
+
finalPath = targetPath;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if ((isArchiving || isUnarchiving) && config.indexPath) {
|
|
76
|
+
const index = buildIndex(config);
|
|
77
|
+
writeIndex(renderIndexFile(index, config), config);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
process.stdout.write(`${green(toRepoPath(finalPath, config.repoRoot))}: ${oldStatus ?? 'unknown'} → ${newStatus}\n`);
|
|
81
|
+
|
|
82
|
+
config.hooks.onStatusChange?.({ path: toRepoPath(finalPath, config.repoRoot), oldStatus, newStatus }, {
|
|
83
|
+
oldPath: toRepoPath(filePath, config.repoRoot),
|
|
84
|
+
newPath: toRepoPath(finalPath, config.repoRoot),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function runArchive(argv, config, opts = {}) {
|
|
89
|
+
const { dryRun } = opts;
|
|
90
|
+
const input = argv[0];
|
|
91
|
+
|
|
92
|
+
if (!input) { die('Usage: dotmd archive <file>'); return; }
|
|
93
|
+
|
|
94
|
+
const filePath = resolveDocPath(input, config);
|
|
95
|
+
if (!filePath) { die(`File not found: ${input}`); return; }
|
|
96
|
+
if (filePath.includes(`/${config.archiveDir}/`)) { die(`Already archived: ${toRepoPath(filePath, config.repoRoot)}`); return; }
|
|
97
|
+
|
|
98
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
99
|
+
const { frontmatter } = extractFrontmatter(raw);
|
|
100
|
+
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
101
|
+
const oldStatus = asString(parsed.status) ?? 'unknown';
|
|
102
|
+
|
|
103
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
104
|
+
const targetDir = path.join(config.docsRoot, config.archiveDir);
|
|
105
|
+
const targetPath = path.join(targetDir, path.basename(filePath));
|
|
106
|
+
const oldRepoPath = toRepoPath(filePath, config.repoRoot);
|
|
107
|
+
const newRepoPath = toRepoPath(targetPath, config.repoRoot);
|
|
108
|
+
|
|
109
|
+
if (dryRun) {
|
|
110
|
+
const prefix = dim('[dry-run]');
|
|
111
|
+
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
|
+
process.stdout.write(`${prefix} Would move: ${oldRepoPath} → ${newRepoPath}\n`);
|
|
114
|
+
if (config.indexPath) process.stdout.write(`${prefix} Would regenerate index\n`);
|
|
115
|
+
|
|
116
|
+
// Reference scan is read-only, still useful in dry-run
|
|
117
|
+
const basename = path.basename(filePath);
|
|
118
|
+
const references = [];
|
|
119
|
+
for (const docFile of collectDocFiles(config)) {
|
|
120
|
+
if (docFile === targetPath) continue;
|
|
121
|
+
const docRaw = readFileSync(docFile, 'utf8');
|
|
122
|
+
const { frontmatter: docFm } = extractFrontmatter(docRaw);
|
|
123
|
+
if (docFm.includes(basename)) {
|
|
124
|
+
references.push(toRepoPath(docFile, config.repoRoot));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (references.length > 0) {
|
|
128
|
+
process.stdout.write('\nThese docs reference the old path — would need updating:\n');
|
|
129
|
+
for (const ref of references) process.stdout.write(`- ${ref}\n`);
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
updateFrontmatter(filePath, { status: 'archived', updated: today });
|
|
135
|
+
|
|
136
|
+
if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); return; }
|
|
137
|
+
|
|
138
|
+
const result = gitMv(filePath, targetPath, config.repoRoot);
|
|
139
|
+
if (result.status !== 0) { die(result.stderr || 'git mv failed.'); return; }
|
|
140
|
+
|
|
141
|
+
if (config.indexPath) {
|
|
142
|
+
const index = buildIndex(config);
|
|
143
|
+
writeIndex(renderIndexFile(index, config), config);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
process.stdout.write(`${green('Archived')}: ${oldRepoPath} → ${newRepoPath}\n`);
|
|
147
|
+
if (config.indexPath) process.stdout.write('Index regenerated.\n');
|
|
148
|
+
|
|
149
|
+
const basename = path.basename(filePath);
|
|
150
|
+
const references = [];
|
|
151
|
+
for (const docFile of collectDocFiles(config)) {
|
|
152
|
+
if (docFile === targetPath) continue;
|
|
153
|
+
const docRaw = readFileSync(docFile, 'utf8');
|
|
154
|
+
const { frontmatter: docFm } = extractFrontmatter(docRaw);
|
|
155
|
+
if (docFm.includes(basename)) {
|
|
156
|
+
references.push(toRepoPath(docFile, config.repoRoot));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (references.length > 0) {
|
|
161
|
+
process.stdout.write('\nThese docs reference the old path — update reference entries:\n');
|
|
162
|
+
for (const ref of references) process.stdout.write(`- ${ref}\n`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
process.stdout.write('\nNext: commit, then update references if needed.\n');
|
|
166
|
+
config.hooks.onArchive?.({ path: newRepoPath, oldStatus }, { oldPath: oldRepoPath, newPath: newRepoPath });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function runTouch(argv, config, opts = {}) {
|
|
170
|
+
const { dryRun } = opts;
|
|
171
|
+
const input = argv[0];
|
|
172
|
+
|
|
173
|
+
if (!input) { die('Usage: dotmd touch <file>'); return; }
|
|
174
|
+
|
|
175
|
+
const filePath = resolveDocPath(input, config);
|
|
176
|
+
if (!filePath) { die(`File not found: ${input}`); return; }
|
|
177
|
+
|
|
178
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
179
|
+
|
|
180
|
+
if (dryRun) {
|
|
181
|
+
process.stdout.write(`${dim('[dry-run]')} Would touch: ${toRepoPath(filePath, config.repoRoot)} (updated → ${today})\n`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
updateFrontmatter(filePath, { updated: today });
|
|
186
|
+
process.stdout.write(`${green('Touched')}: ${toRepoPath(filePath, config.repoRoot)} (updated → ${today})\n`);
|
|
187
|
+
|
|
188
|
+
config.hooks.onTouch?.({ path: toRepoPath(filePath, config.repoRoot) }, { path: toRepoPath(filePath, config.repoRoot), date: today });
|
|
189
|
+
}
|
|
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
|
+
export function updateFrontmatter(filePath, updates) {
|
|
205
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
206
|
+
if (!raw.startsWith('---\n')) throw new Error(`${filePath} has no frontmatter block.`);
|
|
207
|
+
|
|
208
|
+
const endMarker = raw.indexOf('\n---\n', 4);
|
|
209
|
+
if (endMarker === -1) throw new Error(`${filePath} has unclosed frontmatter block.`);
|
|
210
|
+
|
|
211
|
+
let frontmatter = raw.slice(4, endMarker);
|
|
212
|
+
const body = raw.slice(endMarker + 5);
|
|
213
|
+
|
|
214
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
215
|
+
const regex = new RegExp(`^${key}:.*$`, 'm');
|
|
216
|
+
if (regex.test(frontmatter)) {
|
|
217
|
+
frontmatter = frontmatter.replace(regex, `${key}: ${value}`);
|
|
218
|
+
} else {
|
|
219
|
+
frontmatter += `\n${key}: ${value}`;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
writeFileSync(filePath, `---\n${frontmatter}\n---\n${body}`, 'utf8');
|
|
224
|
+
}
|
package/src/query.mjs
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { capitalize, toSlug } from './util.mjs';
|
|
2
|
+
import { renderProgressBar } from './render.mjs';
|
|
3
|
+
import { computeDaysSinceUpdate, computeIsStale } from './validate.mjs';
|
|
4
|
+
import { getGitLastModified } from './git.mjs';
|
|
5
|
+
|
|
6
|
+
export function runFocus(index, argv, config) {
|
|
7
|
+
const statusFilter = argv[0] ?? 'active';
|
|
8
|
+
const docs = index.docs.filter(doc => doc.status === statusFilter);
|
|
9
|
+
|
|
10
|
+
if (docs.length === 0) {
|
|
11
|
+
process.stdout.write(`No docs found for status: ${statusFilter}\n`);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
process.stdout.write(`${statusFilter.toUpperCase()} Focus\n\n`);
|
|
16
|
+
|
|
17
|
+
for (const doc of docs) {
|
|
18
|
+
process.stdout.write(`- ${doc.title}\n`);
|
|
19
|
+
process.stdout.write(` path: ${doc.path}\n`);
|
|
20
|
+
process.stdout.write(` state: ${doc.currentState}\n`);
|
|
21
|
+
if (doc.nextStep) {
|
|
22
|
+
process.stdout.write(` next: ${doc.nextStep}\n`);
|
|
23
|
+
}
|
|
24
|
+
if (doc.blockers?.length) {
|
|
25
|
+
process.stdout.write(` blockers: ${doc.blockers.join('; ')}\n`);
|
|
26
|
+
}
|
|
27
|
+
if (doc.checklist?.total) {
|
|
28
|
+
process.stdout.write(` checklist: ${doc.checklist.completed}/${doc.checklist.total} complete\n`);
|
|
29
|
+
}
|
|
30
|
+
process.stdout.write('\n');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function runQuery(index, argv, config) {
|
|
35
|
+
const filters = parseQueryArgs(argv);
|
|
36
|
+
const docs = filterDocs(index.docs, filters, config);
|
|
37
|
+
|
|
38
|
+
if (filters.json) {
|
|
39
|
+
process.stdout.write(`${JSON.stringify({ filters, count: docs.length, docs }, null, 2)}\n`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
renderQueryResults(docs, filters);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function parseQueryArgs(argv) {
|
|
47
|
+
const filters = {
|
|
48
|
+
statuses: null, keyword: null, owner: null, surface: null,
|
|
49
|
+
module: null, domain: null, audience: null, executionMode: null,
|
|
50
|
+
updatedSince: null, limit: 20, all: false, sort: 'updated',
|
|
51
|
+
stale: false, hasNextStep: false, hasBlockers: false,
|
|
52
|
+
checklistOpen: false, json: false, git: false,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
56
|
+
const arg = argv[i];
|
|
57
|
+
const next = argv[i + 1];
|
|
58
|
+
|
|
59
|
+
if (arg === '--status' && next) { filters.statuses = next.split(',').map(v => v.trim()).filter(Boolean); i += 1; continue; }
|
|
60
|
+
if (arg === '--keyword' && next) { filters.keyword = next; i += 1; continue; }
|
|
61
|
+
if (arg === '--owner' && next) { filters.owner = next; i += 1; continue; }
|
|
62
|
+
if (arg === '--surface' && next) { filters.surface = next; i += 1; continue; }
|
|
63
|
+
if (arg === '--module' && next) { filters.module = next; i += 1; continue; }
|
|
64
|
+
if (arg === '--domain' && next) { filters.domain = next; i += 1; continue; }
|
|
65
|
+
if (arg === '--audience' && next) { filters.audience = next; i += 1; continue; }
|
|
66
|
+
if (arg === '--execution-mode' && next) { filters.executionMode = next; i += 1; continue; }
|
|
67
|
+
if (arg === '--updated-since' && next) { filters.updatedSince = next; i += 1; continue; }
|
|
68
|
+
if (arg === '--limit' && next) { filters.limit = Number.parseInt(next, 10) || 20; i += 1; continue; }
|
|
69
|
+
if (arg === '--sort' && next) { filters.sort = next; i += 1; continue; }
|
|
70
|
+
if (arg === '--all') { filters.all = true; continue; }
|
|
71
|
+
if (arg === '--stale') { filters.stale = true; continue; }
|
|
72
|
+
if (arg === '--has-next-step') { filters.hasNextStep = true; continue; }
|
|
73
|
+
if (arg === '--has-blockers') { filters.hasBlockers = true; continue; }
|
|
74
|
+
if (arg === '--checklist-open') { filters.checklistOpen = true; continue; }
|
|
75
|
+
if (arg === '--json') { filters.json = true; continue; }
|
|
76
|
+
if (arg === '--git') { filters.git = true; continue; }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return filters;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function filterDocs(docs, filters, config) {
|
|
83
|
+
let result = [...docs];
|
|
84
|
+
|
|
85
|
+
if (filters.statuses?.length) result = result.filter(d => filters.statuses.includes(d.status));
|
|
86
|
+
|
|
87
|
+
if (filters.keyword) {
|
|
88
|
+
const needle = filters.keyword.toLowerCase();
|
|
89
|
+
result = result.filter(d => [d.title, d.summary, d.currentState, d.nextStep, d.path, ...(d.blockers ?? [])].filter(Boolean).join(' ').toLowerCase().includes(needle));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (filters.owner) { const n = filters.owner.toLowerCase(); result = result.filter(d => (d.owner ?? '').toLowerCase().includes(n)); }
|
|
93
|
+
if (filters.surface) { const n = filters.surface.toLowerCase(); result = result.filter(d => (d.surfaces ?? []).some(s => s.toLowerCase() === n)); }
|
|
94
|
+
if (filters.module) { const n = filters.module.toLowerCase(); result = result.filter(d => (d.modules ?? []).some(m => m.toLowerCase() === n)); }
|
|
95
|
+
if (filters.domain) { const n = filters.domain.toLowerCase(); result = result.filter(d => (d.domain ?? '').toLowerCase() === n); }
|
|
96
|
+
if (filters.audience) { const n = filters.audience.toLowerCase(); result = result.filter(d => (d.audience ?? '').toLowerCase() === n); }
|
|
97
|
+
if (filters.executionMode) { const n = filters.executionMode.toLowerCase(); result = result.filter(d => (d.executionMode ?? '').toLowerCase() === n); }
|
|
98
|
+
if (filters.updatedSince) result = result.filter(d => d.updated && d.updated >= filters.updatedSince);
|
|
99
|
+
|
|
100
|
+
if (filters.git) {
|
|
101
|
+
for (const doc of result) {
|
|
102
|
+
const gitDate = getGitLastModified(doc.path, config.repoRoot);
|
|
103
|
+
if (gitDate) {
|
|
104
|
+
doc.daysSinceUpdate = computeDaysSinceUpdate(gitDate);
|
|
105
|
+
doc.isStale = computeIsStale(doc.status, gitDate, config);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (filters.stale) result = result.filter(d => d.isStale);
|
|
111
|
+
if (filters.hasNextStep) result = result.filter(d => d.hasNextStep);
|
|
112
|
+
if (filters.hasBlockers) result = result.filter(d => d.hasBlockers);
|
|
113
|
+
if (filters.checklistOpen) result = result.filter(d => (d.checklist?.open ?? 0) > 0);
|
|
114
|
+
|
|
115
|
+
result.sort(buildSorter(filters.sort, config));
|
|
116
|
+
return filters.all ? result : result.slice(0, filters.limit);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function renderQueryResults(docs, filters) {
|
|
120
|
+
process.stdout.write('Query\n\n');
|
|
121
|
+
process.stdout.write(`- results: ${docs.length}\n`);
|
|
122
|
+
if (filters.statuses?.length) process.stdout.write(`- status: ${filters.statuses.join(', ')}\n`);
|
|
123
|
+
if (filters.keyword) process.stdout.write(`- keyword: ${filters.keyword}\n`);
|
|
124
|
+
if (filters.owner) process.stdout.write(`- owner: ${filters.owner}\n`);
|
|
125
|
+
if (filters.surface) process.stdout.write(`- surface: ${filters.surface}\n`);
|
|
126
|
+
if (filters.module) process.stdout.write(`- module: ${filters.module}\n`);
|
|
127
|
+
if (filters.domain) process.stdout.write(`- domain: ${filters.domain}\n`);
|
|
128
|
+
if (filters.audience) process.stdout.write(`- audience: ${filters.audience}\n`);
|
|
129
|
+
if (filters.executionMode) process.stdout.write(`- execution-mode: ${filters.executionMode}\n`);
|
|
130
|
+
if (filters.updatedSince) process.stdout.write(`- updated-since: ${filters.updatedSince}\n`);
|
|
131
|
+
process.stdout.write(`- sort: ${filters.sort}\n`);
|
|
132
|
+
if (filters.stale) process.stdout.write('- stale-only: true\n');
|
|
133
|
+
if (filters.git) process.stdout.write('- using: git dates\n');
|
|
134
|
+
if (filters.hasNextStep) process.stdout.write('- has-next-step: true\n');
|
|
135
|
+
if (filters.hasBlockers) process.stdout.write('- has-blockers: true\n');
|
|
136
|
+
if (filters.checklistOpen) process.stdout.write('- checklist-open: true\n');
|
|
137
|
+
process.stdout.write('\n');
|
|
138
|
+
|
|
139
|
+
if (docs.length === 0) { process.stdout.write('No matching docs.\n'); return; }
|
|
140
|
+
|
|
141
|
+
for (const doc of docs) {
|
|
142
|
+
process.stdout.write(`- ${doc.title}\n`);
|
|
143
|
+
process.stdout.write(` status: ${doc.status}\n`);
|
|
144
|
+
process.stdout.write(` updated: ${doc.updated ?? 'n/a'}\n`);
|
|
145
|
+
if (doc.daysSinceUpdate != null) process.stdout.write(` days-since-update: ${doc.daysSinceUpdate}\n`);
|
|
146
|
+
process.stdout.write(` stale: ${doc.isStale ? 'yes' : 'no'}\n`);
|
|
147
|
+
process.stdout.write(` path: ${doc.path}\n`);
|
|
148
|
+
process.stdout.write(` state: ${doc.currentState}\n`);
|
|
149
|
+
if (doc.nextStep) process.stdout.write(` next: ${doc.nextStep}\n`);
|
|
150
|
+
if (doc.owner) process.stdout.write(` owner: ${doc.owner}\n`);
|
|
151
|
+
if (doc.surfaces?.length) process.stdout.write(` surfaces: ${doc.surfaces.join(', ')}\n`);
|
|
152
|
+
if (doc.modules?.length) process.stdout.write(` modules: ${doc.modules.join(', ')}\n`);
|
|
153
|
+
if (doc.domain) process.stdout.write(` domain: ${doc.domain}\n`);
|
|
154
|
+
if (doc.audience) process.stdout.write(` audience: ${doc.audience}\n`);
|
|
155
|
+
if (doc.executionMode) process.stdout.write(` execution-mode: ${doc.executionMode}\n`);
|
|
156
|
+
if (doc.blockers?.length) process.stdout.write(` blockers: ${doc.blockers.join('; ')}\n`);
|
|
157
|
+
if (doc.checklist?.total) process.stdout.write(` checklist: ${doc.checklist.completed}/${doc.checklist.total} complete\n`);
|
|
158
|
+
process.stdout.write('\n');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function buildSorter(sort, config) {
|
|
163
|
+
if (sort === 'title') return (a, b) => a.title.localeCompare(b.title);
|
|
164
|
+
if (sort === 'status') {
|
|
165
|
+
return (a, b) => {
|
|
166
|
+
const ai = config.statusOrder.indexOf(a.status); const bi = config.statusOrder.indexOf(b.status);
|
|
167
|
+
const aIdx = ai === -1 ? Number.MAX_SAFE_INTEGER : ai; const bIdx = bi === -1 ? Number.MAX_SAFE_INTEGER : bi;
|
|
168
|
+
if (aIdx !== bIdx) return aIdx - bIdx;
|
|
169
|
+
return compareUpdatedDesc(a, b) || a.title.localeCompare(b.title);
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return (a, b) => compareUpdatedDesc(a, b) || a.title.localeCompare(b.title);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function compareUpdatedDesc(a, b) {
|
|
176
|
+
const au = a.updated ?? ''; const bu = b.updated ?? '';
|
|
177
|
+
return au !== bu ? bu.localeCompare(au) : 0;
|
|
178
|
+
}
|
package/src/render.mjs
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { capitalize, toSlug, truncate } from './util.mjs';
|
|
2
|
+
import { bold, red, yellow, green } from './color.mjs';
|
|
3
|
+
|
|
4
|
+
export function renderCompactList(index, config) {
|
|
5
|
+
const defaultRenderer = (idx) => _renderCompactList(idx, config);
|
|
6
|
+
if (config.hooks.renderCompactList) {
|
|
7
|
+
return config.hooks.renderCompactList(index, defaultRenderer);
|
|
8
|
+
}
|
|
9
|
+
return defaultRenderer(index);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function _renderCompactList(index, config) {
|
|
13
|
+
const lines = ['Index', ''];
|
|
14
|
+
const maxWidth = config.display.lineWidth || process.stdout.columns || 120;
|
|
15
|
+
|
|
16
|
+
for (const status of config.statusOrder) {
|
|
17
|
+
const docs = index.docs.filter(d => d.status === status);
|
|
18
|
+
if (!docs.length) continue;
|
|
19
|
+
|
|
20
|
+
lines.push(bold(`${capitalize(status)} (${docs.length})`));
|
|
21
|
+
const maxTitle = Math.min(config.display.truncateTitle || 30, Math.max(...docs.map(d => d.title.length)));
|
|
22
|
+
|
|
23
|
+
for (const doc of docs) {
|
|
24
|
+
const title = doc.title.length > maxTitle
|
|
25
|
+
? doc.title.slice(0, maxTitle - 3) + '...'
|
|
26
|
+
: doc.title.padEnd(maxTitle);
|
|
27
|
+
const days = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : '';
|
|
28
|
+
const progress = renderProgressBar(doc.checklist);
|
|
29
|
+
const next = doc.nextStep ? `next: ${doc.nextStep}` : '';
|
|
30
|
+
const parts = [` ${title} ${days.padStart(4)}`];
|
|
31
|
+
if (progress) parts.push(progress);
|
|
32
|
+
if (next) parts.push(next);
|
|
33
|
+
const line = parts.join(' ');
|
|
34
|
+
lines.push(line.length > maxWidth ? line.slice(0, maxWidth - 3) + '...' : line);
|
|
35
|
+
}
|
|
36
|
+
lines.push('');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function renderVerboseList(index, config) {
|
|
43
|
+
const lines = ['Index', ''];
|
|
44
|
+
|
|
45
|
+
for (const status of config.statusOrder) {
|
|
46
|
+
const docs = index.docs.filter(doc => doc.status === status);
|
|
47
|
+
if (docs.length === 0) continue;
|
|
48
|
+
|
|
49
|
+
lines.push(`${capitalize(status)} (${docs.length})`);
|
|
50
|
+
for (const doc of docs) {
|
|
51
|
+
const parts = [`- ${doc.title}`, `${capitalize(status)}: ${doc.currentState}`, `(${doc.path})`];
|
|
52
|
+
if (doc.nextStep) {
|
|
53
|
+
parts.push(`next: ${doc.nextStep}`);
|
|
54
|
+
}
|
|
55
|
+
lines.push(parts.join(' — '));
|
|
56
|
+
}
|
|
57
|
+
lines.push('');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function renderContext(index, config) {
|
|
64
|
+
const defaultRenderer = (idx) => _renderContext(idx, config);
|
|
65
|
+
if (config.hooks.renderContext) {
|
|
66
|
+
return config.hooks.renderContext(index, defaultRenderer);
|
|
67
|
+
}
|
|
68
|
+
return defaultRenderer(index);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function _renderContext(index, config) {
|
|
72
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
73
|
+
const lines = [`BRIEFING (${today})`, ''];
|
|
74
|
+
const ctx = config.context;
|
|
75
|
+
|
|
76
|
+
const byStatus = {};
|
|
77
|
+
for (const status of config.statusOrder) {
|
|
78
|
+
byStatus[status] = index.docs.filter(d => d.status === status);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const status of (ctx.expanded || [])) {
|
|
82
|
+
const docs = byStatus[status];
|
|
83
|
+
if (!docs?.length) continue;
|
|
84
|
+
lines.push(bold(`${capitalize(status)} (${docs.length}):`));
|
|
85
|
+
const maxSlug = Math.min(24, Math.max(...docs.map(d => toSlug(d).length)));
|
|
86
|
+
for (const doc of docs) {
|
|
87
|
+
const slug = toSlug(doc).padEnd(maxSlug);
|
|
88
|
+
const next = doc.nextStep
|
|
89
|
+
? truncate(doc.nextStep, ctx.truncateNextStep || 80)
|
|
90
|
+
: '(no next step)';
|
|
91
|
+
lines.push(` ${slug} next: ${next}`);
|
|
92
|
+
}
|
|
93
|
+
lines.push('');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const status of (ctx.listed || [])) {
|
|
97
|
+
const docs = byStatus[status];
|
|
98
|
+
if (!docs?.length) continue;
|
|
99
|
+
lines.push(`${capitalize(status)} (${docs.length}): ${docs.map(toSlug).join(', ')}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const counts = (ctx.counted || [])
|
|
103
|
+
.filter(s => byStatus[s]?.length)
|
|
104
|
+
.map(s => `${capitalize(s)} (${byStatus[s].length})`);
|
|
105
|
+
if (counts.length) {
|
|
106
|
+
lines.push(counts.join(' | '));
|
|
107
|
+
}
|
|
108
|
+
lines.push('');
|
|
109
|
+
|
|
110
|
+
const stale = index.docs.filter(d => d.isStale && !config.lifecycle.skipStaleFor.has(d.status));
|
|
111
|
+
if (stale.length) {
|
|
112
|
+
lines.push(`Stale: ${stale.map(d => `${toSlug(d)} (${d.daysSinceUpdate}d)`).join(', ')}`);
|
|
113
|
+
} else {
|
|
114
|
+
lines.push('Stale: none');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const withErrors = index.docs.filter(d => d.errors.length > 0 && !config.lifecycle.skipWarningsFor.has(d.status));
|
|
118
|
+
const withWarnings = index.docs.filter(d => d.warnings.length > 0 && !config.lifecycle.skipWarningsFor.has(d.status));
|
|
119
|
+
if (withErrors.length || withWarnings.length) {
|
|
120
|
+
const parts = [];
|
|
121
|
+
if (withErrors.length) parts.push(`${withErrors.length} with errors`);
|
|
122
|
+
if (withWarnings.length) parts.push(`${withWarnings.length} with warnings`);
|
|
123
|
+
lines.push(`Non-compliant: ${parts.join(', ')} (run \`dotmd check\` for details)`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const recentStatuses = new Set(ctx.recentStatuses || ['active', 'ready', 'planned']);
|
|
127
|
+
const recentDays = ctx.recentDays ?? 3;
|
|
128
|
+
const recentLimit = ctx.recentLimit ?? 10;
|
|
129
|
+
const recent = index.docs
|
|
130
|
+
.filter(d => d.daysSinceUpdate != null && d.daysSinceUpdate <= recentDays && recentStatuses.has(d.status))
|
|
131
|
+
.sort((a, b) => (a.daysSinceUpdate ?? 99) - (b.daysSinceUpdate ?? 99))
|
|
132
|
+
.slice(0, recentLimit);
|
|
133
|
+
if (recent.length) {
|
|
134
|
+
const items = recent.map(d => {
|
|
135
|
+
const label = d.daysSinceUpdate === 0 ? 'today' : `${d.daysSinceUpdate}d ago`;
|
|
136
|
+
return `${toSlug(d)} (${label})`;
|
|
137
|
+
});
|
|
138
|
+
lines.push(`Recently updated: ${items.join(', ')}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function renderCheck(index, config) {
|
|
145
|
+
const defaultRenderer = (idx) => _renderCheck(idx);
|
|
146
|
+
if (config.hooks.renderCheck) {
|
|
147
|
+
return config.hooks.renderCheck(index, defaultRenderer);
|
|
148
|
+
}
|
|
149
|
+
return defaultRenderer(index);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function _renderCheck(index) {
|
|
153
|
+
const lines = ['Check', ''];
|
|
154
|
+
lines.push(`- docs scanned: ${index.docs.length}`);
|
|
155
|
+
lines.push(`- errors: ${index.errors.length}`);
|
|
156
|
+
lines.push(`- warnings: ${index.warnings.length}`);
|
|
157
|
+
lines.push('');
|
|
158
|
+
|
|
159
|
+
if (index.errors.length > 0) {
|
|
160
|
+
lines.push(red('Errors'));
|
|
161
|
+
for (const issue of index.errors) {
|
|
162
|
+
lines.push(`- ${issue.path}: ${issue.message}`);
|
|
163
|
+
}
|
|
164
|
+
lines.push('');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (index.warnings.length > 0) {
|
|
168
|
+
lines.push(yellow('Warnings'));
|
|
169
|
+
for (const issue of index.warnings) {
|
|
170
|
+
lines.push(`- ${issue.path}: ${issue.message}`);
|
|
171
|
+
}
|
|
172
|
+
lines.push('');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (index.errors.length === 0 && index.warnings.length === 0) {
|
|
176
|
+
lines.push(green('No issues found.'));
|
|
177
|
+
} else if (index.errors.length === 0) {
|
|
178
|
+
lines.push(green('Check passed with warnings.'));
|
|
179
|
+
} else {
|
|
180
|
+
lines.push(red('Check failed.'));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function renderCoverage(index, config) {
|
|
187
|
+
const coverage = buildCoverage(index, config);
|
|
188
|
+
const lines = ['Coverage', ''];
|
|
189
|
+
lines.push(`- scoped docs: ${coverage.totals.scopedDocs}`);
|
|
190
|
+
lines.push(`- missing surface: ${coverage.totals.missingSurface}`);
|
|
191
|
+
lines.push(`- missing module: ${coverage.totals.missingModule}`);
|
|
192
|
+
lines.push(`- module:platform: ${coverage.totals.modulePlatform}`);
|
|
193
|
+
lines.push(`- module:none: ${coverage.totals.moduleNone}`);
|
|
194
|
+
lines.push(`- audit_level:none: ${coverage.totals.auditLevelNone}`);
|
|
195
|
+
lines.push(`- audited (pass1/pass2/deep): ${coverage.totals.audited}`);
|
|
196
|
+
lines.push('');
|
|
197
|
+
|
|
198
|
+
for (const [label, list] of [['Missing surface', coverage.missingSurface], ['Missing module', coverage.missingModule], ['module:platform', coverage.modulePlatform], ['module:none', coverage.moduleNone], ['audit_level:none', coverage.auditLevelNone]]) {
|
|
199
|
+
if (list.length) {
|
|
200
|
+
lines.push(label);
|
|
201
|
+
for (const doc of list) lines.push(`- ${doc.path}`);
|
|
202
|
+
lines.push('');
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function buildCoverage(index, config) {
|
|
210
|
+
const scope = ['active', 'ready', 'planned', 'blocked'];
|
|
211
|
+
const scoped = index.docs.filter(doc => scope.includes(doc.status));
|
|
212
|
+
const missingSurface = scoped.filter(doc => !doc.surface);
|
|
213
|
+
const missingModule = scoped.filter(doc => !doc.module);
|
|
214
|
+
const modulePlatform = scoped.filter(doc => doc.module === 'platform');
|
|
215
|
+
const moduleNone = scoped.filter(doc => doc.module === 'none');
|
|
216
|
+
const auditLevelNone = scoped.filter(doc => doc.auditLevel === 'none');
|
|
217
|
+
const audited = scoped.filter(doc => ['pass1', 'pass2', 'deep'].includes(doc.auditLevel));
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
generatedAt: index.generatedAt, scope,
|
|
221
|
+
totals: { scopedDocs: scoped.length, missingSurface: missingSurface.length, missingModule: missingModule.length, modulePlatform: modulePlatform.length, moduleNone: moduleNone.length, auditLevelNone: auditLevelNone.length, audited: audited.length },
|
|
222
|
+
missingSurface, missingModule, modulePlatform, moduleNone, auditLevelNone, audited,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function renderProgressBar(checklist) {
|
|
227
|
+
if (!checklist?.total) return '';
|
|
228
|
+
const ratio = checklist.completed / checklist.total;
|
|
229
|
+
const filled = Math.round(ratio * 10);
|
|
230
|
+
const bar = '█'.repeat(filled) + '░'.repeat(10 - filled);
|
|
231
|
+
return `${bar} ${checklist.completed}/${checklist.total}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function formatSnapshot(doc, config) {
|
|
235
|
+
const defaultFormatter = (d) => _formatSnapshot(d);
|
|
236
|
+
if (config.hooks.formatSnapshot) {
|
|
237
|
+
return config.hooks.formatSnapshot(doc, defaultFormatter);
|
|
238
|
+
}
|
|
239
|
+
return defaultFormatter(doc);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function _formatSnapshot(doc) {
|
|
243
|
+
const state = doc.currentState ?? 'No current_state set';
|
|
244
|
+
if (/^active:|^ready:|^planned:|^research:|^blocked:|^archived:/i.test(state)) {
|
|
245
|
+
return state;
|
|
246
|
+
}
|
|
247
|
+
return `${capitalize(doc.status ?? 'unknown')}: ${state}`;
|
|
248
|
+
}
|