dotmd-cli 0.64.2 → 0.64.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.64.2",
3
+ "version": "0.64.3",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/doctor.mjs CHANGED
@@ -11,6 +11,7 @@ import { checkClaudeCommands, removeGeneratedSlashCommands } from './claude-comm
11
11
  import { runMigrateTemplate } from './migrate-template.mjs';
12
12
  import { runMigratePrompts } from './migrate-prompts.mjs';
13
13
  import { runFrontmatterFix } from './frontmatter-fix.mjs';
14
+ import { normalizeEol } from './frontmatter.mjs';
14
15
  import { toRepoPath } from './util.mjs';
15
16
 
16
17
  // Tunable thresholds for `dotmd doctor --statuses` conflation detection.
@@ -165,7 +166,7 @@ function findWorkflowDrift(config) {
165
166
  for (const filePath of collectDocFiles(config)) {
166
167
  let raw = '';
167
168
  try { raw = readFileSync(filePath, 'utf8'); } catch { continue; }
168
- if (!raw.startsWith('---\n')) docsWithoutFrontmatter.push(toRepoPath(filePath, config.repoRoot));
169
+ if (!normalizeEol(raw).startsWith('---\n')) docsWithoutFrontmatter.push(toRepoPath(filePath, config.repoRoot));
169
170
  }
170
171
 
171
172
  const planStatusGaps = [];
@@ -1,24 +1,40 @@
1
+ // Windows-authored (CRLF) docs otherwise slip past the LF-only fence detection
2
+ // below and read as having NO frontmatter — silently dropping them from the
3
+ // managed set (no type, no status). Normalizing CRLF→LF at every parse/rewrite
4
+ // boundary is the fix; the per-line value parser already strips a trailing \r,
5
+ // so only the fence scan was blind. A managed doc settles to LF the first time a
6
+ // dotmd verb rewrites it — content-preserving line-ending normalization, not
7
+ // corruption. For LF docs this is a no-op (the `\r` guard skips the replace), so
8
+ // existing behavior is byte-identical.
9
+ export function normalizeEol(text) {
10
+ return typeof text === 'string' && text.includes('\r') ? text.replace(/\r\n/g, '\n') : text;
11
+ }
12
+
1
13
  export function extractFrontmatter(raw) {
2
- if (!raw.startsWith('---\n')) {
3
- return { frontmatter: '', body: raw };
14
+ const text = normalizeEol(raw);
15
+ if (!text.startsWith('---\n')) {
16
+ return { frontmatter: '', body: text };
4
17
  }
5
18
 
6
- const endMarker = raw.indexOf('\n---\n', 4);
19
+ const endMarker = text.indexOf('\n---\n', 4);
7
20
  if (endMarker === -1) {
8
- return { frontmatter: '', body: raw };
21
+ return { frontmatter: '', body: text };
9
22
  }
10
23
 
11
24
  return {
12
- frontmatter: raw.slice(4, endMarker),
13
- body: raw.slice(endMarker + 5),
25
+ frontmatter: text.slice(4, endMarker),
26
+ body: text.slice(endMarker + 5),
14
27
  };
15
28
  }
16
29
 
17
30
  export function replaceFrontmatter(raw, newFrontmatter) {
18
- if (!raw.startsWith('---\n')) return raw;
19
- const endMarker = raw.indexOf('\n---\n', 4);
31
+ const text = normalizeEol(raw);
32
+ // No frontmatter to replace: return the ORIGINAL bytes untouched (a no-op
33
+ // rewrite must not normalize a file the caller didn't intend to change).
34
+ if (!text.startsWith('---\n')) return raw;
35
+ const endMarker = text.indexOf('\n---\n', 4);
20
36
  if (endMarker === -1) return raw;
21
- const body = raw.slice(endMarker + 5);
37
+ const body = text.slice(endMarker + 5);
22
38
  return `---\n${newFrontmatter}\n---\n${body}`;
23
39
  }
24
40
 
@@ -34,6 +50,16 @@ export function replaceFrontmatter(raw, newFrontmatter) {
34
50
  // folded block scalar `key: >\n one line\n continues` → "one line continues"
35
51
  // literal block scalar `key: |\n one\n two` → "one\ntwo"
36
52
  // chomping indicators `>-`, `|-` (strip), `>+`, `|+` (keep), default (clip to one trailing \n)
53
+ //
54
+ // Supported-subset boundary (deliberately NOT a full YAML parser):
55
+ // - Scalars stay strings except literal `true`/`false`. Numbers, null, and
56
+ // dates are kept verbatim as strings; callers coerce where they need a type
57
+ // (so `1.0`, `2025-01-01`, version strings, and numeric-looking ids survive
58
+ // intact rather than silently changing type).
59
+ // - Duplicate keys keep the FIRST occurrence; later ones are ignored (and
60
+ // reported via the optional `warnings` array).
61
+ // - Nested maps / multi-level indentation are not parsed — only top-level
62
+ // keys, with one level of `- ` items under an array key.
37
63
  export function parseSimpleFrontmatter(text, warnings) {
38
64
  const data = {};
39
65
  const seenDupKeys = new Set();
package/src/guard.mjs CHANGED
@@ -27,6 +27,14 @@ const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'),
27
27
 
28
28
  const SHELL_READERS = new Set(['cat', 'less', 'more', 'head', 'tail', 'bat', 'view', 'open']);
29
29
 
30
+ // Normalize path separators so the guard's `/`-based matching also fires on
31
+ // Windows backslash paths (`docs\prompts\foo.md`). Without this the PreToolUse
32
+ // status-edit/commit-prompt guards silently no-op on Windows — protection
33
+ // absent exactly where it's needed. On POSIX paths this is a no-op.
34
+ function toSlash(p) {
35
+ return typeof p === 'string' ? p.replace(/\\/g, '/') : p;
36
+ }
37
+
30
38
  // A path that ends in .md and sits under a `prompts/` directory is a
31
39
  // session-local saved prompt regardless of which doc root it belongs to —
32
40
  // robust across repos without needing the resolved config. Archived prompts
@@ -34,20 +42,23 @@ const SHELL_READERS = new Set(['cat', 'less', 'more', 'head', 'tail', 'bat', 'vi
34
42
  // committable history, NOT session-local, so they're explicitly excluded — the
35
43
  // guard must not block committing or reading them.
36
44
  function isPromptPath(p) {
37
- if (typeof p !== 'string' || !p.endsWith('.md')) return false;
38
- if (!/(^|\/)prompts\//.test(p)) return false;
39
- if (/(^|\/)archived\//.test(p)) return false;
45
+ const s = toSlash(p);
46
+ if (typeof s !== 'string' || !s.endsWith('.md')) return false;
47
+ if (!/(^|\/)prompts\//.test(s)) return false;
48
+ if (/(^|\/)archived\//.test(s)) return false;
40
49
  return true;
41
50
  }
42
51
 
43
52
  // Loose "is this a dotmd-managed doc" test: a .md file under one of the
44
53
  // configured doc roots (default `docs/`). Used for the status-edit guard.
45
54
  function isManagedDoc(p, config) {
46
- if (typeof p !== 'string' || !p.endsWith('.md')) return false;
55
+ const s = toSlash(p);
56
+ if (typeof s !== 'string' || !s.endsWith('.md')) return false;
47
57
  const roots = config?.docsRoots || (config?.docsRoot ? [config.docsRoot] : ['docs']);
48
58
  return roots.some(r => {
49
- const base = path.basename(r);
50
- return p.includes(`/${base}/`) || p.startsWith(`${base}/`) || p.includes(r);
59
+ const rNorm = toSlash(r).replace(/\/+$/, '');
60
+ const base = rNorm.split('/').pop();
61
+ return s.includes(`/${base}/`) || s.startsWith(`${base}/`) || s.includes(rNorm);
51
62
  });
52
63
  }
53
64
 
package/src/lifecycle.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
3
+ import { extractFrontmatter, parseSimpleFrontmatter, normalizeEol } from './frontmatter.mjs';
4
4
  import { asString, toRepoPath, die, warn, resolveDocPath, resolveRefPath, escapeRegex, nowIso, suggestCandidates, emitFilesFooter, isArchivedPath } from './util.mjs';
5
5
  import { gitMv, getGitLastModifiedBatch } from './git.mjs';
6
6
  import { buildIndex, collectDocFiles, resolveDocArg } from './index.mjs';
@@ -935,7 +935,7 @@ function countRefsToUpdate(oldPath, newPath, config) {
935
935
  // — never auto-creates the section (don't surprise users on old plans/docs).
936
936
  export function appendVersionHistory(filePath, entry, { createSection = false } = {}) {
937
937
  let raw;
938
- try { raw = readFileSync(filePath, 'utf8'); } catch { return false; }
938
+ try { raw = normalizeEol(readFileSync(filePath, 'utf8')); } catch { return false; }
939
939
  if (!raw.startsWith('---\n')) return false;
940
940
 
941
941
  const endMarker = raw.indexOf('\n---\n', 4);
@@ -979,7 +979,7 @@ export function appendVersionHistory(filePath, entry, { createSection = false }
979
979
  }
980
980
 
981
981
  export function updateFrontmatter(filePath, updates) {
982
- const raw = readFileSync(filePath, 'utf8');
982
+ const raw = normalizeEol(readFileSync(filePath, 'utf8'));
983
983
  // Name the remedy in the error: this is where every status verb lands when a
984
984
  // doc was created outside dotmd, and "no frontmatter block" alone left
985
985
  // sessions retrying other verbs instead of fixing the doc.
@@ -1009,7 +1009,7 @@ export function updateFrontmatter(filePath, updates) {
1009
1009
  // to updateFrontmatter when a block already exists so callers can hand it any
1010
1010
  // file without pre-checking — the result is the same shape either way.
1011
1011
  export function writeFrontmatter(filePath, fields) {
1012
- const raw = readFileSync(filePath, 'utf8');
1012
+ const raw = normalizeEol(readFileSync(filePath, 'utf8'));
1013
1013
  if (raw.startsWith('---\n')) {
1014
1014
  updateFrontmatter(filePath, fields);
1015
1015
  return;
package/src/new.mjs CHANGED
@@ -5,7 +5,7 @@ import { toRepoPath, die, warn, nowIso, emitFilesFooter } from './util.mjs';
5
5
  import { green, dim, bold } from './color.mjs';
6
6
  import { isInteractive, promptText } from './prompt.mjs';
7
7
  import { regenIndex } from './lifecycle.mjs';
8
- import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
8
+ import { extractFrontmatter, parseSimpleFrontmatter, normalizeEol } from './frontmatter.mjs';
9
9
 
10
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
11
  const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
@@ -203,7 +203,7 @@ Status markers (put in heading text):
203
203
  // trap 4. Returns `{ frontmatter: object|null, body: string }`.
204
204
  function splitBodyFrontmatter(rawBody) {
205
205
  if (!rawBody || typeof rawBody !== 'string') return { frontmatter: null, body: rawBody };
206
- if (!rawBody.startsWith('---\n')) return { frontmatter: null, body: rawBody };
206
+ if (!normalizeEol(rawBody).startsWith('---\n')) return { frontmatter: null, body: rawBody };
207
207
  const { frontmatter: fmText, body } = extractFrontmatter(rawBody);
208
208
  if (!fmText) return { frontmatter: null, body: rawBody };
209
209
  const parsed = parseSimpleFrontmatter(fmText);