dotmd-cli 0.69.0 → 0.70.1
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 +251 -202
- 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/check-collapse.mjs +2 -2
- 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 +25 -15
- package/src/export.mjs +154 -25
- package/src/fix-refs.mjs +2 -0
- package/src/frontmatter-fix.mjs +9 -7
- package/src/frontmatter.mjs +3 -2
- package/src/git.mjs +722 -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 +21 -13
- package/src/init.mjs +1 -1
- package/src/journal.mjs +145 -12
- package/src/lifecycle.mjs +596 -294
- package/src/lint.mjs +117 -56
- 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 +24 -11
- 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 +53 -17
- package/src/watch.mjs +6 -1
- package/src/notion.mjs +0 -528
package/src/lint.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { buildIndex, collectDocFiles } from './index.mjs';
|
|
|
6
6
|
import { updateFrontmatter } from './lifecycle.mjs';
|
|
7
7
|
import { runMLX, checkUvAvailable } from './ai.mjs';
|
|
8
8
|
import { bold, green, yellow, dim } from './color.mjs';
|
|
9
|
+
import { authorizeManagedSweep, findLexicalDocsRoot } from './managed-path.mjs';
|
|
9
10
|
|
|
10
11
|
const KEY_RENAMES = {
|
|
11
12
|
nextStep: 'next_step',
|
|
@@ -16,6 +17,55 @@ const KEY_RENAMES = {
|
|
|
16
17
|
supportsPlans: 'supports_plans',
|
|
17
18
|
};
|
|
18
19
|
|
|
20
|
+
function frontmatterFieldBlock(frontmatter, field) {
|
|
21
|
+
const lines = frontmatter.split(/\r?\n/);
|
|
22
|
+
const fieldRe = new RegExp(`^${escapeRegex(field)}:`);
|
|
23
|
+
const start = lines.findIndex(line => fieldRe.test(line));
|
|
24
|
+
if (start < 0) return null;
|
|
25
|
+
let end = start + 1;
|
|
26
|
+
while (end < lines.length) {
|
|
27
|
+
if (/^[ \t]/.test(lines[end]) || lines[end].trim() === '') {
|
|
28
|
+
end++;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (lines[end].trimStart().startsWith('#')) {
|
|
32
|
+
let next = end + 1;
|
|
33
|
+
while (next < lines.length && (lines[next].trim() === '' || lines[next].trimStart().startsWith('#'))) next++;
|
|
34
|
+
if (next < lines.length && /^[ \t]/.test(lines[next])) {
|
|
35
|
+
end++;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
return { lines, start, end };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function replaceFrontmatterFieldBlock(frontmatter, field, replacement = '') {
|
|
45
|
+
const block = frontmatterFieldBlock(frontmatter, field);
|
|
46
|
+
if (!block) return frontmatter;
|
|
47
|
+
const { lines, start, end } = block;
|
|
48
|
+
lines.splice(start, end - start, ...(replacement ? replacement.split('\n') : []));
|
|
49
|
+
return lines.join('\n');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function frontmatterFieldComments(frontmatter, field) {
|
|
53
|
+
const block = frontmatterFieldBlock(frontmatter, field);
|
|
54
|
+
if (!block) return [];
|
|
55
|
+
return block.lines
|
|
56
|
+
.slice(block.start + 1, block.end)
|
|
57
|
+
.map(line => line.trim())
|
|
58
|
+
.filter(line => line.startsWith('#'));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isAutoFixableString(value) {
|
|
62
|
+
return typeof value === 'string' && !/(^|\s)#/.test(value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatYamlScalar(value) {
|
|
66
|
+
return /^[A-Za-z0-9_./-]+$/.test(value) ? value : JSON.stringify(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
19
69
|
export function runLint(argv, config, opts = {}) {
|
|
20
70
|
const { dryRun } = opts;
|
|
21
71
|
const fix = argv.includes('--fix');
|
|
@@ -29,11 +79,11 @@ export function runLint(argv, config, opts = {}) {
|
|
|
29
79
|
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
30
80
|
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
31
81
|
const fixes = [];
|
|
82
|
+
const skipWarnings = config.lifecycle.skipWarningsFor.has(asString(parsed.status));
|
|
32
83
|
|
|
33
84
|
// Missing type (fixable — infer from root: plans → 'plan', else 'doc')
|
|
34
85
|
if (!asString(parsed.type)) {
|
|
35
|
-
const
|
|
36
|
-
const docRoot = roots.find(r => filePath.startsWith(r)) ?? config.docsRoot;
|
|
86
|
+
const docRoot = findLexicalDocsRoot(filePath, config) ?? config.docsRoot;
|
|
37
87
|
const rootLabel = path.relative(config.repoRoot, docRoot).split(path.sep).join('/');
|
|
38
88
|
// If the root label contains 'plan' (e.g. 'docs/plans'), default to plan type
|
|
39
89
|
const inferredType = rootLabel.includes('plan') ? 'plan' : 'doc';
|
|
@@ -46,7 +96,7 @@ export function runLint(argv, config, opts = {}) {
|
|
|
46
96
|
}
|
|
47
97
|
|
|
48
98
|
// Missing updated
|
|
49
|
-
if (!asString(parsed.updated) && asString(parsed.status) && !
|
|
99
|
+
if (!asString(parsed.updated) && asString(parsed.status) && !skipWarnings) {
|
|
50
100
|
const today = new Date().toISOString().slice(0, 10);
|
|
51
101
|
fixes.push({ field: 'updated', oldValue: null, newValue: today, type: 'add' });
|
|
52
102
|
}
|
|
@@ -72,26 +122,31 @@ export function runLint(argv, config, opts = {}) {
|
|
|
72
122
|
// split on `,`, single values become a one-item list. Merging with any
|
|
73
123
|
// existing plural array happens at apply-time so the message reflects
|
|
74
124
|
// just what's being introduced from the singular form.
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
125
|
+
if (!skipWarnings) {
|
|
126
|
+
for (const { singular, plural } of [{ singular: 'module', plural: 'modules' }, { singular: 'surface', plural: 'surfaces' }]) {
|
|
127
|
+
const rawVal = parsed[singular];
|
|
128
|
+
const rawPluralVal = parsed[plural];
|
|
129
|
+
const val = asString(rawVal);
|
|
130
|
+
const scalarValue = val && isAutoFixableString(val) ? val : null;
|
|
131
|
+
const blockValues = Array.isArray(rawVal) && rawVal.every(isAutoFixableString)
|
|
132
|
+
? rawVal.map(item => item.trim()).filter(Boolean)
|
|
133
|
+
: [];
|
|
134
|
+
const pluralAutoFixable = rawPluralVal === undefined
|
|
135
|
+
|| isAutoFixableString(rawPluralVal)
|
|
136
|
+
|| (Array.isArray(rawPluralVal) && rawPluralVal.every(isAutoFixableString));
|
|
137
|
+
if (pluralAutoFixable && (scalarValue || blockValues.length > 0)) {
|
|
138
|
+
const values = blockValues.length > 0
|
|
139
|
+
? blockValues
|
|
140
|
+
: scalarValue.includes(',')
|
|
141
|
+
? scalarValue.split(',').map(s => s.trim()).filter(Boolean)
|
|
142
|
+
: [scalarValue];
|
|
143
|
+
fixes.push({ field: singular, oldValue: scalarValue || blockValues.join(', '), newValue: values, pluralKey: plural, type: 'singular-to-plural' });
|
|
144
|
+
} else if (pluralAutoFixable && singular in parsed && (
|
|
145
|
+
(typeof rawVal === 'string' && !rawVal.trim())
|
|
146
|
+
|| (Array.isArray(rawVal) && rawVal.every(item => typeof item === 'string' && !item.trim()))
|
|
147
|
+
)) {
|
|
148
|
+
fixes.push({ field: singular, pluralKey: plural, type: 'drop-singular' });
|
|
149
|
+
}
|
|
95
150
|
}
|
|
96
151
|
}
|
|
97
152
|
|
|
@@ -113,27 +168,31 @@ export function runLint(argv, config, opts = {}) {
|
|
|
113
168
|
}
|
|
114
169
|
}
|
|
115
170
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
171
|
+
let nonFixable = [];
|
|
172
|
+
if (!fix) {
|
|
173
|
+
// Report-only mode also gets non-fixable issues from the index. Fix mode
|
|
174
|
+
// does not render these and must not pay for a full validation/Git scan.
|
|
175
|
+
const index = buildIndex(config);
|
|
176
|
+
const fixablePaths = new Set(fixable.map(f => f.repoPath));
|
|
177
|
+
// Singular-key deprecation warnings whose fix `lint --fix` will actually make.
|
|
178
|
+
// Without this, those warnings landed in BOTH the "fixable" preview and the
|
|
179
|
+
// "non-fixable" list, so the header claimed "N non-fixable" while every line
|
|
180
|
+
// told you to run `lint --fix` (issue #17, item 8).
|
|
181
|
+
const fixableSingularKeys = new Set();
|
|
182
|
+
for (const { repoPath, fixes } of fixable) {
|
|
183
|
+
for (const f of fixes) {
|
|
184
|
+
if (f.type === 'singular-to-plural' || f.type === 'drop-singular') {
|
|
185
|
+
fixableSingularKeys.add(`${repoPath}::${f.field}`);
|
|
186
|
+
}
|
|
128
187
|
}
|
|
129
188
|
}
|
|
189
|
+
nonFixable = [...index.errors, ...index.warnings].filter(issue => {
|
|
190
|
+
if (issue.message.includes('Missing frontmatter `status`') && fixablePaths.has(issue.path)) return false;
|
|
191
|
+
const dep = issue.message.match(/^`(module|surface):` \(singular\) is deprecated/);
|
|
192
|
+
if (dep && fixableSingularKeys.has(`${issue.path}::${dep[1]}`)) return false;
|
|
193
|
+
return true;
|
|
194
|
+
});
|
|
130
195
|
}
|
|
131
|
-
const nonFixable = [...index.errors, ...index.warnings].filter(issue => {
|
|
132
|
-
if (issue.message.includes('Missing frontmatter `status`') && fixablePaths.has(issue.path)) return false;
|
|
133
|
-
const dep = issue.message.match(/^`(module|surface):` \(singular\) is deprecated/);
|
|
134
|
-
if (dep && fixableSingularKeys.has(`${issue.path}::${dep[1]}`)) return false;
|
|
135
|
-
return true;
|
|
136
|
-
});
|
|
137
196
|
|
|
138
197
|
if (!fix) {
|
|
139
198
|
// Report mode
|
|
@@ -176,6 +235,7 @@ export function runLint(argv, config, opts = {}) {
|
|
|
176
235
|
}
|
|
177
236
|
|
|
178
237
|
// Fix mode
|
|
238
|
+
authorizeManagedSweep(fixable.map(item => item.filePath), config, { kind: 'Lint fix source' });
|
|
179
239
|
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
180
240
|
let totalFixes = 0;
|
|
181
241
|
|
|
@@ -224,22 +284,25 @@ export function runLint(argv, config, opts = {}) {
|
|
|
224
284
|
}
|
|
225
285
|
|
|
226
286
|
// Apply singular-to-plural fixes (module/surface → modules/surfaces array).
|
|
227
|
-
// Removes the singular key
|
|
287
|
+
// Removes the singular key block; merges its value(s) into the plural array,
|
|
228
288
|
// or creates the plural block if absent. Duplicates are skipped.
|
|
229
289
|
for (const sa of singularToPlural) {
|
|
230
290
|
let raw = readFileSync(filePath, 'utf8');
|
|
231
291
|
const { frontmatter: fm } = extractFrontmatter(raw);
|
|
232
|
-
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
292
|
+
const parsedFm = parseSimpleFrontmatter(fm);
|
|
293
|
+
const pluralValue = parsedFm[sa.pluralKey];
|
|
294
|
+
const existing = Array.isArray(pluralValue)
|
|
295
|
+
? pluralValue.filter(value => typeof value === 'string' && value.trim())
|
|
296
|
+
: asString(pluralValue) ? [asString(pluralValue)] : [];
|
|
297
|
+
const merged = [...new Set([...sa.newValue, ...existing])];
|
|
298
|
+
const comments = [...frontmatterFieldComments(fm, sa.field), ...frontmatterFieldComments(fm, sa.pluralKey)];
|
|
299
|
+
let newFm = replaceFrontmatterFieldBlock(fm, sa.field);
|
|
300
|
+
const pluralLines = [sa.pluralKey + ':', ...comments.map(comment => ` ${comment}`), ...merged.map(v => ` - ${formatYamlScalar(v)}`)];
|
|
301
|
+
const pluralBlock = pluralLines.join('\n');
|
|
302
|
+
if (Object.prototype.hasOwnProperty.call(parsedFm, sa.pluralKey)) {
|
|
303
|
+
newFm = replaceFrontmatterFieldBlock(newFm, sa.pluralKey, pluralBlock);
|
|
241
304
|
} else {
|
|
242
|
-
newFm
|
|
305
|
+
newFm = `${newFm.trim()}\n${pluralBlock}`;
|
|
243
306
|
}
|
|
244
307
|
raw = replaceFrontmatter(raw, newFm.trim());
|
|
245
308
|
writeFileSync(filePath, raw, 'utf8');
|
|
@@ -251,9 +314,8 @@ export function runLint(argv, config, opts = {}) {
|
|
|
251
314
|
for (const ds of dropSingular) {
|
|
252
315
|
let raw = readFileSync(filePath, 'utf8');
|
|
253
316
|
const { frontmatter: fm } = extractFrontmatter(raw);
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
.replace(/\n{2,}/g, '\n');
|
|
317
|
+
const comments = frontmatterFieldComments(fm, ds.field);
|
|
318
|
+
const newFm = replaceFrontmatterFieldBlock(fm, ds.field, comments.join('\n'));
|
|
257
319
|
raw = replaceFrontmatter(raw, newFm.trim());
|
|
258
320
|
writeFileSync(filePath, raw, 'utf8');
|
|
259
321
|
}
|
|
@@ -322,4 +384,3 @@ export function runLint(argv, config, opts = {}) {
|
|
|
322
384
|
|
|
323
385
|
process.stdout.write(`\n${prefix}${totalFixes} fix${totalFixes !== 1 ? 'es' : ''} applied across ${fixable.length} file(s).\n`);
|
|
324
386
|
}
|
|
325
|
-
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { existsSync, lstatSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
// These checks are authorization preflights, not filesystem transactions. A
|
|
5
|
+
// concurrent actor can still replace a checked ancestor before the later write
|
|
6
|
+
// (TOCTOU); eliminating that residual boundary requires descriptor-relative IO.
|
|
7
|
+
|
|
8
|
+
function contains(parent, child) {
|
|
9
|
+
const rel = path.relative(parent, child);
|
|
10
|
+
return rel === '' || (!rel.startsWith(`..${path.sep}`) && rel !== '..' && !path.isAbsolute(rel));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function sameNode(a, b) {
|
|
14
|
+
try {
|
|
15
|
+
const left = statSync(a, { bigint: true });
|
|
16
|
+
const right = statSync(b, { bigint: true });
|
|
17
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
18
|
+
} catch { return false; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// realpath on case-insensitive macOS volumes may preserve the caller's casing.
|
|
22
|
+
// Prove an alias by inode before treating it as contained; lowercase string
|
|
23
|
+
// comparison alone would be unsafe on case-sensitive volumes.
|
|
24
|
+
function aliasContainment(parent, child) {
|
|
25
|
+
if (contains(parent, child)) return { contained: true, relative: path.relative(parent, child) };
|
|
26
|
+
const parentParts = path.resolve(parent).split(path.sep).filter(Boolean);
|
|
27
|
+
const childParts = path.resolve(child).split(path.sep).filter(Boolean);
|
|
28
|
+
if (childParts.length < parentParts.length) return { contained: false, relative: null };
|
|
29
|
+
const childPrefix = `${path.parse(path.resolve(child)).root}${childParts.slice(0, parentParts.length).join(path.sep)}`;
|
|
30
|
+
if (!sameNode(parent, childPrefix)) return { contained: false, relative: null };
|
|
31
|
+
return { contained: true, relative: childParts.slice(parentParts.length).join(path.sep) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function lexicalRootsFor(config) {
|
|
35
|
+
return (config.docsRoots ?? [config.docsRoot]).map(configuredPath => ({
|
|
36
|
+
configuredPath,
|
|
37
|
+
lexicalPath: path.resolve(configuredPath),
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function rootsFor(config) {
|
|
42
|
+
return lexicalRootsFor(config).map(root => {
|
|
43
|
+
const { configuredPath, lexicalPath } = root;
|
|
44
|
+
let canonicalPath;
|
|
45
|
+
try {
|
|
46
|
+
canonicalPath = realpathSync(lexicalPath);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
throw new Error(`Configured docs root cannot be resolved: ${lexicalPath} (${err.message})`);
|
|
49
|
+
}
|
|
50
|
+
return { configuredPath, lexicalPath, canonicalPath };
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function rootsMessage(roots) {
|
|
55
|
+
return roots.map(root => root.lexicalPath).join(', ');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function markdown(pathname, kind, roots) {
|
|
59
|
+
if (!pathname.endsWith('.md')) {
|
|
60
|
+
throw new Error(`${kind} must be a Markdown file ending in .md: ${pathname}\nConfigured docs roots: ${rootsMessage(roots)}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function nearestExistingAncestor(input, roots, kind) {
|
|
65
|
+
let current = input;
|
|
66
|
+
while (!existsSync(current)) {
|
|
67
|
+
try {
|
|
68
|
+
// existsSync is false for dangling symlinks, but lstat still sees them.
|
|
69
|
+
if (lstatSync(current).isSymbolicLink()) {
|
|
70
|
+
throw new Error(`dangling symlink: ${current}`);
|
|
71
|
+
}
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (err?.code !== 'ENOENT' && err?.code !== 'ENOTDIR') {
|
|
74
|
+
throw new Error(`${kind} has an unsafe existing ancestor: ${current} (${err.message})\nConfigured docs roots: ${rootsMessage(roots)}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const parent = path.dirname(current);
|
|
78
|
+
if (parent === current) break;
|
|
79
|
+
current = parent;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
return realpathSync(current);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
throw new Error(`${kind} ancestor cannot be resolved: ${current} (${err.message})\nConfigured docs roots: ${rootsMessage(roots)}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function lexicalOwner(input, roots) {
|
|
89
|
+
return roots
|
|
90
|
+
.filter(root => contains(root.lexicalPath, input))
|
|
91
|
+
.sort((a, b) => b.lexicalPath.length - a.lexicalPath.length)[0] ?? null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function findLexicalDocsRoot(input, config) {
|
|
95
|
+
return lexicalOwner(path.resolve(input), lexicalRootsFor(config))?.lexicalPath ?? null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function authorizeManagedSource(input, config, { kind = 'Managed mutation source' } = {}) {
|
|
99
|
+
const roots = rootsFor(config);
|
|
100
|
+
const lexicalPath = path.resolve(input);
|
|
101
|
+
markdown(lexicalPath, kind, roots);
|
|
102
|
+
|
|
103
|
+
if (!existsSync(lexicalPath)) {
|
|
104
|
+
throw new Error(`${kind} does not exist: ${lexicalPath}\nConfigured docs roots: ${rootsMessage(roots)}`);
|
|
105
|
+
}
|
|
106
|
+
if (lstatSync(lexicalPath).isSymbolicLink()) {
|
|
107
|
+
throw new Error(`${kind} may not be a symlink: ${lexicalPath}\nConfigured docs roots: ${rootsMessage(roots)}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let canonicalPath;
|
|
111
|
+
try {
|
|
112
|
+
canonicalPath = realpathSync(lexicalPath);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
throw new Error(`${kind} cannot be resolved: ${lexicalPath} (${err.message})\nConfigured docs roots: ${rootsMessage(roots)}`);
|
|
115
|
+
}
|
|
116
|
+
if (!statSync(canonicalPath).isFile()) {
|
|
117
|
+
throw new Error(`${kind} is not a file: ${lexicalPath}\nConfigured docs roots: ${rootsMessage(roots)}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const lexical = lexicalOwner(lexicalPath, roots);
|
|
121
|
+
if (lexical && !aliasContainment(lexical.canonicalPath, canonicalPath).contained) {
|
|
122
|
+
throw new Error(`${kind} is lexically owned by ${lexical.lexicalPath} but resolves outside that root: ${lexicalPath} -> ${canonicalPath}\nConfigured docs roots: ${rootsMessage(roots)}`);
|
|
123
|
+
}
|
|
124
|
+
const root = lexical
|
|
125
|
+
?? roots.filter(candidate => aliasContainment(candidate.canonicalPath, canonicalPath).contained)
|
|
126
|
+
.sort((a, b) => b.canonicalPath.length - a.canonicalPath.length)[0];
|
|
127
|
+
if (!root) {
|
|
128
|
+
throw new Error(`${kind} resolves outside configured docs roots: ${lexicalPath} -> ${canonicalPath}\nConfigured docs roots: ${rootsMessage(roots)}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Keep operations on the configured root spelling (important when the root
|
|
132
|
+
// itself is a symlink), while normalizing OS-level aliases such as /var ->
|
|
133
|
+
// /private/var back through the owning root.
|
|
134
|
+
const managedPath = lexical
|
|
135
|
+
? lexicalPath
|
|
136
|
+
: path.join(root.lexicalPath, aliasContainment(root.canonicalPath, canonicalPath).relative);
|
|
137
|
+
return { path: managedPath, canonicalPath, root };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function authorizeManagedDestination(input, config, { root: requiredRoot = null, kind = 'Managed mutation destination' } = {}) {
|
|
141
|
+
const roots = rootsFor(config);
|
|
142
|
+
const lexicalPath = path.resolve(input);
|
|
143
|
+
markdown(lexicalPath, kind, roots);
|
|
144
|
+
|
|
145
|
+
const root = requiredRoot
|
|
146
|
+
? roots.find(candidate => candidate.lexicalPath === requiredRoot.lexicalPath && candidate.canonicalPath === requiredRoot.canonicalPath)
|
|
147
|
+
: lexicalOwner(lexicalPath, roots);
|
|
148
|
+
if (!root || !contains(root.lexicalPath, lexicalPath)) {
|
|
149
|
+
const ownership = requiredRoot ? `Owning docs root: ${requiredRoot.lexicalPath}\n` : '';
|
|
150
|
+
throw new Error(`${kind} is lexically outside its configured docs root: ${lexicalPath}\n${ownership}Configured docs roots: ${rootsMessage(roots)}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const ancestor = nearestExistingAncestor(lexicalPath, roots, kind);
|
|
154
|
+
if (!aliasContainment(root.canonicalPath, ancestor).contained) {
|
|
155
|
+
throw new Error(`${kind} escapes through an existing symlinked parent: ${lexicalPath} -> ${ancestor}\nOwning docs root: ${root.lexicalPath}\nConfigured docs roots: ${rootsMessage(roots)}`);
|
|
156
|
+
}
|
|
157
|
+
return { path: lexicalPath, canonicalPath: existsSync(lexicalPath) ? realpathSync(lexicalPath) : null, root };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function authorizeManagedMove(source, destination, config, options = {}) {
|
|
161
|
+
const authorizedSource = authorizeManagedSource(source, config, options);
|
|
162
|
+
const authorizedDestination = authorizeManagedDestination(destination, config, {
|
|
163
|
+
...options,
|
|
164
|
+
kind: options.destinationKind ?? 'Managed mutation destination',
|
|
165
|
+
root: authorizedSource.root,
|
|
166
|
+
});
|
|
167
|
+
return { source: authorizedSource, destination: authorizedDestination };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function authorizeManagedSweep(files, config, { kind = 'Managed mutation sweep' } = {}) {
|
|
171
|
+
return files.map(file => authorizeManagedSource(file, config, { kind }));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function authorizeRepoGeneratedPath(input, config, { kind = 'Repository-generated destination' } = {}) {
|
|
175
|
+
const lexicalPath = path.resolve(input);
|
|
176
|
+
const repoRoot = path.resolve(config.repoRoot);
|
|
177
|
+
let canonicalRepo;
|
|
178
|
+
try { canonicalRepo = realpathSync(repoRoot); }
|
|
179
|
+
catch (err) { throw new Error(`Repository root cannot be resolved: ${repoRoot} (${err.message})`); }
|
|
180
|
+
|
|
181
|
+
if (!contains(repoRoot, lexicalPath)) {
|
|
182
|
+
throw new Error(`${kind} is outside the repository: ${lexicalPath}\nRepository root: ${repoRoot}`);
|
|
183
|
+
}
|
|
184
|
+
const ancestor = nearestExistingAncestor(lexicalPath, [{ lexicalPath: repoRoot }], kind);
|
|
185
|
+
if (!contains(canonicalRepo, ancestor)) {
|
|
186
|
+
throw new Error(`${kind} escapes through an existing symlinked parent: ${lexicalPath} -> ${ancestor}\nRepository root: ${repoRoot}`);
|
|
187
|
+
}
|
|
188
|
+
if (existsSync(lexicalPath) && lstatSync(lexicalPath).isSymbolicLink()) {
|
|
189
|
+
throw new Error(`${kind} may not be a symlink: ${lexicalPath}\nRepository root: ${repoRoot}`);
|
|
190
|
+
}
|
|
191
|
+
return { path: lexicalPath, canonicalPath: existsSync(lexicalPath) ? realpathSync(lexicalPath) : null };
|
|
192
|
+
}
|
package/src/migrate-prompts.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { getGitFirstAdded } from './git.mjs';
|
|
|
6
6
|
import { bold, green, dim } from './color.mjs';
|
|
7
7
|
import { readFileSync as rfs } from 'node:fs';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { authorizeManagedSweep } from './managed-path.mjs';
|
|
9
10
|
|
|
10
11
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
12
|
const pkg = JSON.parse(rfs(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
@@ -124,6 +125,7 @@ export function runMigratePrompts(argv, config, opts = {}) {
|
|
|
124
125
|
} else {
|
|
125
126
|
files = findPromptCandidates(config);
|
|
126
127
|
}
|
|
128
|
+
authorizeManagedSweep(files, config, { kind: 'Prompt migration source' });
|
|
127
129
|
|
|
128
130
|
const results = [];
|
|
129
131
|
let touched = 0;
|
package/src/migrate-template.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
|
4
4
|
import { asString, toRepoPath, nowIso } from './util.mjs';
|
|
5
5
|
import { collectDocFiles } from './index.mjs';
|
|
6
6
|
import { bold, green, yellow, dim } from './color.mjs';
|
|
7
|
+
import { authorizeManagedSweep } from './managed-path.mjs';
|
|
7
8
|
|
|
8
9
|
const HEADING_RENAMES = [
|
|
9
10
|
{ from: /^##\s+Open questions\s*$/gm, to: '## Open Questions' },
|
|
@@ -117,6 +118,7 @@ export function runMigrateTemplate(argv, config, opts = {}) {
|
|
|
117
118
|
files = collectDocFiles(config);
|
|
118
119
|
if (!includeArchived) files = files.filter(f => !isInArchive(f, config));
|
|
119
120
|
}
|
|
121
|
+
authorizeManagedSweep(files, config, { kind: 'Template migration source' });
|
|
120
122
|
|
|
121
123
|
const results = [];
|
|
122
124
|
let totalChanges = 0;
|
package/src/migrate.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { asString, toRepoPath, resolveDocPath, die } from './util.mjs';
|
|
|
5
5
|
import { collectDocFiles } from './index.mjs';
|
|
6
6
|
import { updateFrontmatter } from './lifecycle.mjs';
|
|
7
7
|
import { bold, green, dim } from './color.mjs';
|
|
8
|
+
import { authorizeManagedSweep } from './managed-path.mjs';
|
|
8
9
|
|
|
9
10
|
export function runMigrate(argv, config, opts = {}) {
|
|
10
11
|
const { dryRun } = opts;
|
|
@@ -38,7 +39,11 @@ export function runMigrate(argv, config, opts = {}) {
|
|
|
38
39
|
matched.push(filePath);
|
|
39
40
|
continue;
|
|
40
41
|
}
|
|
41
|
-
const
|
|
42
|
+
const normalizedInput = input.replaceAll('\\', '/');
|
|
43
|
+
const hits = allFiles.filter(f => {
|
|
44
|
+
const repoPath = toRepoPath(f, config.repoRoot);
|
|
45
|
+
return repoPath.includes(normalizedInput) || path.basename(f).includes(input);
|
|
46
|
+
});
|
|
42
47
|
if (hits.length === 0) {
|
|
43
48
|
unresolved.push(input);
|
|
44
49
|
} else {
|
|
@@ -52,6 +57,7 @@ export function runMigrate(argv, config, opts = {}) {
|
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
const matches = [];
|
|
60
|
+
authorizeManagedSweep(fileFilter ? [...fileFilter] : allFiles, config, { kind: 'Migration source' });
|
|
55
61
|
|
|
56
62
|
for (const filePath of allFiles) {
|
|
57
63
|
if (fileFilter && !fileFilter.has(filePath)) continue;
|