dotmd-cli 0.29.2 → 0.30.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.29.2",
3
+ "version": "0.30.0",
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/init.mjs CHANGED
@@ -1,10 +1,15 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
- import { green, dim } from './color.mjs';
4
+ import { green, dim, yellow } from './color.mjs';
5
5
  import { warn } from './util.mjs';
6
6
  import { scaffoldClaudeCommands } from './claude-commands.mjs';
7
7
 
8
+ // Subdirectories scaffolded under docsRoot and tracked separately during scans.
9
+ // Each maps to a builtin type (plan, prompt). New types added here should also
10
+ // have a matching builtin template so `dotmd new <type>` lands files correctly.
11
+ const TYPE_SUBDIRS = ['plans', 'prompts'];
12
+
8
13
  const STARTER_CONFIG = `// dotmd.config.mjs — document management configuration
9
14
  // All exports are optional. See dotmd.config.example.mjs for full reference.
10
15
 
@@ -33,17 +38,33 @@ function scanExistingDocs(dir) {
33
38
  const modules = new Set();
34
39
  const refFieldNames = new Set();
35
40
  let docCount = 0;
41
+ // Track files per top-level subdir under `dir` (e.g. plans/, prompts/, "")
42
+ // so callers can report what's already there — including files without frontmatter,
43
+ // which are otherwise invisible to detection.
44
+ const subdirCounts = {};
45
+
46
+ function bump(subdir, hasFrontmatter) {
47
+ if (!subdirCounts[subdir]) subdirCounts[subdir] = { withFrontmatter: 0, withoutFrontmatter: 0 };
48
+ if (hasFrontmatter) subdirCounts[subdir].withFrontmatter++;
49
+ else subdirCounts[subdir].withoutFrontmatter++;
50
+ }
36
51
 
37
- function walk(d) {
52
+ function walk(d, topSubdir) {
38
53
  let entries;
39
54
  try { entries = readdirSync(d, { withFileTypes: true }); } catch (err) { warn(`Could not read ${d}: ${err.message}`); return; }
40
55
  for (const entry of entries) {
41
- if (entry.isDirectory()) { walk(path.join(d, entry.name)); continue; }
56
+ if (entry.isDirectory()) {
57
+ const nextTop = topSubdir === null ? entry.name : topSubdir;
58
+ walk(path.join(d, entry.name), nextTop);
59
+ continue;
60
+ }
42
61
  if (!entry.name.endsWith('.md')) continue;
43
62
  let raw;
44
63
  try { raw = readFileSync(path.join(d, entry.name), 'utf8'); } catch (err) { warn(`Could not read ${entry.name}: ${err.message}`); continue; }
45
64
  const { frontmatter } = extractFrontmatter(raw);
46
- if (!frontmatter) continue;
65
+ const subdir = topSubdir ?? '';
66
+ if (!frontmatter) { bump(subdir, false); continue; }
67
+ bump(subdir, true);
47
68
  const parsed = parseSimpleFrontmatter(frontmatter);
48
69
  docCount++;
49
70
  if (parsed.status) statuses.add(String(parsed.status).toLowerCase());
@@ -59,8 +80,25 @@ function scanExistingDocs(dir) {
59
80
  }
60
81
  }
61
82
 
62
- walk(dir);
63
- return { docCount, statuses, surfaces, modules, refFieldNames };
83
+ walk(dir, null);
84
+ return { docCount, statuses, surfaces, modules, refFieldNames, subdirCounts };
85
+ }
86
+
87
+ // Count .md files (regardless of frontmatter) directly inside a single directory.
88
+ // Used to detect root-level plans/ or prompts/ siblings that aren't under docsRoot.
89
+ function countMarkdownFiles(dir) {
90
+ let withFrontmatter = 0;
91
+ let withoutFrontmatter = 0;
92
+ let entries;
93
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return { withFrontmatter, withoutFrontmatter }; }
94
+ for (const entry of entries) {
95
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
96
+ let raw;
97
+ try { raw = readFileSync(path.join(dir, entry.name), 'utf8'); } catch { continue; }
98
+ const { frontmatter } = extractFrontmatter(raw);
99
+ if (frontmatter) withFrontmatter++; else withoutFrontmatter++;
100
+ }
101
+ return { withFrontmatter, withoutFrontmatter };
64
102
  }
65
103
 
66
104
  function generateDetectedConfig(scan, rootPath) {
@@ -112,10 +150,11 @@ export function runInit(cwd, config) {
112
150
 
113
151
  process.stdout.write('\n');
114
152
 
153
+ const scan = existsSync(docsDir) ? scanExistingDocs(docsDir) : null;
154
+
115
155
  if (existsSync(configPath)) {
116
156
  process.stdout.write(` ${dim('exists')} dotmd.config.mjs\n`);
117
157
  } else {
118
- const scan = existsSync(docsDir) ? scanExistingDocs(docsDir) : null;
119
158
  if (scan && scan.docCount > 0) {
120
159
  writeFileSync(configPath, generateDetectedConfig(scan, 'docs'), 'utf8');
121
160
  process.stdout.write(` ${green('create')} dotmd.config.mjs (detected ${scan.docCount} docs)\n`);
@@ -132,6 +171,41 @@ export function runInit(cwd, config) {
132
171
  process.stdout.write(` ${green('create')} docs/\n`);
133
172
  }
134
173
 
174
+ // Inspect root-level siblings (e.g. ./plans/, ./prompts/) before scaffolding.
175
+ // If a sibling already holds content, skip creating the matching docs/<sub>/
176
+ // so we don't quietly create a parallel dir the user has to reconcile.
177
+ const siblingsWithContent = [];
178
+ for (const sub of TYPE_SUBDIRS) {
179
+ const siblingPath = path.join(cwd, sub);
180
+ if (!existsSync(siblingPath)) continue;
181
+ const c = countMarkdownFiles(siblingPath);
182
+ const total = c.withFrontmatter + c.withoutFrontmatter;
183
+ if (total > 0) siblingsWithContent.push({ sub, total });
184
+ }
185
+ const siblingSet = new Set(siblingsWithContent.map(s => s.sub));
186
+
187
+ // Scaffold the canonical type subdirs (docs/plans/, docs/prompts/) so the
188
+ // builtin `dotmd new plan` / `dotmd new prompt` templates land somewhere
189
+ // sensible without extra config.
190
+ for (const sub of TYPE_SUBDIRS) {
191
+ const subPath = path.join(docsDir, sub);
192
+ const counts = scan?.subdirCounts?.[sub];
193
+ const total = counts ? counts.withFrontmatter + counts.withoutFrontmatter : 0;
194
+ if (siblingSet.has(sub) && !existsSync(subPath)) {
195
+ process.stdout.write(` ${yellow('skip')} docs/${sub}/ (root-level ./${sub}/ already holds content)\n`);
196
+ continue;
197
+ }
198
+ if (existsSync(subPath)) {
199
+ const detail = total > 0
200
+ ? ` (${counts.withFrontmatter} dotmd-tracked, ${counts.withoutFrontmatter} plain .md)`
201
+ : '';
202
+ process.stdout.write(` ${dim('exists')} docs/${sub}/${detail}\n`);
203
+ } else {
204
+ mkdirSync(subPath, { recursive: true });
205
+ process.stdout.write(` ${green('create')} docs/${sub}/\n`);
206
+ }
207
+ }
208
+
135
209
  if (existsSync(indexPath)) {
136
210
  process.stdout.write(` ${dim('exists')} docs/docs.md\n`);
137
211
  } else {
@@ -139,6 +213,20 @@ export function runInit(cwd, config) {
139
213
  process.stdout.write(` ${green('create')} docs/docs.md\n`);
140
214
  }
141
215
 
216
+ if (siblingsWithContent.length > 0) {
217
+ const list = siblingsWithContent
218
+ .map(({ sub, total }) => `${sub}/ (${total} .md file${total === 1 ? '' : 's'})`)
219
+ .join(', ');
220
+ const subs = siblingsWithContent.map(s => s.sub);
221
+ process.stdout.write(`\n ${yellow('notice')} found at repo root: ${list}\n`);
222
+ process.stdout.write(` these are NOT under docs/ and won't be tracked by the default config. Either:\n`);
223
+ for (const sub of subs) {
224
+ process.stdout.write(` • move into docs/: mv ./${sub}/* docs/${sub}/ && rmdir ./${sub}\n`);
225
+ }
226
+ process.stdout.write(` • or use a flat layout — set in dotmd.config.mjs:\n`);
227
+ process.stdout.write(` export const root = [${subs.map(s => `'${s}'`).join(', ')}];\n`);
228
+ }
229
+
142
230
  // .gitignore: ensure .dotmd/ is ignored (session leases live there)
143
231
  const gitignorePath = path.join(cwd, '.gitignore');
144
232
  const ignoreLine = '.dotmd/';
package/src/lifecycle.mjs CHANGED
@@ -25,6 +25,29 @@ function findFileRoot(filePath, config) {
25
25
  return roots.find(r => filePath.startsWith(r + '/')) ?? config.docsRoot;
26
26
  }
27
27
 
28
+ // Pick an archive destination that won't clobber an existing record. If
29
+ // `<dir>/<basename>` is free, returns it unchanged; otherwise appends a UTC
30
+ // timestamp (and a counter on the vanishingly rare same-second collision) so
31
+ // both the prior archive and the current one survive.
32
+ function uniqueArchiveTarget(targetDir, basename) {
33
+ const base = path.join(targetDir, basename);
34
+ if (!existsSync(base)) return base;
35
+
36
+ const ext = path.extname(basename);
37
+ const stem = basename.slice(0, -ext.length);
38
+ const d = new Date();
39
+ const pad = (n) => String(n).padStart(2, '0');
40
+ const stamp = `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
41
+
42
+ let target = path.join(targetDir, `${stem}-${stamp}${ext}`);
43
+ let n = 2;
44
+ while (existsSync(target)) {
45
+ target = path.join(targetDir, `${stem}-${stamp}-${n}${ext}`);
46
+ n++;
47
+ }
48
+ return target;
49
+ }
50
+
28
51
  export async function runStatus(argv, config, opts = {}) {
29
52
  const { dryRun } = opts;
30
53
  const input = argv[0];
@@ -85,7 +108,7 @@ export async function runStatus(argv, config, opts = {}) {
85
108
  const prefix = dim('[dry-run]');
86
109
  process.stdout.write(`${prefix} Would update frontmatter: status: ${oldStatus ?? 'unknown'} → ${newStatus}, updated: ${today}\n`);
87
110
  if (isArchiving) {
88
- const targetPath = path.join(archiveDir, path.basename(filePath));
111
+ const targetPath = uniqueArchiveTarget(archiveDir, path.basename(filePath));
89
112
  process.stdout.write(`${prefix} Would move: ${toRepoPath(filePath, config.repoRoot)} → ${toRepoPath(targetPath, config.repoRoot)}\n`);
90
113
  finalPath = targetPath;
91
114
  }
@@ -106,8 +129,7 @@ export async function runStatus(argv, config, opts = {}) {
106
129
 
107
130
  if (isArchiving) {
108
131
  mkdirSync(archiveDir, { recursive: true });
109
- const targetPath = path.join(archiveDir, path.basename(filePath));
110
- if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
132
+ const targetPath = uniqueArchiveTarget(archiveDir, path.basename(filePath));
111
133
  const result = gitMv(filePath, targetPath, config.repoRoot);
112
134
  if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
113
135
  finalPath = targetPath;
@@ -479,14 +501,13 @@ export function runArchive(argv, config, opts = {}) {
479
501
 
480
502
  const today = nowIso();
481
503
  const targetDir = path.join(archiveFileRoot, config.archiveDir);
482
- const targetPath = path.join(targetDir, path.basename(filePath));
504
+ const targetPath = uniqueArchiveTarget(targetDir, path.basename(filePath));
483
505
  const oldRepoPath = toRepoPath(filePath, config.repoRoot);
484
506
  const newRepoPath = toRepoPath(targetPath, config.repoRoot);
485
507
 
486
508
  if (dryRun) {
487
509
  const prefix = dim('[dry-run]');
488
510
  out.write(`${prefix} Would update frontmatter: status: ${oldStatus} → archived, updated: ${today}\n`);
489
- if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
490
511
  out.write(`${prefix} Would move: ${oldRepoPath} → ${newRepoPath}\n`);
491
512
  if (config.indexPath) out.write(`${prefix} Would regenerate index\n`);
492
513
 
@@ -502,7 +523,6 @@ export function runArchive(argv, config, opts = {}) {
502
523
  appendVersionHistory(filePath, 'Archived.');
503
524
 
504
525
  mkdirSync(targetDir, { recursive: true });
505
- if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
506
526
 
507
527
  const result = gitMv(filePath, targetPath, config.repoRoot);
508
528
  if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }