dotmd-cli 0.74.2 → 0.74.3
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/bin/dotmd.mjs +4 -1
- package/package.json +1 -1
- package/src/new.mjs +69 -6
package/bin/dotmd.mjs
CHANGED
|
@@ -919,7 +919,10 @@ Plan body variants (plans only — pick one body shape):
|
|
|
919
919
|
Other options:
|
|
920
920
|
--status <s> Set initial status (defaults to first valid status for the type)
|
|
921
921
|
--title <t> Override the auto-derived title
|
|
922
|
-
--root <name> Create in a specific docs root
|
|
922
|
+
--root <name> Create in a specific docs root. Applies to a nested name
|
|
923
|
+
too: \`new doc prospects/kim --root docs\` writes
|
|
924
|
+
docs/prospects/kim.md. Without it, a name containing a
|
|
925
|
+
\`/\` is read relative to the repo.
|
|
923
926
|
--show-files Append \`files: …\` line to stderr listing what was touched
|
|
924
927
|
(the new doc + the index file). See \`dotmd archive --help\`.
|
|
925
928
|
--list-types Show registered types (alias: --list-templates)
|
package/package.json
CHANGED
package/src/new.mjs
CHANGED
|
@@ -60,6 +60,27 @@ function fullBodyShortcut(title, bodyInput) {
|
|
|
60
60
|
return hasOwnTitle ? `\n${b}\n` : `\n# ${title}\n\n${b}\n`;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
function lexicallyInside(parent, child) {
|
|
64
|
+
return child === parent || child.startsWith(parent + path.sep);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The default root for a type that doesn't name one of its own. This used to be
|
|
68
|
+
// "whichever root is listed first", which drops a `doc` into `docs/plans` for
|
|
69
|
+
// any project that lists its plans root first — the plan type's own root, for a
|
|
70
|
+
// type that isn't a plan. When one configured root contains another it is
|
|
71
|
+
// structurally the catch-all (`docs` holding `docs/plans` and `docs/adr`), so
|
|
72
|
+
// prefer that; the deepest one, so a nested chain picks the most specific
|
|
73
|
+
// container rather than the outermost. Single-root projects and flat sibling
|
|
74
|
+
// roots have no catch-all to find and keep first-listed order.
|
|
75
|
+
export function catchAllRoot(config) {
|
|
76
|
+
const roots = config.docsRoots ?? [config.docsRoot];
|
|
77
|
+
if (roots.length < 2) return config.docsRoot;
|
|
78
|
+
const containers = roots.filter(root => roots.some(other => other !== root && lexicallyInside(root, other)));
|
|
79
|
+
if (!containers.length) return config.docsRoot;
|
|
80
|
+
const depth = root => root.split(path.sep).length;
|
|
81
|
+
return containers.reduce((deepest, root) => depth(root) > depth(deepest) ? root : deepest);
|
|
82
|
+
}
|
|
83
|
+
|
|
63
84
|
const BUILTIN_TEMPLATES = {
|
|
64
85
|
doc: {
|
|
65
86
|
description: 'Reference doc, design note, module overview — build-up shape lite',
|
|
@@ -800,6 +821,9 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
800
821
|
} else if (name.endsWith('.md')) {
|
|
801
822
|
namePart = name.slice(0, -3);
|
|
802
823
|
}
|
|
824
|
+
// A prefix the *user* typed and one a template declares resolve differently
|
|
825
|
+
// below, so the distinction has to survive the template's assignment to nameDir.
|
|
826
|
+
const userNameDir = nameDir;
|
|
803
827
|
|
|
804
828
|
// Slugify
|
|
805
829
|
const slug = namePart.toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
|
@@ -811,7 +835,7 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
811
835
|
// Resolve target root. Precedence: CLI --root > template.targetRoot > config.docsRoot.
|
|
812
836
|
// When the chosen root is a first-class type-container (matched by --root or targetRoot),
|
|
813
837
|
// we skip the `template.dir` join — the root already points at the right directory.
|
|
814
|
-
let targetRoot = config
|
|
838
|
+
let targetRoot = catchAllRoot(config);
|
|
815
839
|
let routedToTypeRoot = false;
|
|
816
840
|
if (rootName) {
|
|
817
841
|
const roots = config.docsRoots || [config.docsRoot];
|
|
@@ -837,10 +861,46 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
837
861
|
nameDir = path.join(path.relative(config.repoRoot, targetRoot), template.dir);
|
|
838
862
|
}
|
|
839
863
|
|
|
840
|
-
// Path —
|
|
841
|
-
|
|
864
|
+
// Path — a directory prefix is read relative to the repo, because `dotmd new
|
|
865
|
+
// plan docs/plans/feature` is a full repo path and has to stay one. That was
|
|
866
|
+
// the ONLY reading, which is how `--root` came to be silently ignored the
|
|
867
|
+
// moment a name contained a slash: the block above picked a root and this line
|
|
868
|
+
// threw it away, so the one flag the out-of-root error advertises could not
|
|
869
|
+
// fix the error. With an explicit --root the prefix is now read relative to
|
|
870
|
+
// that root — unless the repo-relative reading already lands inside it, so
|
|
871
|
+
// full paths keep working.
|
|
872
|
+
const allRoots = config.docsRoots ?? [config.docsRoot];
|
|
873
|
+
let baseDir;
|
|
874
|
+
if (!nameDir) baseDir = targetRoot;
|
|
875
|
+
else if (userNameDir && rootName) {
|
|
876
|
+
const repoRelative = path.resolve(config.repoRoot, nameDir);
|
|
877
|
+
baseDir = lexicallyInside(targetRoot, repoRelative) ? repoRelative : path.resolve(targetRoot, nameDir);
|
|
878
|
+
} else baseDir = path.resolve(config.repoRoot, nameDir);
|
|
842
879
|
const filePath = path.join(baseDir, slug + '.md');
|
|
843
880
|
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
881
|
+
|
|
882
|
+
// Without --root there is nothing to disambiguate with, so a prefix pointing
|
|
883
|
+
// outside every root stays an error — but it names the flag that resolves it,
|
|
884
|
+
// and the flag now works. The generic containment error underneath reports
|
|
885
|
+
// absolute paths and no remedy, which is what agents kept re-guessing at.
|
|
886
|
+
//
|
|
887
|
+
// Gated on the remedy actually working: the suggestion is only offered when
|
|
888
|
+
// `--root` would land the file inside that root. That is what keeps this off
|
|
889
|
+
// a traversal (`../escaped`, an absolute path), where `--root` fixes nothing
|
|
890
|
+
// and the containment check below is the error that should speak — printing
|
|
891
|
+
// an untested remedy is the very defect this finding is about.
|
|
892
|
+
if (userNameDir && !rootName && !allRoots.some(root => lexicallyInside(root, filePath))) {
|
|
893
|
+
const rooted = path.resolve(targetRoot, userNameDir, slug + '.md');
|
|
894
|
+
if (lexicallyInside(targetRoot, rooted)) {
|
|
895
|
+
die(`Destination is outside every configured root:\n`
|
|
896
|
+
+ ` ${repoPath}\n\n`
|
|
897
|
+
+ `A name with a \`/\` is read relative to the repo.\n`
|
|
898
|
+
+ `To place it under a root instead:\n`
|
|
899
|
+
+ ` dotmd new ${typeName} ${name} --root ${path.basename(targetRoot)}\n`
|
|
900
|
+
+ ` → ${toRepoPath(rooted, config.repoRoot)}\n\n`
|
|
901
|
+
+ `Roots: ${allRoots.map(root => path.basename(root)).join(', ')}`);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
844
904
|
const destinationAuthorization = authorizeManagedDestination(filePath, config, { kind: 'New document destination' });
|
|
845
905
|
|
|
846
906
|
if (existsSync(filePath)) {
|
|
@@ -862,12 +922,15 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
862
922
|
// When the project has >1 root and `--root` was omitted, surface the choice
|
|
863
923
|
// so agents can see that an alternative root was available. Cheap visibility
|
|
864
924
|
// for the "ended up in docs/plans/ for a doc" foot-gun.
|
|
865
|
-
|
|
925
|
+
// Report the root the file actually landed in, not the one resolution started
|
|
926
|
+
// from: a directory prefix can move the destination into a different root
|
|
927
|
+
// entirely, and the line used to say `Root: plans` while writing docs/prospects/.
|
|
866
928
|
let rootHint = '';
|
|
867
929
|
if (!rootName && allRoots.length > 1) {
|
|
868
|
-
const
|
|
930
|
+
const owningRoot = destinationAuthorization.root.lexicalPath;
|
|
931
|
+
const chosenLabel = path.basename(owningRoot);
|
|
869
932
|
const others = allRoots
|
|
870
|
-
.filter(r => r !==
|
|
933
|
+
.filter(r => r !== owningRoot)
|
|
871
934
|
.map(r => path.basename(r));
|
|
872
935
|
rootHint = `Root: ${chosenLabel} (others: ${others.join(', ')} — pass --root <name> to change)\n`;
|
|
873
936
|
}
|