dotmd-cli 0.70.2 → 0.70.4

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.70.2",
3
+ "version": "0.70.4",
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",
@@ -23,10 +23,15 @@ import os from 'node:os';
23
23
  import path from 'node:path';
24
24
  import { captureGitIndexGeneration, reclaimPreparedGitIndex, restoreGitIndexCas, sameGitIndexGeneration, stageMovePathsCas } from './git.mjs';
25
25
  import { authorizeManagedDestination, authorizeManagedSource, authorizeRepoGeneratedPath } from './managed-path.mjs';
26
+ import { commitRename } from './durable-rename.mjs';
26
27
 
27
28
  const sleepBuffer = new Int32Array(new SharedArrayBuffer(4));
28
29
  let tempSequence = 0;
29
30
 
31
+ // How long a peer waits to acquire a path lock before MutationLockError. Any
32
+ // retry budget spent while the lock is held has to fit well inside this.
33
+ export const MUTATION_LOCK_TIMEOUT_MS = 2000;
34
+
30
35
  export function processStartIdentity(pid) {
31
36
  try {
32
37
  const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
@@ -111,7 +116,7 @@ function transactionRoot(repoRoot, options = {}) {
111
116
  function durableJson(filePath, value, options = {}) {
112
117
  const content = JSON.stringify(value, null, 2) + '\n';
113
118
  const temp = writeCompleteTemp(filePath, content, 0o600);
114
- renameSync(temp.path, filePath);
119
+ commitRename(temp.path, filePath, options.testHooks);
115
120
  fsyncDirectory(path.dirname(filePath), options, 'transaction-manifest');
116
121
  }
117
122
 
@@ -841,7 +846,7 @@ function reclaimLock(lockPath, lockRoot) {
841
846
  }
842
847
 
843
848
  export function withPathLocks(filePaths, options, callback) {
844
- const { repoRoot, timeoutMs = 2000, retryMs = 20 } = options;
849
+ const { repoRoot, timeoutMs = MUTATION_LOCK_TIMEOUT_MS, retryMs = 20 } = options;
845
850
  if (!repoRoot) throw new Error('withPathLocks requires repoRoot.');
846
851
  const canonicals = [...new Set(filePaths.map(canonicalPath))].sort();
847
852
  const lockRoot = safeGeneratedPath(path.join(path.resolve(repoRoot), '.dotmd', 'locks'), repoRoot, options, 'Lock root');
@@ -1036,7 +1041,7 @@ export function replaceSnapshot(snapshot, newContent, options = {}) {
1036
1041
  try {
1037
1042
  options.testHooks?.beforeReplacePublish?.({ snapshot, tempPath: prepared.path });
1038
1043
  assertSnapshotCurrent(snapshot);
1039
- renameSync(prepared.path, snapshot.path);
1044
+ commitRename(prepared.path, snapshot.path, options.testHooks);
1040
1045
  committed = committedGeneration(prepared, snapshot.path);
1041
1046
  options.testHooks?.afterPublicationBeforePathOpen?.('replace', snapshot.path);
1042
1047
  try { fsyncDirectory(path.dirname(snapshot.path), options, 'replace-publish'); }
@@ -1097,7 +1102,7 @@ export function createFileExclusive(filePath, content, options) {
1097
1102
  options.testHooks?.afterReservationAcquired?.(filePath);
1098
1103
  options.testHooks?.afterCreateReservation?.(filePath);
1099
1104
  assertSnapshotCurrent(reservation);
1100
- renameSync(prepared.path, filePath);
1105
+ commitRename(prepared.path, filePath, options.testHooks);
1101
1106
  tempPublished = true;
1102
1107
  committed = committedGeneration(prepared, filePath);
1103
1108
  options.testHooks?.afterPublicationBeforePathOpen?.('create-rename', filePath);
@@ -1210,7 +1215,7 @@ export function moveFileAtomic(sourcePath, targetPath, render, options) {
1210
1215
  // step that can permit an injected or external source edit.
1211
1216
  assertSnapshotCurrent(source);
1212
1217
  for (const item of preparedUpdates) assertSnapshotCurrent(item.snapshot);
1213
- renameSync(sourcePath, backup);
1218
+ commitRename(sourcePath, backup, testHooks);
1214
1219
  moved = true;
1215
1220
  backupSnapshot = { ...source, path: backup, identityKind: 'inode-content' };
1216
1221
  fsyncDirectory(path.dirname(sourcePath));
@@ -1218,7 +1223,7 @@ export function moveFileAtomic(sourcePath, targetPath, render, options) {
1218
1223
 
1219
1224
  testHooks?.afterSourceMove?.({ backup, sourcePath, targetPath });
1220
1225
  assertSnapshotCurrent(reservation);
1221
- renameSync(stagedPath, targetPath);
1226
+ commitRename(stagedPath, targetPath, testHooks);
1222
1227
  published = true;
1223
1228
  reserved = false;
1224
1229
  publishedSnapshot = committedGeneration(prepared, targetPath);
@@ -7,7 +7,7 @@
7
7
  // 2. fs.writeFileSync(<path>.tmp, new)
8
8
  // 3. import the tmp via file:// + cache-bust query — must parse
9
9
  // 4. resolveConfig(tmp) — must not surface new warnings
10
- // 5. fs.renameSync(tmp, real) only on full success
10
+ // 5. commitRename(tmp, real) only on full success
11
11
  // 6. on any failure: unlink tmp, surface error, real file untouched
12
12
  //
13
13
  // Pulling in @babel/parser would force a heavy dep on a project with two
@@ -15,9 +15,10 @@
15
15
  // `templates` section is 264 lines of arrow functions with multi-line
16
16
  // template literals). Line surgery is the conservative choice.
17
17
 
18
- import { existsSync, readFileSync, writeFileSync, unlinkSync, renameSync } from 'node:fs';
18
+ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
19
19
  import { pathToFileURL } from 'node:url';
20
20
  import { resolveConfig } from './config.mjs';
21
+ import { commitRename } from './durable-rename.mjs';
21
22
 
22
23
  const STATUS_NAME_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
23
24
  const RESERVED_NAMES = new Set([
@@ -595,7 +596,9 @@ export async function writeConfigAtomic(configPath, newContent, cwd) {
595
596
  throw new ConfigEditError(`Generated config surfaces new warnings:\n - ${novel.join('\n - ')}`);
596
597
  }
597
598
 
598
- renameSync(tmpPath, configPath);
599
+ // The live config is very likely open in the user's editor; on Windows that
600
+ // makes the rename fail transiently, so publish through the retrying helper.
601
+ commitRename(tmpPath, configPath);
599
602
  } catch (err) {
600
603
  try { unlinkSync(tmpPath); } catch { /* ignore */ }
601
604
  throw err;
@@ -0,0 +1,55 @@
1
+ // Retrying rename for publishing a prepared temp over a live path.
2
+ //
3
+ // Lives in its own leaf module rather than in atomic-mutation.mjs because
4
+ // atomic-mutation.mjs already imports git.mjs, and git.mjs needs this helper for
5
+ // its .git/index publish — exporting it from atomic-mutation.mjs would close an
6
+ // import cycle.
7
+ import { renameSync } from 'node:fs';
8
+
9
+ const sleepBuffer = new Int32Array(new SharedArrayBuffer(4));
10
+
11
+ const RENAME_RETRY_CODES = ['EPERM', 'EBUSY', 'EACCES'];
12
+ const RENAME_RETRY_ATTEMPTS = 10;
13
+ const RENAME_RETRY_BACKOFF_MS = 10;
14
+
15
+ // Total the backoff can sleep across a fully exhausted retry — the sum of
16
+ // RENAME_RETRY_BACKOFF_MS × (1 … attempts-1). Exported so the budget can be
17
+ // checked against MUTATION_LOCK_TIMEOUT_MS deterministically, rather than by
18
+ // timing a run on a shared CI runner.
19
+ export const RENAME_RETRY_SLEEP_BUDGET_MS =
20
+ (RENAME_RETRY_BACKOFF_MS * (RENAME_RETRY_ATTEMPTS - 1) * RENAME_RETRY_ATTEMPTS) / 2;
21
+
22
+ // Windows refuses to rename onto — or away from — a path another process holds
23
+ // open, surfacing EPERM/EBUSY/EACCES. That holder is by construction a
24
+ // non-cooperating one (editor, AV, Search Indexer, `git`): dotmd's path lock is
25
+ // advisory and only excludes other dotmd processes, so a retry here is never
26
+ // waiting on a peer we would have serialized against anyway. POSIX never fails a
27
+ // rename this way, so retrying there would mask a genuinely different fault.
28
+ //
29
+ // The budget is bounded by withPathLocks' timeoutMs (2000ms default): these
30
+ // renames run while the lock is held, so a longer budget would trade a rare
31
+ // transient EPERM for common peer MutationLockErrors. 10 attempts with a 10ms
32
+ // linear backoff is ~450ms of sleep — comfortable headroom under 2s. Do not
33
+ // raise it past ~1s without also raising timeoutMs.
34
+ export function commitRename(from, to, testHooks) {
35
+ // Single seam for the platform check so the retry is reachable in tests on the
36
+ // POSIX machines that actually run them.
37
+ const windowsRenameSemantics = testHooks?.forceWindowsRenameSemantics ?? process.platform === 'win32';
38
+ for (let attempt = 0; ; attempt++) {
39
+ try {
40
+ const injected = testHooks?.forceRenameError?.(attempt);
41
+ if (injected) {
42
+ const error = new Error(`injected rename ${injected}`);
43
+ error.code = injected;
44
+ throw error;
45
+ }
46
+ renameSync(from, to);
47
+ return;
48
+ } catch (err) {
49
+ if (!windowsRenameSemantics) throw err;
50
+ if (!RENAME_RETRY_CODES.includes(err?.code)) throw err;
51
+ if (attempt >= RENAME_RETRY_ATTEMPTS - 1) throw err;
52
+ Atomics.wait(sleepBuffer, 0, 0, RENAME_RETRY_BACKOFF_MS * (attempt + 1));
53
+ }
54
+ }
55
+ }
package/src/git.mjs CHANGED
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process';
2
2
  import { chmodSync, closeSync, existsSync, fsyncSync, linkSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { createHash, randomUUID } from 'node:crypto';
5
+ import { commitRename } from './durable-rename.mjs';
5
6
 
6
7
  // Best-effort `git check-ignore` for a path. Returns true only when git
7
8
  // definitively reports the path is ignored; any failure (not a repo, git
@@ -675,7 +676,9 @@ function publishIndexGeneration(repoRoot, expected, desired, prepared, testHooks
675
676
  if (!sameGitIndexGeneration(current, expected)) throw new Error('Git index changed before transaction publication; current staging was preserved.');
676
677
  testHooks.afterGitIndexCompare?.({ lockPath, current, desired });
677
678
  if (desired.exists) {
678
- renameSync(lockPath, indexPath);
679
+ // .git/index is routinely held open by concurrent git processes and IDE git
680
+ // extensions, so on Windows this publish needs the retry.
681
+ commitRename(lockPath, indexPath, testHooks);
679
682
  lockOwned = false;
680
683
  } else {
681
684
  if (existsSync(indexPath)) unlinkSync(indexPath);
package/src/lifecycle.mjs CHANGED
@@ -80,10 +80,20 @@ function commitLifecycleMutation(filePath, targetPath, config, updates, historyF
80
80
  const tracked = isTracked(filePath, config.repoRoot);
81
81
  const gitIndex = tracked ? captureGitIndexGeneration(config.repoRoot) : null;
82
82
  const companionPaths = new Set((options.additionalUpdates ?? []).map(item => path.resolve(item.path)));
83
- const allFiles = collectDocFiles(config).filter(candidate => candidate !== filePath && candidate !== targetPath && !companionPaths.has(path.resolve(candidate)));
83
+ const allFiles = options.skipInboundRefs
84
+ ? []
85
+ : collectDocFiles(config).filter(candidate => candidate !== filePath && candidate !== targetPath && !companionPaths.has(path.resolve(candidate)));
84
86
  authorizeManagedSweep(allFiles, config, { kind: 'Reference rewrite source' });
85
87
  const identities = createReferenceIdentitySet([filePath, ...allFiles]);
86
88
  const referenceFields = configuredReferenceFields(config);
89
+ const additionalUpdates = options.skipInboundRefs
90
+ ? (options.additionalUpdates ?? [])
91
+ : (options.additionalUpdates ?? []).map(item => ({
92
+ ...item,
93
+ content: item.content === undefined ? undefined : rewriteDocumentReferences(item.content, {
94
+ sourcePath: item.path, repoRoot: config.repoRoot, identities, oldPath: filePath, newPath: targetPath, referenceFields,
95
+ }),
96
+ }));
87
97
  const moveResult = moveFileAtomic(filePath, targetPath, sourceContent => {
88
98
  result = render(sourceContent);
89
99
  return result.content;
@@ -95,12 +105,7 @@ function commitLifecycleMutation(filePath, targetPath, config, updates, historyF
95
105
  render: raw => rewriteDocumentReferences(raw, {
96
106
  sourcePath: docFile, repoRoot: config.repoRoot, identities, oldPath: filePath, newPath: targetPath, referenceFields,
97
107
  }),
98
- })), ...(options.additionalUpdates ?? []).map(item => ({
99
- ...item,
100
- content: item.content === undefined ? undefined : rewriteDocumentReferences(item.content, {
101
- sourcePath: item.path, repoRoot: config.repoRoot, identities, oldPath: filePath, newPath: targetPath, referenceFields,
102
- }),
103
- }))],
108
+ })), ...additionalUpdates],
104
109
  creations: options.creations ?? [],
105
110
  gitMove: tracked,
106
111
  gitIndex,
@@ -806,10 +811,13 @@ export function runArchive(argv, config, opts = {}) {
806
811
  if (config.indexPath && !noIndex && !opts.deferIndex) out.write(`${prefix} Would regenerate index\n`);
807
812
  if (config.indexPath && noIndex) out.write(`${prefix} Would skip index regen (--no-index)\n`);
808
813
 
809
- // Preview reference updates
810
- const refCount = countRefsToUpdate(filePath, targetPath, config);
811
- if (refCount > 0) {
812
- out.write(`${prefix} Would update references in ${refCount} file(s)\n`);
814
+ // Preview reference updates unless the caller owns a non-referenceable,
815
+ // session-local document such as a saved prompt.
816
+ if (!opts.skipInboundRefs) {
817
+ const refCount = countRefsToUpdate(filePath, targetPath, config);
818
+ if (refCount > 0) {
819
+ out.write(`${prefix} Would update references in ${refCount} file(s)\n`);
820
+ }
813
821
  }
814
822
 
815
823
  // Preview onArchive hook fire
@@ -825,6 +833,7 @@ export function runArchive(argv, config, opts = {}) {
825
833
  testHooks: opts.testHooks,
826
834
  additionalUpdates: [...(opts.additionalUpdates ?? []), ...(releaseUpdate ? [releaseUpdate] : [])],
827
835
  creations: opts.creations,
836
+ skipInboundRefs: opts.skipInboundRefs,
828
837
  bodyTransform: closeoutTemplate ? currentBody => {
829
838
  committedCloseoutAction = planCloseoutInjection(currentBody);
830
839
  return committedCloseoutAction.action === 'inject' ? committedCloseoutAction.newBody : currentBody;
package/src/prompts.mjs CHANGED
@@ -270,7 +270,7 @@ export async function consumePrompt(filePath, config, opts) {
270
270
  process.stderr.write(body);
271
271
  if (!body.endsWith('\n')) process.stderr.write('\n');
272
272
  process.stderr.write(`${dim('--->8---')}\n`);
273
- runArchive([filePath], config, { dryRun: true, noIndex, out: process.stderr });
273
+ runArchive([filePath], config, { dryRun: true, noIndex, out: process.stderr, skipInboundRefs: true });
274
274
  return;
275
275
  }
276
276
 
@@ -284,6 +284,9 @@ export async function consumePrompt(filePath, config, opts) {
284
284
  out: process.stderr,
285
285
  testHooks: opts.testHooks,
286
286
  deferIndex: Boolean(linkedClaim),
287
+ // Saved prompts are gitignored session state, never durable reference
288
+ // targets. Avoid locking and replanning the entire corpus on consumption.
289
+ skipInboundRefs: true,
287
290
  additionalUpdates: linkedClaim?.prepared?.updates,
288
291
  creations: linkedClaim?.prepared?.creations,
289
292
  });