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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.74.2",
3
+ "version": "0.74.3",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, lifecycle, and AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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.docsRoot;
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 — if user provided a directory prefix OR template declared one, resolve relative to repoRoot
841
- const baseDir = nameDir ? path.resolve(config.repoRoot, nameDir) : targetRoot;
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
- const allRoots = config.docsRoots ?? [config.docsRoot];
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 chosenLabel = path.basename(targetRoot);
930
+ const owningRoot = destinationAuthorization.root.lexicalPath;
931
+ const chosenLabel = path.basename(owningRoot);
869
932
  const others = allRoots
870
- .filter(r => r !== targetRoot)
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
  }