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.
- package/README.md +19 -4
- package/assets/anhedral-cli-init-technical.svg +4 -4
- package/assets/anhedral-cli-init.svg +4 -4
- package/dist/bin.js +27 -8
- package/dist/capabilities.d.ts +54 -0
- package/dist/capabilities.js +90 -0
- package/dist/command.d.ts +5 -0
- package/dist/command.js +16 -0
- package/dist/dependencies.js +8 -17
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/platforms/desktop.js +24 -19
- package/dist/platforms/extension.js +22 -39
- package/dist/platforms/lint.d.ts +1 -0
- package/dist/platforms/lint.js +5 -0
- package/dist/platforms/mobile.js +21 -15
- package/dist/readiness.d.ts +20 -0
- package/dist/readiness.js +61 -0
- package/dist/render.d.ts +0 -1
- package/dist/render.js +1 -1
- package/dist/standard.js +184 -121
- package/dist/transaction.js +238 -195
- package/dist/util.js +4 -2
- package/dist/version.d.ts +1 -2
- package/dist/version.js +1 -2
- package/docs/application-stack-standard.md +13 -10
- package/package.json +5 -9
- /package/{anhedral.svg → assets/anhedral.svg} +0 -0
- /package/{favicon.ico → assets/favicon.ico} +0 -0
package/dist/transaction.js
CHANGED
|
@@ -140,7 +140,7 @@ function fingerprintEntry(target) {
|
|
|
140
140
|
}
|
|
141
141
|
function isUnsupportedDirectorySync(error, operation) {
|
|
142
142
|
const code = error.code;
|
|
143
|
-
if (
|
|
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
|
-
|
|
153
|
-
|
|
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
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
452
|
-
|
|
453
|
-
|
|
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 [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
699
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|**
|
|
49
|
-
|**Inbound
|
|
50
|
-
|**
|
|
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.
|
|
522
|
+
# 13. Domain and mail
|
|
523
523
|
|
|
524
524
|
Use:
|
|
525
525
|
|
|
526
526
|
```
|
|
527
|
-
|
|
528
|
-
Inbound
|
|
529
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|