anhedral 0.6.0 → 0.7.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.
@@ -140,7 +140,7 @@ function fingerprintEntry(target) {
140
140
  }
141
141
  function isUnsupportedDirectorySync(error, operation) {
142
142
  const code = error.code;
143
- if (code === 'ENOTSUP' || code === 'EOPNOTSUPP')
143
+ if (['ENOTSUP', 'EOPNOTSUPP'].includes(code ?? ''))
144
144
  return true;
145
145
  // Some filesystems expose directories as readable handles but reject fsync.
146
146
  // Windows may reject opening or syncing directory handles altogether. Keep
@@ -149,9 +149,8 @@ function isUnsupportedDirectorySync(error, operation) {
149
149
  return true;
150
150
  if (process.platform !== 'win32')
151
151
  return false;
152
- if (operation === 'open')
153
- return code === 'EISDIR' || code === 'EPERM';
154
- return code === 'EBADF' || code === 'EPERM';
152
+ const windowsCodes = operation === 'open' ? ['EISDIR', 'EPERM'] : ['EBADF', 'EPERM'];
153
+ return windowsCodes.includes(code ?? '');
155
154
  }
156
155
  function syncDirectory(directory) {
157
156
  let descriptor;
@@ -280,7 +279,79 @@ function isProcessAlive(pid) {
280
279
  throw error;
281
280
  }
282
281
  }
283
- function acquireTransactionLock(root) {
282
+ function cleanupTemporaryLocks(root, owner, temporaryPath) {
283
+ for (const entry of readdirSync(root)) {
284
+ if (!entry.startsWith(`${LOCK_FILE}.`) || !entry.endsWith('.tmp'))
285
+ continue;
286
+ const staleTemporaryPath = path.join(root, entry);
287
+ if (staleTemporaryPath === temporaryPath)
288
+ continue;
289
+ let temporaryOwner;
290
+ try {
291
+ temporaryOwner = parseLockOwner(staleTemporaryPath);
292
+ }
293
+ catch (error) {
294
+ throw new Error(`Cannot safely classify transaction lock artifact at ${staleTemporaryPath}; inspect it manually before retrying.`, { cause: error });
295
+ }
296
+ if (temporaryOwner.hostname !== owner.hostname) {
297
+ throw new Error(`Transaction lock artifact at ${staleTemporaryPath} belongs to another host; inspect it manually before retrying.`);
298
+ }
299
+ if (!isProcessAlive(temporaryOwner.pid)) {
300
+ unlinkSync(staleTemporaryPath);
301
+ }
302
+ }
303
+ }
304
+ function claimReclamation(lockPath, reclaimPath, temporaryPath, owner) {
305
+ try {
306
+ linkSync(temporaryPath, reclaimPath);
307
+ }
308
+ catch (error) {
309
+ if (error.code === 'EEXIST') {
310
+ const reclaimer = readLockOwnerIfPresent(reclaimPath);
311
+ if (!reclaimer) {
312
+ return false;
313
+ }
314
+ if (reclaimer.hostname === owner.hostname && !isProcessAlive(reclaimer.pid)) {
315
+ unlinkSync(reclaimPath);
316
+ return false;
317
+ }
318
+ throw new Error(`A stale Anhedral lock is being recovered (${lockPath}, pid ${reclaimer.pid} on ${reclaimer.hostname}); retry the command.`);
319
+ }
320
+ throw error;
321
+ }
322
+ return true;
323
+ }
324
+ function reclaimTransactionLock(lockPath, reclaimPath, temporaryPath, owner) {
325
+ const existing = readLockOwnerIfPresent(lockPath);
326
+ if (!existing) {
327
+ return 'retry';
328
+ }
329
+ if (existing.hostname !== owner.hostname || isProcessAlive(existing.pid)) {
330
+ throw new Error(`Another Anhedral operation is already running (${lockPath}, pid ${existing.pid} on ${existing.hostname}).`);
331
+ }
332
+ if (!claimReclamation(lockPath, reclaimPath, temporaryPath, owner))
333
+ return 'retry';
334
+ try {
335
+ if (!pathEntryExists(lockPath))
336
+ return 'next';
337
+ const current = parseLockOwner(lockPath);
338
+ if (current.token !== existing.token)
339
+ return 'next';
340
+ if (current.hostname !== owner.hostname || isProcessAlive(current.pid)) {
341
+ throw new Error(`Another Anhedral operation is already running (${lockPath}, pid ${current.pid} on ${current.hostname}).`);
342
+ }
343
+ unlinkSync(lockPath);
344
+ }
345
+ finally {
346
+ if (pathEntryExists(reclaimPath)) {
347
+ const reclaimer = parseLockOwner(reclaimPath);
348
+ if (reclaimer.token === owner.token)
349
+ unlinkSync(reclaimPath);
350
+ }
351
+ }
352
+ return 'next';
353
+ }
354
+ function assertLockRoot(root) {
284
355
  const rootStat = lstatIfPresent(root);
285
356
  if (!rootStat)
286
357
  throw new Error(`Transaction root is not a directory: ${root}`);
@@ -289,6 +360,37 @@ function acquireTransactionLock(root) {
289
360
  }
290
361
  if (!rootStat.isDirectory())
291
362
  throw new Error(`Transaction root is not a directory: ${root}`);
363
+ }
364
+ function clearDeadReclaimer(lockPath, reclaimPath, owner) {
365
+ const activeReclaimer = readLockOwnerIfPresent(reclaimPath);
366
+ if (activeReclaimer) {
367
+ if (activeReclaimer.hostname === owner.hostname && !isProcessAlive(activeReclaimer.pid)) {
368
+ try {
369
+ unlinkSync(reclaimPath);
370
+ }
371
+ catch (error) {
372
+ if (error.code !== 'ENOENT')
373
+ throw error;
374
+ }
375
+ return true;
376
+ }
377
+ throw new Error(`An Anhedral lock is being recovered (${lockPath}, pid ${activeReclaimer.pid} on ${activeReclaimer.hostname}); retry the command.`);
378
+ }
379
+ return false;
380
+ }
381
+ function cleanupAcquiredLock(root, lockPath, owner, temporaryPath) {
382
+ try {
383
+ cleanupTemporaryLocks(root, owner, temporaryPath);
384
+ }
385
+ catch (error) {
386
+ const current = readLockOwnerIfPresent(lockPath);
387
+ if (current?.token === owner.token)
388
+ unlinkSync(lockPath);
389
+ throw error;
390
+ }
391
+ }
392
+ function acquireTransactionLock(root) {
393
+ assertLockRoot(root);
292
394
  const lockPath = path.join(root, LOCK_FILE);
293
395
  const reclaimPath = `${lockPath}.reclaim`;
294
396
  const owner = {
@@ -302,102 +404,21 @@ function acquireTransactionLock(root) {
302
404
  writeFileSync(temporaryPath, JSON.stringify(owner) + '\n', { encoding: 'utf8', flag: 'wx', mode: 0o600 });
303
405
  try {
304
406
  for (let attempt = 0; attempt < 3; attempt += 1) {
305
- const activeReclaimer = readLockOwnerIfPresent(reclaimPath);
306
- if (activeReclaimer) {
307
- if (activeReclaimer.hostname === owner.hostname && !isProcessAlive(activeReclaimer.pid)) {
308
- try {
309
- unlinkSync(reclaimPath);
310
- }
311
- catch (error) {
312
- if (error.code !== 'ENOENT')
313
- throw error;
314
- }
315
- attempt -= 1;
316
- continue;
317
- }
318
- throw new Error(`An Anhedral lock is being recovered (${lockPath}, pid ${activeReclaimer.pid} on ${activeReclaimer.hostname}); retry the command.`);
407
+ if (clearDeadReclaimer(lockPath, reclaimPath, owner)) {
408
+ attempt -= 1;
409
+ continue;
319
410
  }
320
411
  try {
321
412
  linkSync(temporaryPath, lockPath);
322
- try {
323
- for (const entry of readdirSync(root)) {
324
- if (!entry.startsWith(`${LOCK_FILE}.`) || !entry.endsWith('.tmp'))
325
- continue;
326
- const staleTemporaryPath = path.join(root, entry);
327
- if (staleTemporaryPath === temporaryPath)
328
- continue;
329
- let temporaryOwner;
330
- try {
331
- temporaryOwner = parseLockOwner(staleTemporaryPath);
332
- }
333
- catch (error) {
334
- throw new Error(`Cannot safely classify transaction lock artifact at ${staleTemporaryPath}; inspect it manually before retrying.`, { cause: error });
335
- }
336
- if (temporaryOwner.hostname !== owner.hostname) {
337
- throw new Error(`Transaction lock artifact at ${staleTemporaryPath} belongs to another host; inspect it manually before retrying.`);
338
- }
339
- if (!isProcessAlive(temporaryOwner.pid)) {
340
- unlinkSync(staleTemporaryPath);
341
- }
342
- }
343
- }
344
- catch (error) {
345
- const current = readLockOwnerIfPresent(lockPath);
346
- if (current?.token === owner.token)
347
- unlinkSync(lockPath);
348
- throw error;
349
- }
413
+ cleanupAcquiredLock(root, lockPath, owner, temporaryPath);
350
414
  return { lockPath, owner };
351
415
  }
352
416
  catch (error) {
353
417
  if (error.code !== 'EEXIST')
354
418
  throw error;
355
419
  }
356
- const existing = readLockOwnerIfPresent(lockPath);
357
- if (!existing) {
420
+ if (reclaimTransactionLock(lockPath, reclaimPath, temporaryPath, owner) === 'retry')
358
421
  attempt -= 1;
359
- continue;
360
- }
361
- if (existing.hostname !== owner.hostname || isProcessAlive(existing.pid)) {
362
- throw new Error(`Another Anhedral operation is already running (${lockPath}, pid ${existing.pid} on ${existing.hostname}).`);
363
- }
364
- try {
365
- linkSync(temporaryPath, reclaimPath);
366
- }
367
- catch (error) {
368
- if (error.code === 'EEXIST') {
369
- const reclaimer = readLockOwnerIfPresent(reclaimPath);
370
- if (!reclaimer) {
371
- attempt -= 1;
372
- continue;
373
- }
374
- if (reclaimer.hostname === owner.hostname && !isProcessAlive(reclaimer.pid)) {
375
- unlinkSync(reclaimPath);
376
- attempt -= 1;
377
- continue;
378
- }
379
- throw new Error(`A stale Anhedral lock is being recovered (${lockPath}, pid ${reclaimer.pid} on ${reclaimer.hostname}); retry the command.`);
380
- }
381
- throw error;
382
- }
383
- try {
384
- if (!pathEntryExists(lockPath))
385
- continue;
386
- const current = parseLockOwner(lockPath);
387
- if (current.token !== existing.token)
388
- continue;
389
- if (current.hostname !== owner.hostname || isProcessAlive(current.pid)) {
390
- throw new Error(`Another Anhedral operation is already running (${lockPath}, pid ${current.pid} on ${current.hostname}).`);
391
- }
392
- unlinkSync(lockPath);
393
- }
394
- finally {
395
- if (pathEntryExists(reclaimPath)) {
396
- const reclaimer = parseLockOwner(reclaimPath);
397
- if (reclaimer.token === owner.token)
398
- unlinkSync(reclaimPath);
399
- }
400
- }
401
422
  }
402
423
  }
403
424
  finally {
@@ -448,20 +469,13 @@ function assertNoSymlinksRecursively(target) {
448
469
  for (const entry of readdirSync(target))
449
470
  assertNoSymlinksRecursively(path.join(target, entry));
450
471
  }
451
- function validateJournal(root, value) {
452
- if (!isRecord(value) || value.version !== JOURNAL_VERSION) {
453
- throw new Error(`Cannot recover unsupported transaction journal at ${path.join(root, JOURNAL_FILE)}`);
454
- }
455
- assertExactObjectKeys(value, ['version', 'backupRoot', 'stageRoot', 'entries', 'createdDirectories'], 'transaction journal');
456
- if (typeof value.backupRoot !== 'string' || typeof value.stageRoot !== 'string' || !Array.isArray(value.entries)) {
457
- throw new Error(`Invalid transaction journal at ${path.join(root, JOURNAL_FILE)}`);
458
- }
459
- const backupIdentity = assertExpectedTempRoot(root, value.backupRoot, 'backup');
460
- const stageIdentity = assertExpectedTempRoot(root, value.stageRoot, 'stage');
472
+ function assertJournalRoots(root, backupRoot, stageRoot) {
473
+ const backupIdentity = assertExpectedTempRoot(root, backupRoot, 'backup');
474
+ const stageIdentity = assertExpectedTempRoot(root, stageRoot, 'stage');
461
475
  if (backupIdentity.token !== stageIdentity.token) {
462
476
  throw new Error('Transaction stage and backup roots do not share one token.');
463
477
  }
464
- for (const temporaryRoot of [value.backupRoot, value.stageRoot]) {
478
+ for (const temporaryRoot of [backupRoot, stageRoot]) {
465
479
  const stat = lstatIfPresent(temporaryRoot);
466
480
  if (stat) {
467
481
  if (stat.isSymbolicLink() || !stat.isDirectory()) {
@@ -469,6 +483,16 @@ function validateJournal(root, value) {
469
483
  }
470
484
  }
471
485
  }
486
+ }
487
+ function validateJournal(root, value) {
488
+ if (!isRecord(value) || value.version !== JOURNAL_VERSION) {
489
+ throw new Error(`Cannot recover unsupported transaction journal at ${path.join(root, JOURNAL_FILE)}`);
490
+ }
491
+ assertExactObjectKeys(value, ['version', 'backupRoot', 'stageRoot', 'entries', 'createdDirectories'], 'transaction journal');
492
+ if (typeof value.backupRoot !== 'string' || typeof value.stageRoot !== 'string' || !Array.isArray(value.entries)) {
493
+ throw new Error(`Invalid transaction journal at ${path.join(root, JOURNAL_FILE)}`);
494
+ }
495
+ assertJournalRoots(root, value.backupRoot, value.stageRoot);
472
496
  const seen = new Set();
473
497
  const entries = value.entries.map((entry, index) => {
474
498
  if (!isRecord(entry) || typeof entry.relativePath !== 'string' || typeof entry.installed !== 'boolean') {
@@ -561,7 +585,8 @@ function removeEmptyTransactionDirectory(root) {
561
585
  }
562
586
  catch (error) {
563
587
  const code = error.code;
564
- if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(code ?? ''))
588
+ // Preserve rejected files/symlinks and the original namespace validation error.
589
+ if (!['ENOENT', 'ENOTDIR', 'ENOTEMPTY', 'EEXIST'].includes(code ?? ''))
565
590
  throw error;
566
591
  }
567
592
  }
@@ -588,27 +613,30 @@ function cleanupOrphanTransactionRoots(root) {
588
613
  }
589
614
  removeEmptyTransactionDirectory(root);
590
615
  }
616
+ function restoreInstalledEntry(root, journal, entry, target) {
617
+ if (entry.installed) {
618
+ if (pathEntryExists(target)) {
619
+ if (entry.installedFingerprint === null) {
620
+ throw new Error(`Installed transaction path has no trusted fingerprint: ${entry.relativePath}. `
621
+ + 'Recovery stopped without modifying it; inspect the journal, target, and backup manually.');
622
+ }
623
+ const actualFingerprint = fingerprintEntry(target);
624
+ if (actualFingerprint !== entry.installedFingerprint) {
625
+ throw new Error(`Installed transaction path changed after an interrupted commit: ${entry.relativePath}. `
626
+ + 'Recovery stopped without modifying it; inspect the journal, target, and backup manually.');
627
+ }
628
+ }
629
+ removeEntryDurably(target);
630
+ entry.installed = false;
631
+ entry.installedFingerprint = null;
632
+ writeJournal(root, journal);
633
+ }
634
+ }
591
635
  function restoreJournal(root, journal) {
592
636
  for (const entry of [...journal.entries].reverse()) {
593
637
  assertNoSymlinkComponents(root, entry.relativePath);
594
638
  const target = resolveTransactionPath(root, entry.relativePath);
595
- if (entry.installed) {
596
- if (pathEntryExists(target)) {
597
- if (entry.installedFingerprint === null) {
598
- throw new Error(`Installed transaction path has no trusted fingerprint: ${entry.relativePath}. `
599
- + 'Recovery stopped without modifying it; inspect the journal, target, and backup manually.');
600
- }
601
- const actualFingerprint = fingerprintEntry(target);
602
- if (actualFingerprint !== entry.installedFingerprint) {
603
- throw new Error(`Installed transaction path changed after an interrupted commit: ${entry.relativePath}. `
604
- + 'Recovery stopped without modifying it; inspect the journal, target, and backup manually.');
605
- }
606
- }
607
- removeEntryDurably(target);
608
- entry.installed = false;
609
- entry.installedFingerprint = null;
610
- writeJournal(root, journal);
611
- }
639
+ restoreInstalledEntry(root, journal, entry, target);
612
640
  if (entry.backupPath) {
613
641
  assertNoSymlinkComponents(journal.backupRoot, entry.relativePath);
614
642
  if (pathEntryExists(entry.backupPath)) {
@@ -675,6 +703,90 @@ export class PostCommitError extends Error {
675
703
  this.committedPaths = Object.freeze([...committedPaths]);
676
704
  }
677
705
  }
706
+ function assertTransactionNamespace(transactionDirectory) {
707
+ const namespaceStat = lstatIfPresent(transactionDirectory);
708
+ if (namespaceStat && (namespaceStat.isSymbolicLink() || !namespaceStat.isDirectory())) {
709
+ throw new Error(`Unsafe reserved transaction namespace requires manual inspection: ${transactionDirectory}`);
710
+ }
711
+ if (!namespaceStat)
712
+ return;
713
+ for (const entry of readdirSync(transactionDirectory)) {
714
+ const candidate = path.join(transactionDirectory, entry);
715
+ const stat = lstatIfPresent(candidate);
716
+ if (!LOCAL_TRANSACTION_ROOT_PATTERN.test(entry) || !stat || stat.isSymbolicLink() || !stat.isDirectory()) {
717
+ throw new Error(`Unknown or unsafe artifact in reserved transaction namespace requires manual inspection: ${candidate}`);
718
+ }
719
+ }
720
+ }
721
+ function seedTransaction(root, stageRoot, seedPaths) {
722
+ for (const relativePath of seedPaths) {
723
+ assertRelativePath(relativePath);
724
+ assertNoSymlinkComponents(root, relativePath);
725
+ const source = resolveTransactionPath(root, relativePath);
726
+ if (!pathEntryExists(source))
727
+ continue;
728
+ assertNoSymlinksRecursively(source);
729
+ const target = resolveTransactionPath(stageRoot, relativePath);
730
+ mkdirSync(path.dirname(target), { recursive: true });
731
+ cpSync(source, target, { recursive: true, preserveTimestamps: true });
732
+ }
733
+ }
734
+ function ensureTransactionParents(root, entry, journal, createdDirectorySet) {
735
+ const segments = entry.relativePath.split('/').slice(0, -1);
736
+ let cursor = root;
737
+ let relativeDirectory = '';
738
+ const missingDirectories = [];
739
+ for (const segment of segments) {
740
+ cursor = path.join(cursor, segment);
741
+ relativeDirectory = relativeDirectory ? `${relativeDirectory}/${segment}` : segment;
742
+ if (!pathEntryExists(cursor))
743
+ missingDirectories.push(relativeDirectory);
744
+ else if (!lstatSync(cursor).isDirectory()) {
745
+ throw new Error(`Transaction parent is not a directory: ${relativeDirectory}`);
746
+ }
747
+ }
748
+ const newDirectories = missingDirectories.filter((directory) => !createdDirectorySet.has(directory));
749
+ if (newDirectories.length > 0) {
750
+ journal.createdDirectories.push(...newDirectories);
751
+ for (const directory of newDirectories)
752
+ createdDirectorySet.add(directory);
753
+ writeJournal(root, journal);
754
+ }
755
+ }
756
+ function commitJournal(root, journal, deletePaths) {
757
+ const { stageRoot, backupRoot } = journal;
758
+ const createdDirectorySet = new Set(journal.createdDirectories);
759
+ for (const entry of journal.entries) {
760
+ const source = resolveTransactionPath(stageRoot, entry.relativePath);
761
+ const deleting = deletePaths.has(entry.relativePath);
762
+ if (!deleting && !pathEntryExists(source)) {
763
+ throw new Error(`Staged output is missing declared path: ${entry.relativePath}`);
764
+ }
765
+ if (deleting && pathEntryExists(source))
766
+ throw new Error(`Deleted transaction path still exists in staging: ${entry.relativePath}`);
767
+ assertNoSymlinkComponents(root, entry.relativePath);
768
+ if (!deleting) {
769
+ assertNoSymlinksRecursively(source);
770
+ syncEntryRecursively(source);
771
+ }
772
+ const target = resolveTransactionPath(root, entry.relativePath);
773
+ if (pathEntryExists(target)) {
774
+ const backup = resolveTransactionPath(backupRoot, entry.relativePath);
775
+ mkdirSync(path.dirname(backup), { recursive: true });
776
+ entry.backupPath = backup;
777
+ writeJournal(root, journal);
778
+ renameDurably(target, backup, root);
779
+ }
780
+ if (!deleting) {
781
+ ensureTransactionParents(root, entry, journal, createdDirectorySet);
782
+ mkdirSync(path.dirname(target), { recursive: true });
783
+ entry.installedFingerprint = fingerprintEntry(source);
784
+ entry.installed = true;
785
+ writeJournal(root, journal);
786
+ renameDurably(source, target, root);
787
+ }
788
+ }
789
+ }
678
790
  /**
679
791
  * Build changes away from the destination and install only the declared paths.
680
792
  * Every replaced path is journaled first, so errors and later invocations can
@@ -686,6 +798,7 @@ export async function runStagedTransaction(root, options) {
686
798
  const transactionDirectory = path.join(path.resolve(root), TRANSACTION_DIRECTORY);
687
799
  const stageRoot = path.join(transactionDirectory, `stage-${token}`);
688
800
  const backupRoot = path.join(transactionDirectory, `backup-${token}`);
801
+ let namespaceValidated = false;
689
802
  try {
690
803
  if (options.dryRun && pathEntryExists(path.join(root, JOURNAL_FILE))) {
691
804
  throw new Error('Dry-run cannot recover an interrupted transaction. Re-run the original command without --dry-run first.');
@@ -695,32 +808,11 @@ export async function runStagedTransaction(root, options) {
695
808
  cleanupOrphanTransactionRoots(root);
696
809
  if (await options.prepare?.() === false)
697
810
  return Object.freeze([]);
698
- const namespaceStat = lstatIfPresent(transactionDirectory);
699
- if (namespaceStat && (namespaceStat.isSymbolicLink() || !namespaceStat.isDirectory())) {
700
- throw new Error(`Unsafe reserved transaction namespace requires manual inspection: ${transactionDirectory}`);
701
- }
702
- if (namespaceStat) {
703
- for (const entry of readdirSync(transactionDirectory)) {
704
- const candidate = path.join(transactionDirectory, entry);
705
- const stat = lstatIfPresent(candidate);
706
- if (!LOCAL_TRANSACTION_ROOT_PATTERN.test(entry) || !stat || stat.isSymbolicLink() || !stat.isDirectory()) {
707
- throw new Error(`Unknown or unsafe artifact in reserved transaction namespace requires manual inspection: ${candidate}`);
708
- }
709
- }
710
- }
811
+ assertTransactionNamespace(transactionDirectory);
812
+ namespaceValidated = true;
711
813
  mkdirSync(stageRoot, { recursive: true, mode: 0o700 });
712
814
  mkdirSync(backupRoot, { recursive: true, mode: 0o700 });
713
- for (const relativePath of options.seedPaths ?? []) {
714
- assertRelativePath(relativePath);
715
- assertNoSymlinkComponents(root, relativePath);
716
- const source = resolveTransactionPath(root, relativePath);
717
- if (!pathEntryExists(source))
718
- continue;
719
- assertNoSymlinksRecursively(source);
720
- const target = resolveTransactionPath(stageRoot, relativePath);
721
- mkdirSync(path.dirname(target), { recursive: true });
722
- cpSync(source, target, { recursive: true, preserveTimestamps: true });
723
- }
815
+ seedTransaction(root, stageRoot, options.seedPaths ?? []);
724
816
  await options.build(stageRoot);
725
817
  const normalizedPaths = normalizeTransactionPaths(options.commitPaths, options.deletePaths ?? []);
726
818
  const deletePaths = new Set(normalizedPaths.deletePaths);
@@ -738,58 +830,9 @@ export async function runStagedTransaction(root, options) {
738
830
  return { relativePath, backupPath: null, installed: false, installedFingerprint: null };
739
831
  }),
740
832
  };
741
- const createdDirectorySet = new Set(journal.createdDirectories);
742
833
  writeJournal(root, journal);
743
834
  try {
744
- for (const entry of journal.entries) {
745
- const source = resolveTransactionPath(stageRoot, entry.relativePath);
746
- const deleting = deletePaths.has(entry.relativePath);
747
- if (!deleting && !pathEntryExists(source)) {
748
- throw new Error(`Staged output is missing declared path: ${entry.relativePath}`);
749
- }
750
- if (deleting && pathEntryExists(source))
751
- throw new Error(`Deleted transaction path still exists in staging: ${entry.relativePath}`);
752
- assertNoSymlinkComponents(root, entry.relativePath);
753
- if (!deleting) {
754
- assertNoSymlinksRecursively(source);
755
- syncEntryRecursively(source);
756
- }
757
- const target = resolveTransactionPath(root, entry.relativePath);
758
- if (pathEntryExists(target)) {
759
- const backup = resolveTransactionPath(backupRoot, entry.relativePath);
760
- mkdirSync(path.dirname(backup), { recursive: true });
761
- entry.backupPath = backup;
762
- writeJournal(root, journal);
763
- renameDurably(target, backup, root);
764
- }
765
- if (!deleting) {
766
- const segments = entry.relativePath.split('/').slice(0, -1);
767
- let cursor = root;
768
- let relativeDirectory = '';
769
- const missingDirectories = [];
770
- for (const segment of segments) {
771
- cursor = path.join(cursor, segment);
772
- relativeDirectory = relativeDirectory ? `${relativeDirectory}/${segment}` : segment;
773
- if (!pathEntryExists(cursor))
774
- missingDirectories.push(relativeDirectory);
775
- else if (!lstatSync(cursor).isDirectory()) {
776
- throw new Error(`Transaction parent is not a directory: ${relativeDirectory}`);
777
- }
778
- }
779
- const newDirectories = missingDirectories.filter((directory) => !createdDirectorySet.has(directory));
780
- if (newDirectories.length > 0) {
781
- journal.createdDirectories.push(...newDirectories);
782
- for (const directory of newDirectories)
783
- createdDirectorySet.add(directory);
784
- writeJournal(root, journal);
785
- }
786
- mkdirSync(path.dirname(target), { recursive: true });
787
- entry.installedFingerprint = fingerprintEntry(source);
788
- entry.installed = true;
789
- writeJournal(root, journal);
790
- renameDurably(source, target, root);
791
- }
792
- }
835
+ commitJournal(root, journal, deletePaths);
793
836
  }
794
837
  catch (error) {
795
838
  restoreJournal(root, journal);
@@ -809,7 +852,7 @@ export async function runStagedTransaction(root, options) {
809
852
  }
810
853
  finally {
811
854
  try {
812
- if (!pathEntryExists(path.join(root, JOURNAL_FILE))) {
855
+ if (namespaceValidated && !pathEntryExists(path.join(root, JOURNAL_FILE))) {
813
856
  rmSync(stageRoot, { recursive: true, force: true });
814
857
  rmSync(backupRoot, { recursive: true, force: true });
815
858
  removeEmptyTransactionDirectory(root);
package/dist/util.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, rmSync, writeFileSync, } from 'node:fs';
2
+ import { resolveCommand } from './command.js';
2
3
  import { spawnSync } from 'node:child_process';
3
4
  import { tmpdir } from 'node:os';
4
5
  import path from 'node:path';
@@ -50,11 +51,12 @@ function dumpFailure(cmd, status, stdoutPath, stderrPath, spawnError) {
50
51
  throw new Error(`Command failed (exit ${status ?? '?'}): ${cmd}`, spawnError ? { cause: spawnError } : undefined);
51
52
  }
52
53
  export function execFile(executable, args, cwd) {
54
+ const invocation = resolveCommand(executable, args);
53
55
  const quiet = process.env.ANHEDRAL_QUIET === '1';
54
56
  const display = [executable, ...args].join(' ');
55
57
  if (process.env.ANHEDRAL_VERBOSE === '1' && !quiet) {
56
58
  console.log(` $ ${display}`);
57
- const result = spawnSync(executable, [...args], { cwd, stdio: 'inherit', shell: false });
59
+ const result = spawnSync(invocation.command, invocation.args, { cwd, stdio: 'inherit', shell: false });
58
60
  if (result.error || result.status !== 0) {
59
61
  throw new Error(`Command failed (exit ${result.status ?? '?'}): ${display}`, result.error ? { cause: result.error } : undefined);
60
62
  }
@@ -66,7 +68,7 @@ export function execFile(executable, args, cwd) {
66
68
  const stdout = openSync(stdoutPath, 'w');
67
69
  const stderr = openSync(stderrPath, 'w');
68
70
  try {
69
- const result = spawnSync(executable, [...args], {
71
+ const result = spawnSync(invocation.command, invocation.args, {
70
72
  cwd,
71
73
  shell: false,
72
74
  stdio: ['ignore', stdout, stderr],
package/dist/version.d.ts CHANGED
@@ -1,2 +1 @@
1
- /** Kept in sync with package.json by tests/release checks. */
2
- export declare const GENERATOR_VERSION = "0.6.0";
1
+ export declare const GENERATOR_VERSION = "0.7.0";
package/dist/version.js CHANGED
@@ -1,2 +1 @@
1
- /** Kept in sync with package.json by tests/release checks. */
2
- export const GENERATOR_VERSION = '0.6.0';
1
+ export const GENERATOR_VERSION = '0.7.0';
@@ -45,9 +45,9 @@ Human approval is required for paid activation, material spending, architecture
45
45
  |**Scheduled work**|**Cron Triggers**|—|Recurring scheduled work.|
46
46
  |**Durable workflows**|**Workflows**|—|Resumable multi-step processes.|
47
47
  |**AI**|**OpenAI SDK or AI SDK**|Workers AI|OpenAI SDK for direct OpenAI use; AI SDK for streaming/tools/provider abstraction; Workers AI when Cloudflare inference is advantageous.|
48
- |**Outbound email**|**Resend**|—|Transactional application email.|
49
- |**Inbound email**|**Cloudflare Email Routing**|—|Incoming domain-email forwarding.|
50
- |**Business email**|**Existing provider**|Future Cloudflare offering|Reconsider when Cloudflare's offering is GA/free and preferable.|
48
+ |**Domain**|**GoDaddy broker + Cloudflare nameservers**|—|Use GoDaddy for domain registration/brokerage and Cloudflare for authoritative DNS.|
49
+ |**Inbound Mail**|**Cloudflare Email Routing**|—|Incoming domain-email routing for business and application mail.|
50
+ |**Outbound Mail**|**Cloudflare Email Sending**|Resend|Business and application mail; use Resend when selected as the alternative sending provider.|
51
51
  |**Payments**|**Stripe**|RevenueCat + Stripe|Add RevenueCat when web/App Store/Play Store entitlements need unified management.|
52
52
  |**Operational telemetry**|**Cloudflare Observability**|Sentry|Add Sentry when additional crash/runtime diagnostics are required.|
53
53
  |**Product analytics**|**Basin**|PostHog|PostHog when replay, experiments, funnels, or other required capabilities justify it.|
@@ -519,21 +519,23 @@ AI Gateway and related Cloudflare AI services may be selected when their routing
519
519
 
520
520
  ---
521
521
 
522
- # 13. Email
522
+ # 13. Domain and mail
523
523
 
524
524
  Use:
525
525
 
526
526
  ```
527
- Outbound application email → Resend
528
- Inbound email → Cloudflare Email Routing
529
- Business mailbox → existing provider
527
+ Domain → GoDaddy broker + Cloudflare nameservers
528
+ Inbound Mail → Cloudflare Email Routing
529
+ Outbound Mail → Cloudflare Email Sending (default) / Resend (alternative)
530
530
  ```
531
531
 
532
- Treat these as separate concerns.
532
+ Purchase the domain through GoDaddy registration/brokerage in the client's account. The client owns registration, renewal, billing, and recovery access.
533
533
 
534
- Do not migrate business email solely for provider consolidation.
534
+ Then add the domain as a zone in the client's Cloudflare account, verify its DNS records, and set the registrar's nameservers to those assigned by Cloudflare. Verify the zone is active before connecting application domains and mail. Registration remains with the registrar; Cloudflare manages authoritative DNS.
535
535
 
536
- Reconsider Cloudflare business email when its offering is generally available or free and actually preferable.
536
+ Use Cloudflare Email Routing for inbound mail and Cloudflare Email Sending for outbound business and application mail. Resend is an alternative outbound sending provider when selected for the project.
537
+
538
+ Configure domain verification, routing destinations, sender authentication, and environment-specific sending credentials or bindings for the selected provider.
537
539
 
538
540
  ---
539
541
 
@@ -692,6 +694,7 @@ macOS
692
694
  Configure only the services selected by the project.
693
695
 
694
696
  - **Cloudflare:** client-owned account, account ID, authorized plugin/OAuth or scoped API token, and a plan supporting the selected services. Add billing or DNS access only when required.
697
+ - **Domain and mail:** client-owned GoDaddy domain access, Cloudflare nameservers/DNS access, Email Routing destinations, and Cloudflare Email Sending setup or Resend credentials when selected.
695
698
  - **Neon / Hyperdrive:** authorized Neon project access, database connection credentials, separate migration/runtime roles, and Hyperdrive origin configuration.
696
699
  - **Private R2 / Pipelines:** Worker bindings for application storage; authorized sink writes to the selected private bucket. Cloudflare's managed setup requires the Workers Pipelines app's **Pipelines Setup** grant. Keep ingestion private unless an authorized public endpoint is required.
697
700
  - **Auth and optional providers:** Better Auth secret/URL or Clerk keys; selected AI, email, payments, analytics, and observability credentials, domains, and webhooks.