dotmd-cli 0.69.0 → 0.70.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -964
- package/bin/dotmd.mjs +238 -195
- package/dotmd.config.example.mjs +5 -8
- package/package.json +6 -10
- package/src/agent-context.mjs +132 -0
- package/src/atomic-mutation.mjs +1505 -0
- package/src/baton.mjs +109 -114
- package/src/bulk-tag.mjs +7 -7
- package/src/commands.mjs +326 -12
- package/src/completions.mjs +38 -98
- package/src/config.mjs +18 -3
- package/src/diff.mjs +7 -3
- package/src/doctor.mjs +12 -5
- package/src/export.mjs +154 -25
- package/src/fix-refs.mjs +2 -0
- package/src/frontmatter-fix.mjs +2 -0
- package/src/frontmatter.mjs +3 -2
- package/src/git.mjs +531 -14
- package/src/graph.mjs +53 -25
- package/src/guard.mjs +163 -60
- package/src/hud.mjs +65 -76
- package/src/index-file.mjs +28 -16
- package/src/index.mjs +17 -12
- package/src/init.mjs +1 -1
- package/src/journal.mjs +145 -12
- package/src/lifecycle.mjs +554 -282
- package/src/lint.mjs +3 -3
- package/src/managed-path.mjs +192 -0
- package/src/migrate-prompts.mjs +2 -0
- package/src/migrate-template.mjs +2 -0
- package/src/migrate.mjs +7 -1
- package/src/new.mjs +135 -54
- package/src/output-identity.mjs +106 -0
- package/src/pickup-card.mjs +24 -10
- package/src/pickup.mjs +457 -0
- package/src/prompts.mjs +134 -75
- package/src/query.mjs +22 -10
- package/src/reference-planner.mjs +292 -0
- package/src/rename.mjs +65 -73
- package/src/render.mjs +17 -8
- package/src/runlist.mjs +109 -71
- package/src/section.mjs +2 -1
- package/src/ship.mjs +39 -20
- package/src/stats.mjs +1 -1
- package/src/status-metadata.mjs +87 -0
- package/src/statuses.mjs +11 -26
- package/src/summary.mjs +14 -3
- package/src/update.mjs +38 -10
- package/src/use.mjs +4 -1
- package/src/util.mjs +1 -0
- package/src/validate.mjs +14 -6
- package/src/watch.mjs +6 -1
- package/src/notion.mjs +0 -528
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
|
|
5
|
+
function slash(value) { return value.split(path.sep).join('/'); }
|
|
6
|
+
|
|
7
|
+
function canonicalExisting(filePath) {
|
|
8
|
+
try { return realpathSync(filePath); }
|
|
9
|
+
catch {
|
|
10
|
+
const suffix = [];
|
|
11
|
+
let cursor = path.resolve(filePath);
|
|
12
|
+
while (true) {
|
|
13
|
+
try { return path.join(realpathSync(cursor), ...suffix); }
|
|
14
|
+
catch {
|
|
15
|
+
const parent = path.dirname(cursor);
|
|
16
|
+
if (parent === cursor) return path.resolve(filePath);
|
|
17
|
+
suffix.unshift(path.basename(cursor));
|
|
18
|
+
cursor = parent;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class AmbiguousReferenceError extends Error {
|
|
25
|
+
constructor(token, sourcePath, local, repository) {
|
|
26
|
+
super(`Ambiguous reference '${token}' in ${sourcePath}: document-relative resolves to ${local}, repository-relative resolves to ${repository}. Use an explicit unambiguous path.`);
|
|
27
|
+
this.name = 'AmbiguousReferenceError';
|
|
28
|
+
this.code = 'DOTMD_AMBIGUOUS_REFERENCE';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function configuredReferenceFields(config) {
|
|
33
|
+
return [...new Set([
|
|
34
|
+
...(config.referenceFields?.bidirectional ?? []),
|
|
35
|
+
...(config.referenceFields?.unidirectional ?? []),
|
|
36
|
+
])];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createReferenceIdentitySet(filePaths) {
|
|
40
|
+
const identities = new Set();
|
|
41
|
+
identities.paths = new Map();
|
|
42
|
+
for (const filePath of filePaths) {
|
|
43
|
+
const identity = canonicalExisting(filePath);
|
|
44
|
+
identities.add(identity);
|
|
45
|
+
identities.paths.set(identity, path.resolve(filePath));
|
|
46
|
+
}
|
|
47
|
+
return identities;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Both interpretations are evaluated. A local document wins only when the
|
|
51
|
+
// repo-relative spelling is absent or names the same identity; disagreement is
|
|
52
|
+
// rejected rather than guessed.
|
|
53
|
+
export function resolveReferenceIdentity(token, documentPath, repoRoot, identities) {
|
|
54
|
+
if (!token || /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(token)) return null;
|
|
55
|
+
const clean = token.replace(/[?#].*$/, '').replace(/\\([\s()[\]<>])/g, '$1');
|
|
56
|
+
const local = canonicalExisting(path.resolve(path.dirname(documentPath), clean));
|
|
57
|
+
const repository = canonicalExisting(path.resolve(repoRoot, clean.replace(/^\/+/, '')));
|
|
58
|
+
const localExists = identities.has(local);
|
|
59
|
+
const repositoryExists = identities.has(repository);
|
|
60
|
+
if (localExists && repositoryExists && local !== repository) {
|
|
61
|
+
throw new AmbiguousReferenceError(token, documentPath, local, repository);
|
|
62
|
+
}
|
|
63
|
+
return localExists ? local : (repositoryExists ? repository : null);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function rewriteToken(token, sourcePath, outputPath, repoRoot, identities, oldIdentity, newPath, rebaseAll, format = 'plain') {
|
|
67
|
+
const target = resolveReferenceIdentity(token, sourcePath, repoRoot, identities);
|
|
68
|
+
if (!target || (!rebaseAll && target !== oldIdentity)) return token;
|
|
69
|
+
const destination = target === oldIdentity ? path.resolve(newPath) : (identities.paths?.get(target) ?? target);
|
|
70
|
+
const relative = slash(path.relative(path.dirname(outputPath), destination)) || path.basename(destination);
|
|
71
|
+
return format === 'escaped' ? relative.replace(/([\s()[\]<>])/g, '\\$1') : relative;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function rewritePathTokens(value, args) {
|
|
75
|
+
let quote = null;
|
|
76
|
+
let commentAt = -1;
|
|
77
|
+
for (let index = 0; index < value.length; index++) {
|
|
78
|
+
const char = value[index];
|
|
79
|
+
if (quote) {
|
|
80
|
+
if (char === quote && value[index - 1] !== '\\') quote = null;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (char === '"' || char === "'") { quote = char; continue; }
|
|
84
|
+
if (char === '#' && index > 0 && /\s/.test(value[index - 1])) { commentAt = index - 1; break; }
|
|
85
|
+
}
|
|
86
|
+
const editable = commentAt === -1 ? value : value.slice(0, commentAt);
|
|
87
|
+
const comment = commentAt === -1 ? '' : value.slice(commentAt);
|
|
88
|
+
const quoted = editable.replace(/(["'])((?:\\.|(?!\1).)*)\1/g, (match, delimiter, token) => {
|
|
89
|
+
if (!/^.+\.md(?:[?#].*)?$/i.test(token)) return match;
|
|
90
|
+
const suffixAt = token.search(/[?#]/);
|
|
91
|
+
const bare = suffixAt === -1 ? token : token.slice(0, suffixAt);
|
|
92
|
+
const suffix = suffixAt === -1 ? '' : token.slice(suffixAt);
|
|
93
|
+
const rewritten = rewriteToken(bare, ...args);
|
|
94
|
+
return rewritten === bare ? match : `${delimiter}${rewritten}${suffix}${delimiter}`;
|
|
95
|
+
});
|
|
96
|
+
const rewritten = quoted.replace(/[^\s"'<>:[\],()#?]+\.md(?=$|[\s"'<>:[\],()#?])/g, token => rewriteToken(token, ...args));
|
|
97
|
+
return rewritten + comment;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function rewriteFrontmatter(frontmatter, fields, args) {
|
|
101
|
+
const allowed = new Set(fields);
|
|
102
|
+
const lines = frontmatter.split('\n');
|
|
103
|
+
let active = false;
|
|
104
|
+
return lines.map(line => {
|
|
105
|
+
const key = /^([A-Za-z_][\w-]*):(?:\s*(.*))?$/.exec(line);
|
|
106
|
+
if (key) {
|
|
107
|
+
active = allowed.has(key[1]);
|
|
108
|
+
if (!active) return line;
|
|
109
|
+
const colon = line.indexOf(':');
|
|
110
|
+
return line.slice(0, colon + 1) + rewritePathTokens(line.slice(colon + 1), args);
|
|
111
|
+
}
|
|
112
|
+
if (/^[^\s#][^:]*:/.test(line)) active = false;
|
|
113
|
+
if (!active || /^\s*#/.test(line)) return line;
|
|
114
|
+
return rewritePathTokens(line, args);
|
|
115
|
+
}).join('\n');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function destinationParts(raw) {
|
|
119
|
+
const angle = raw.startsWith('<') && raw.endsWith('>');
|
|
120
|
+
const inner = angle ? raw.slice(1, -1) : raw;
|
|
121
|
+
const suffixAt = inner.search(/[?#]/);
|
|
122
|
+
return {
|
|
123
|
+
angle,
|
|
124
|
+
path: suffixAt === -1 ? inner : inner.slice(0, suffixAt),
|
|
125
|
+
suffix: suffixAt === -1 ? '' : inner.slice(suffixAt),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function rewriteDestination(raw, args) {
|
|
130
|
+
const parsed = destinationParts(raw);
|
|
131
|
+
if (!/\.md$/i.test(parsed.path.replace(/\\./g, 'x'))) return raw;
|
|
132
|
+
const next = rewriteToken(parsed.path, ...args, parsed.angle ? 'plain' : 'escaped');
|
|
133
|
+
if (next === parsed.path) return raw;
|
|
134
|
+
const rendered = `${next}${parsed.suffix}`;
|
|
135
|
+
return parsed.angle ? `<${rendered}>` : rendered;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function rewriteMarkdownSegment(segment, args) {
|
|
139
|
+
// Inline links: angle destinations, escaped whitespace, optional titles.
|
|
140
|
+
let next = segment.replace(/(\[[^\]]*\]\(\s*)(<[^>\n]+>|(?:\\.|[^\s()])+)(\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?(\s*\))/g,
|
|
141
|
+
(match, prefix, destination, title = '', close, offset) => {
|
|
142
|
+
let escapes = 0;
|
|
143
|
+
for (let index = offset - 1; index >= 0 && segment[index] === '\\'; index--) escapes++;
|
|
144
|
+
if (escapes % 2 === 1) return match;
|
|
145
|
+
const rewritten = rewriteDestination(destination, args);
|
|
146
|
+
return rewritten === destination ? match : `${prefix}${rewritten}${title}${close}`;
|
|
147
|
+
});
|
|
148
|
+
// Reference definitions preserve labels, spacing, destinations, and titles.
|
|
149
|
+
next = next.replace(/^(\s{0,3}\[[^\]]+\]:\s*)(<[^>\n]+>|(?:\\.|[^\s])+)(.*)$/,
|
|
150
|
+
(match, prefix, destination, tail) => {
|
|
151
|
+
const rewritten = rewriteDestination(destination, args);
|
|
152
|
+
return rewritten === destination ? match : `${prefix}${rewritten}${tail}`;
|
|
153
|
+
});
|
|
154
|
+
return next;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function rewriteMarkdown(body, args) {
|
|
158
|
+
const lines = body.split('\n');
|
|
159
|
+
let fence = null;
|
|
160
|
+
let inlineRun = null;
|
|
161
|
+
let indentedCode = false;
|
|
162
|
+
let htmlBlock = null;
|
|
163
|
+
const containerContext = line => {
|
|
164
|
+
let rest = line;
|
|
165
|
+
let quotePrefix = '';
|
|
166
|
+
while (true) {
|
|
167
|
+
const quote = /^( {0,3}>[ \t]?)/.exec(rest);
|
|
168
|
+
if (!quote) break;
|
|
169
|
+
quotePrefix += quote[1];
|
|
170
|
+
rest = rest.slice(quote[1].length);
|
|
171
|
+
}
|
|
172
|
+
const list = /^( {0,3}(?:[-+*]|\d+[.)]))([ \t]+)/.exec(rest);
|
|
173
|
+
if (list) {
|
|
174
|
+
const padding = !list[2].includes('\t') && list[2].length <= 4 ? list[2] : list[2][0];
|
|
175
|
+
const listPrefix = `${list[1]}${padding}`;
|
|
176
|
+
rest = rest.slice(listPrefix.length);
|
|
177
|
+
return { content: rest, prefixes: [`${quotePrefix}${listPrefix}`, `${quotePrefix}${' '.repeat(listPrefix.length)}`] };
|
|
178
|
+
}
|
|
179
|
+
return { content: rest, prefixes: quotePrefix ? [quotePrefix] : [''] };
|
|
180
|
+
};
|
|
181
|
+
return lines.map(line => {
|
|
182
|
+
if (fence) {
|
|
183
|
+
const close = new RegExp(`^ {0,3}${fence.char === '`' ? '`' : '~'}{${fence.length},}[ \\t]*$`);
|
|
184
|
+
const compatible = fence.prefixes.find(prefix => line.startsWith(prefix));
|
|
185
|
+
if (compatible !== undefined && close.test(line.slice(compatible.length))) fence = null;
|
|
186
|
+
return line;
|
|
187
|
+
}
|
|
188
|
+
if (htmlBlock) {
|
|
189
|
+
if (htmlBlock === 'comment' && line.includes('-->')) htmlBlock = null;
|
|
190
|
+
else if (htmlBlock !== 'generic' && new RegExp(`</${htmlBlock}\\s*>`, 'i').test(line)) htmlBlock = null;
|
|
191
|
+
else if (htmlBlock === 'generic' && line.trim() === '') htmlBlock = null;
|
|
192
|
+
return line;
|
|
193
|
+
}
|
|
194
|
+
const trimmed = line.trimStart();
|
|
195
|
+
if (trimmed.startsWith('<!--')) {
|
|
196
|
+
if (!trimmed.includes('-->')) htmlBlock = 'comment';
|
|
197
|
+
return line;
|
|
198
|
+
}
|
|
199
|
+
const rawTag = /^<(pre|code|script|style)(?:\s|>|$)/i.exec(trimmed)?.[1]?.toLowerCase();
|
|
200
|
+
if (rawTag) {
|
|
201
|
+
if (!new RegExp(`</${rawTag}\\s*>`, 'i').test(trimmed)) htmlBlock = rawTag;
|
|
202
|
+
return line;
|
|
203
|
+
}
|
|
204
|
+
if (/^<\/?(?:address|article|aside|base|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)(?:\s|>|\/)/i.test(trimmed)) {
|
|
205
|
+
htmlBlock = 'generic';
|
|
206
|
+
return line;
|
|
207
|
+
}
|
|
208
|
+
if (trimmed.startsWith('<')) return line;
|
|
209
|
+
const container = containerContext(line);
|
|
210
|
+
if (indentedCode) {
|
|
211
|
+
if (container.content === '' || /^(?: {4}|\t)/.test(container.content)) return line;
|
|
212
|
+
indentedCode = false;
|
|
213
|
+
} else if (/^(?: {4}|\t)/.test(container.content)) {
|
|
214
|
+
indentedCode = true;
|
|
215
|
+
return line;
|
|
216
|
+
}
|
|
217
|
+
const opener = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(container.content);
|
|
218
|
+
if (opener) {
|
|
219
|
+
fence = { char: opener[1][0], length: opener[1].length, prefixes: container.prefixes };
|
|
220
|
+
return line;
|
|
221
|
+
}
|
|
222
|
+
let output = '';
|
|
223
|
+
let cursor = 0;
|
|
224
|
+
const runs = [...line.matchAll(/`+/g)];
|
|
225
|
+
let index = 0;
|
|
226
|
+
if (inlineRun !== null) {
|
|
227
|
+
const closingIndex = runs.findIndex(candidate => candidate[0].length === inlineRun);
|
|
228
|
+
if (closingIndex === -1) return line;
|
|
229
|
+
const closing = runs[closingIndex];
|
|
230
|
+
output += line.slice(0, closing.index + closing[0].length);
|
|
231
|
+
cursor = closing.index + closing[0].length;
|
|
232
|
+
index = closingIndex + 1;
|
|
233
|
+
inlineRun = null;
|
|
234
|
+
}
|
|
235
|
+
for (; index < runs.length; index++) {
|
|
236
|
+
const opening = runs[index];
|
|
237
|
+
const closingIndex = runs.findIndex((candidate, candidateIndex) => candidateIndex > index && candidate[0].length === opening[0].length);
|
|
238
|
+
if (closingIndex === -1) {
|
|
239
|
+
output += rewriteMarkdownSegment(line.slice(cursor, opening.index), args);
|
|
240
|
+
output += line.slice(opening.index);
|
|
241
|
+
inlineRun = opening[0].length;
|
|
242
|
+
cursor = line.length;
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
const closing = runs[closingIndex];
|
|
246
|
+
output += rewriteMarkdownSegment(line.slice(cursor, opening.index), args);
|
|
247
|
+
output += line.slice(opening.index, closing.index + closing[0].length);
|
|
248
|
+
cursor = closing.index + closing[0].length;
|
|
249
|
+
index = closingIndex;
|
|
250
|
+
}
|
|
251
|
+
return output + rewriteMarkdownSegment(line.slice(cursor), args);
|
|
252
|
+
}).join('\n');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function rewriteDocumentReferences(content, {
|
|
256
|
+
sourcePath,
|
|
257
|
+
outputPath = sourcePath,
|
|
258
|
+
repoRoot,
|
|
259
|
+
identities,
|
|
260
|
+
oldPath,
|
|
261
|
+
newPath,
|
|
262
|
+
referenceFields = [],
|
|
263
|
+
rebaseAll = false,
|
|
264
|
+
}) {
|
|
265
|
+
const { frontmatter, body } = extractFrontmatter(content);
|
|
266
|
+
if (!frontmatter) return content;
|
|
267
|
+
const oldIdentity = canonicalExisting(oldPath);
|
|
268
|
+
const args = [sourcePath, outputPath, repoRoot, identities, oldIdentity, newPath, rebaseAll];
|
|
269
|
+
const nextFrontmatter = rewriteFrontmatter(frontmatter, referenceFields, args);
|
|
270
|
+
const nextBody = rewriteMarkdown(body, args);
|
|
271
|
+
return nextFrontmatter === frontmatter && nextBody === body ? content : `---\n${nextFrontmatter}\n---\n${nextBody}`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function planReferenceMove({ documents, oldPath, newPath, repoRoot, referenceFields = [] }) {
|
|
275
|
+
const identities = createReferenceIdentitySet(documents.map(document => document.path));
|
|
276
|
+
const oldIdentity = canonicalExisting(oldPath);
|
|
277
|
+
identities.add(oldIdentity);
|
|
278
|
+
const source = documents.find(document => path.resolve(document.path) === path.resolve(oldPath));
|
|
279
|
+
if (!source) throw new Error(`Reference move plan is missing source content: ${oldPath}`);
|
|
280
|
+
const movedContent = rewriteDocumentReferences(source.content, {
|
|
281
|
+
sourcePath: oldPath, outputPath: newPath, repoRoot, identities, oldPath, newPath, referenceFields, rebaseAll: true,
|
|
282
|
+
});
|
|
283
|
+
const updates = [];
|
|
284
|
+
for (const document of documents) {
|
|
285
|
+
if (path.resolve(document.path) === path.resolve(oldPath)) continue;
|
|
286
|
+
const content = rewriteDocumentReferences(document.content, {
|
|
287
|
+
sourcePath: document.path, repoRoot, identities, oldPath, newPath, referenceFields,
|
|
288
|
+
});
|
|
289
|
+
if (content !== document.content) updates.push({ path: document.path, expectedContent: document.content, content });
|
|
290
|
+
}
|
|
291
|
+
return { movedContent, updates, identities };
|
|
292
|
+
}
|
package/src/rename.mjs
CHANGED
|
@@ -1,107 +1,99 @@
|
|
|
1
|
-
import { existsSync, readFileSync
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { toRepoPath, die, warn } from './util.mjs';
|
|
4
4
|
import { collectDocFiles, resolveDocArg } from './index.mjs';
|
|
5
5
|
import { regenIndex } from './lifecycle.mjs';
|
|
6
|
-
import {
|
|
6
|
+
import { captureGitIndexGeneration, isTracked } from './git.mjs';
|
|
7
7
|
import { green, dim } from './color.mjs';
|
|
8
8
|
import { isInteractive, promptText } from './prompt.mjs';
|
|
9
|
+
import { authorizeManagedDestination, authorizeManagedSource, authorizeManagedSweep } from './managed-path.mjs';
|
|
10
|
+
import { availableSessionId, prepareOwnershipMigration } from './pickup.mjs';
|
|
11
|
+
import { moveFileAtomic } from './atomic-mutation.mjs';
|
|
12
|
+
import { configuredReferenceFields, createReferenceIdentitySet, planReferenceMove, rewriteDocumentReferences } from './reference-planner.mjs';
|
|
9
13
|
|
|
10
14
|
export async function runRename(argv, config, opts = {}) {
|
|
11
15
|
const { dryRun } = opts;
|
|
12
|
-
|
|
13
|
-
// Parse positional args (skip flags)
|
|
14
|
-
const positional = [];
|
|
15
|
-
for (let i = 0; i < argv.length; i++) {
|
|
16
|
-
if (argv[i].startsWith('-')) continue;
|
|
17
|
-
positional.push(argv[i]);
|
|
18
|
-
}
|
|
19
|
-
|
|
16
|
+
const positional = argv.filter(arg => !arg.startsWith('-'));
|
|
20
17
|
const oldInput = positional[0];
|
|
21
18
|
let newInput = positional[1];
|
|
22
|
-
|
|
23
|
-
if (!oldInput) { die('Usage: dotmd rename <old> <new>'); }
|
|
19
|
+
if (!oldInput) die('Usage: dotmd rename <old> <new>');
|
|
24
20
|
if (!newInput) {
|
|
25
|
-
if (isInteractive())
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
} else {
|
|
29
|
-
die('Usage: dotmd rename <old> <new>');
|
|
30
|
-
}
|
|
21
|
+
if (!isInteractive()) die('Usage: dotmd rename <old> <new>');
|
|
22
|
+
newInput = await promptText('New name: ');
|
|
23
|
+
if (!newInput) die('No name provided.');
|
|
31
24
|
}
|
|
32
25
|
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
// Compute new path — cross-directory if input has slashes, same directory otherwise
|
|
26
|
+
let oldPath = resolveDocArg(oldInput, config);
|
|
27
|
+
const sourceAuthorization = authorizeManagedSource(oldPath, config, { kind: 'Rename source' });
|
|
28
|
+
oldPath = sourceAuthorization.path;
|
|
37
29
|
let newPath;
|
|
38
30
|
if (newInput.includes('/') || newInput.includes(path.sep)) {
|
|
39
|
-
|
|
40
|
-
if (!resolved.endsWith('.md')) resolved += '.md';
|
|
41
|
-
newPath = path.resolve(config.repoRoot, resolved);
|
|
31
|
+
newPath = path.resolve(config.repoRoot, newInput.endsWith('.md') ? newInput : `${newInput}.md`);
|
|
42
32
|
} else {
|
|
43
|
-
|
|
44
|
-
if (!newBasename.endsWith('.md')) newBasename += '.md';
|
|
45
|
-
newPath = path.join(path.dirname(oldPath), newBasename);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
if (existsSync(newPath)) {
|
|
49
|
-
die(`Target already exists: ${toRepoPath(newPath, config.repoRoot)}`);
|
|
50
|
-
return;
|
|
33
|
+
newPath = path.join(path.dirname(oldPath), newInput.endsWith('.md') ? newInput : `${newInput}.md`);
|
|
51
34
|
}
|
|
35
|
+
if (existsSync(newPath)) die(`Target already exists: ${toRepoPath(newPath, config.repoRoot)}`);
|
|
36
|
+
newPath = authorizeManagedDestination(newPath, config, { root: sourceAuthorization.root, kind: 'Rename destination' }).path;
|
|
52
37
|
|
|
53
38
|
const oldRepoPath = toRepoPath(oldPath, config.repoRoot);
|
|
54
39
|
const newRepoPath = toRepoPath(newPath, config.repoRoot);
|
|
55
|
-
const oldBasename = path.basename(oldPath);
|
|
56
|
-
const newBasename = path.basename(newPath);
|
|
57
|
-
|
|
58
|
-
// Scan for references in other docs
|
|
59
40
|
const allFiles = collectDocFiles(config);
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
filesToUpdate.push(filePath);
|
|
67
|
-
}
|
|
41
|
+
authorizeManagedSweep(allFiles, config, { kind: 'Rename reference rewrite source' });
|
|
42
|
+
const documents = allFiles.map(filePath => ({ path: filePath, content: readFileSync(filePath, 'utf8') }));
|
|
43
|
+
let sourceDocument = documents.find(document => document.path === oldPath);
|
|
44
|
+
if (!sourceDocument) {
|
|
45
|
+
sourceDocument = { path: oldPath, content: readFileSync(oldPath, 'utf8') };
|
|
46
|
+
documents.push(sourceDocument);
|
|
68
47
|
}
|
|
48
|
+
const sourceContent = sourceDocument.content;
|
|
49
|
+
const referenceFields = configuredReferenceFields(config);
|
|
50
|
+
const referencePlan = planReferenceMove({ documents, oldPath, newPath, repoRoot: config.repoRoot, referenceFields });
|
|
51
|
+
const ownership = prepareOwnershipMigration(oldRepoPath, newPath, config, { sessionId: availableSessionId() });
|
|
69
52
|
|
|
70
53
|
if (dryRun) {
|
|
71
54
|
const prefix = dim('[dry-run]');
|
|
72
55
|
process.stdout.write(`${prefix} Would rename: ${oldRepoPath} → ${newRepoPath}\n`);
|
|
73
|
-
if (
|
|
74
|
-
process.stdout.write(`${prefix} Would update references in ${
|
|
75
|
-
for (const
|
|
76
|
-
process.stdout.write(`${prefix} ${toRepoPath(f, config.repoRoot)}\n`);
|
|
77
|
-
}
|
|
56
|
+
if (referencePlan.updates.length) {
|
|
57
|
+
process.stdout.write(`${prefix} Would update references in ${referencePlan.updates.length} file(s):\n`);
|
|
58
|
+
for (const item of referencePlan.updates) process.stdout.write(`${prefix} ${toRepoPath(item.path, config.repoRoot)}\n`);
|
|
78
59
|
}
|
|
60
|
+
if (ownership) process.stdout.write(`${prefix} Would migrate this session's ownership record.\n`);
|
|
79
61
|
return;
|
|
80
62
|
}
|
|
81
63
|
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
64
|
+
const tracked = isTracked(oldPath, config.repoRoot);
|
|
65
|
+
const gitIndex = tracked ? captureGitIndexGeneration(config.repoRoot) : null;
|
|
66
|
+
const identities = createReferenceIdentitySet(allFiles);
|
|
67
|
+
const result = moveFileAtomic(oldPath, newPath, current => rewriteDocumentReferences(current, {
|
|
68
|
+
sourcePath: oldPath,
|
|
69
|
+
outputPath: newPath,
|
|
70
|
+
repoRoot: config.repoRoot,
|
|
71
|
+
identities,
|
|
72
|
+
oldPath,
|
|
73
|
+
newPath,
|
|
74
|
+
referenceFields,
|
|
75
|
+
rebaseAll: true,
|
|
76
|
+
}), {
|
|
77
|
+
repoRoot: config.repoRoot,
|
|
78
|
+
config,
|
|
79
|
+
updates: allFiles.filter(filePath => filePath !== oldPath).map(filePath => ({
|
|
80
|
+
path: filePath,
|
|
81
|
+
render: current => rewriteDocumentReferences(current, {
|
|
82
|
+
sourcePath: filePath, repoRoot: config.repoRoot, identities, oldPath, newPath, referenceFields,
|
|
83
|
+
}),
|
|
84
|
+
})),
|
|
85
|
+
creations: ownership ? [{ path: ownership.newRecordPath, content: ownership.newContent, label: 'ownership' }] : [],
|
|
86
|
+
deletions: ownership ? [{ path: ownership.oldRecordPath, expectedContent: ownership.oldContent, label: 'ownership' }] : [],
|
|
87
|
+
gitMove: tracked,
|
|
88
|
+
gitIndex,
|
|
89
|
+
operation: 'rename',
|
|
90
|
+
sessionId: availableSessionId(),
|
|
91
|
+
testHooks: opts.testHooks,
|
|
92
|
+
});
|
|
99
93
|
regenIndex(config);
|
|
100
|
-
|
|
101
94
|
process.stdout.write(`${green('Renamed')}: ${oldRepoPath} → ${newRepoPath}\n`);
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
try { config.hooks.onRename?.({ oldPath: oldRepoPath, newPath: newRepoPath, referencesUpdated: updatedCount }); } catch (err) { warn(`Hook 'onRename' threw: ${err.message}`); }
|
|
95
|
+
if (result.updatedPaths.length) process.stdout.write(`Updated references in ${result.updatedPaths.length} file(s).\n`);
|
|
96
|
+
try { config.hooks.onRename?.({ oldPath: oldRepoPath, newPath: newRepoPath, referencesUpdated: result.updatedPaths.length }); }
|
|
97
|
+
catch (err) { warn(`Hook 'onRename' threw: ${err.message}`); }
|
|
98
|
+
return { oldRepoPath, newRepoPath, referencePaths: result.updatedPaths.map(item => toRepoPath(item, config.repoRoot)) };
|
|
107
99
|
}
|
package/src/render.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { summarizeDocBody } from './ai.mjs';
|
|
|
6
6
|
import { bold, red, yellow, green, dim } from './color.mjs';
|
|
7
7
|
import { categorizeWarnings } from './check-collapse.mjs';
|
|
8
8
|
import { buildCoordinationIndex, isRoadmapHub } from './runlist.mjs';
|
|
9
|
+
import { resolveStatusMetadata, statusMetadataFor } from './status-metadata.mjs';
|
|
9
10
|
|
|
10
11
|
// Render `currentState` with an `(auto)` prefix when the value was body-scraped
|
|
11
12
|
// rather than read from frontmatter. Lets a reader see at a glance which docs
|
|
@@ -20,7 +21,7 @@ export function formatCurrentState(doc) {
|
|
|
20
21
|
|
|
21
22
|
export function renderCompactList(index, config) {
|
|
22
23
|
const defaultRenderer = (idx) => _renderCompactList(idx, config);
|
|
23
|
-
if (config.hooks.renderCompactList) {
|
|
24
|
+
if (!config._execution?.suppressSideEffects && config.hooks.renderCompactList) {
|
|
24
25
|
try { return config.hooks.renderCompactList(index, defaultRenderer); }
|
|
25
26
|
catch (err) { warn(`Hook 'renderCompactList' threw: ${err.message}`); }
|
|
26
27
|
}
|
|
@@ -162,7 +163,7 @@ export function renderVerboseList(index, config) {
|
|
|
162
163
|
|
|
163
164
|
export function renderContext(index, config, opts = {}) {
|
|
164
165
|
const defaultRenderer = (idx) => _renderContext(idx, config, opts);
|
|
165
|
-
if (config.hooks.renderContext) {
|
|
166
|
+
if (!config._execution?.suppressSideEffects && config.hooks.renderContext) {
|
|
166
167
|
try { return config.hooks.renderContext(index, defaultRenderer); }
|
|
167
168
|
catch (err) { warn(`Hook 'renderContext' threw: ${err.message}`); }
|
|
168
169
|
}
|
|
@@ -190,7 +191,7 @@ function _renderContextSection(docs, ctx, opts, config, lines) {
|
|
|
190
191
|
? truncate(doc.nextStep, ctx.truncateNextStep || 80)
|
|
191
192
|
: '(no next step)';
|
|
192
193
|
lines.push(` ${slug}${ageTag} next: ${next}`);
|
|
193
|
-
if (opts.summarize) {
|
|
194
|
+
if (opts.summarize && !config._execution?.suppressSideEffects) {
|
|
194
195
|
try {
|
|
195
196
|
const absPath = path.resolve(config.repoRoot, doc.path);
|
|
196
197
|
const raw = readFileSync(absPath, 'utf8');
|
|
@@ -225,6 +226,9 @@ function _renderContextSection(docs, ctx, opts, config, lines) {
|
|
|
225
226
|
function _renderContext(index, config, opts = {}) {
|
|
226
227
|
const today = new Date().toISOString().slice(0, 10);
|
|
227
228
|
const lines = [`BRIEFING (${today})`, ''];
|
|
229
|
+
if (opts.summarize && config._execution?.suppressSideEffects) {
|
|
230
|
+
lines.push(dim('[preview] AI summaries skipped; models and custom summarizeDoc hooks are not invoked.'), '');
|
|
231
|
+
}
|
|
228
232
|
|
|
229
233
|
// Group docs by type
|
|
230
234
|
const typeOrder = ['plan', 'doc', 'research'];
|
|
@@ -352,11 +356,16 @@ export function renderBriefing(index, config) {
|
|
|
352
356
|
// headline plan count entirely (they get their own `N runlists · dotmd
|
|
353
357
|
// runlists` pointer line below). The breakdown is leaf statuses only and sums
|
|
354
358
|
// back to the leaf count, so "N live plans" means N things to actually work on.
|
|
355
|
-
const
|
|
359
|
+
const planStatusOrder = (resolveStatusMetadata(config).byType.plan ?? []).map(item => item.name);
|
|
360
|
+
const orderedStatuses = [
|
|
361
|
+
...planStatusOrder.filter(status => bySt[status]),
|
|
362
|
+
...Object.keys(bySt).filter(status => !planStatusOrder.includes(status)).sort(),
|
|
363
|
+
];
|
|
364
|
+
const counts = orderedStatuses.map(status => `${bySt[status]} ${status}`).join(', ');
|
|
356
365
|
const closedCount = plans.length - live.length;
|
|
357
366
|
const closedPart = closedCount ? ` (${closedCount} archived)` : '';
|
|
358
367
|
lines.push(liveLeaves ? `${liveLeaves} live plans${closedPart}: ${counts}` : `0 live plans${closedPart}`);
|
|
359
|
-
const show = plans.filter(p => (
|
|
368
|
+
const show = plans.filter(p => statusMetadataFor(config, 'plan', p.status)?.context === 'expanded' && !isHub(p));
|
|
360
369
|
for (const p of show) {
|
|
361
370
|
const next = p.nextStep ? `next: ${p.nextStep}` : '(no next step)';
|
|
362
371
|
lines.push(` > ${path.basename(p.path, '.md')} (${p.status}) ${next}`);
|
|
@@ -382,7 +391,7 @@ export function renderBriefing(index, config) {
|
|
|
382
391
|
if (untyped.length) parts.push(`${untyped.length} untyped`);
|
|
383
392
|
if (parts.length) lines.push(parts.join(' | '));
|
|
384
393
|
|
|
385
|
-
const stale = index.docs.filter(d => d.isStale && !config.
|
|
394
|
+
const stale = index.docs.filter(d => d.isStale && !statusMetadataFor(config, d.type, d.status)?.skipStale).length;
|
|
386
395
|
// Append a hint when errors are present — otherwise the user sees `Errors: 1`
|
|
387
396
|
// with no clue what or where. `dotmd check` is the canonical detail view.
|
|
388
397
|
const errorCount = index.errors.length;
|
|
@@ -396,7 +405,7 @@ export function renderBriefing(index, config) {
|
|
|
396
405
|
|
|
397
406
|
export function renderCheck(index, config, opts = {}) {
|
|
398
407
|
const defaultRenderer = (idx) => _renderCheck(idx, opts);
|
|
399
|
-
if (config.hooks.renderCheck) {
|
|
408
|
+
if (!config._execution?.suppressSideEffects && config.hooks.renderCheck) {
|
|
400
409
|
try { return config.hooks.renderCheck(index, defaultRenderer); }
|
|
401
410
|
catch (err) { warn(`Hook 'renderCheck' threw: ${err.message}`); }
|
|
402
411
|
}
|
|
@@ -586,7 +595,7 @@ export function renderProgressBar(checklist) {
|
|
|
586
595
|
|
|
587
596
|
export function formatSnapshot(doc, config) {
|
|
588
597
|
const defaultFormatter = (d) => _formatSnapshot(d, config);
|
|
589
|
-
if (config.hooks.formatSnapshot) {
|
|
598
|
+
if (!config._execution?.suppressSideEffects && config.hooks.formatSnapshot) {
|
|
590
599
|
try { return config.hooks.formatSnapshot(doc, defaultFormatter); }
|
|
591
600
|
catch (err) { warn(`Hook 'formatSnapshot' threw: ${err.message}`); }
|
|
592
601
|
}
|