dotmd-cli 0.29.1 → 0.29.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.29.1",
3
+ "version": "0.29.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/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.'); }
package/src/new.mjs CHANGED
@@ -128,6 +128,7 @@ Status markers (put in heading text):
128
128
  targetRoot: 'prompts',
129
129
  defaultStatus: 'pending',
130
130
  requiresBody: true,
131
+ acceptsBody: true,
131
132
  frontmatter: (s, d, ctx) => [
132
133
  'type: prompt',
133
134
  `status: ${s}`,
@@ -222,13 +223,31 @@ export async function runNew(argv, config, opts = {}) {
222
223
 
223
224
  // Body input resolution: messageFlag > bodyArg > nothing
224
225
  let bodyInput = null;
225
- if (messageFlag !== null) bodyInput = readBodyInput(messageFlag);
226
- else if (bodyArg !== null) bodyInput = readBodyInput(bodyArg);
226
+ let bodyInputSource = null;
227
+ if (messageFlag !== null) { bodyInput = readBodyInput(messageFlag); bodyInputSource = '--message'; }
228
+ else if (bodyArg !== null) {
229
+ bodyInput = readBodyInput(bodyArg);
230
+ bodyInputSource = bodyArg === '-' ? 'stdin (`-`)' : (bodyArg.startsWith('@') ? `file (\`${bodyArg}\`)` : 'inline body argument');
231
+ }
227
232
 
228
233
  if (template.requiresBody && (!bodyInput || !bodyInput.trim())) {
229
234
  die(`\`${typeName}\` template requires a body. Pass inline, --message "...", - for stdin, or @path for a file.`);
230
235
  }
231
236
 
237
+ // Fail-fast when the user passes body input to a template that doesn't
238
+ // consume it — silently discarding heredoc content is the worst UX.
239
+ // Templates opt in via `acceptsBody: true` or `requiresBody: true`. Built-in
240
+ // `prompt` is the only template that consumes body by default.
241
+ if (bodyInput !== null && !template.acceptsBody && !template.requiresBody) {
242
+ const accepting = Object.entries(BUILTIN_TEMPLATES)
243
+ .filter(([, t]) => t.acceptsBody || t.requiresBody)
244
+ .map(([n]) => n);
245
+ const hint = accepting.length > 0
246
+ ? ` Templates that accept body input: ${accepting.join(', ')}.`
247
+ : '';
248
+ die(`\`${typeName}\` template does not accept body input, but body was passed via ${bodyInputSource}.${hint}\nEither drop the body, switch to a template that accepts it, or set \`acceptsBody: true\` on your custom \`${typeName}\` template in dotmd.config.mjs.`);
249
+ }
250
+
232
251
  // If name contains path separators, split into directory prefix and basename
233
252
  let nameDir = null;
234
253
  let namePart = name;