anhedral 0.1.10 → 0.4.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.
Files changed (87) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/CONTRIBUTING.md +73 -0
  3. package/LICENSE +196 -17
  4. package/README.md +202 -145
  5. package/SECURITY.md +37 -0
  6. package/SKILL.md +244 -0
  7. package/bin/anhedral.js +1 -1
  8. package/dist/architecture/contributions.d.ts +28 -0
  9. package/dist/architecture/contributions.js +125 -0
  10. package/dist/architecture/index.d.ts +12 -0
  11. package/dist/architecture/index.js +6 -0
  12. package/dist/architecture/manifest.d.ts +53 -0
  13. package/dist/architecture/manifest.js +283 -0
  14. package/dist/architecture/modules.d.ts +37 -0
  15. package/dist/architecture/modules.js +184 -0
  16. package/dist/architecture/plan.d.ts +58 -0
  17. package/dist/architecture/plan.js +145 -0
  18. package/dist/architecture/templates.d.ts +10 -0
  19. package/dist/architecture/templates.js +23 -0
  20. package/dist/bin.d.ts +2 -0
  21. package/dist/bin.js +179 -0
  22. package/dist/cli.d.ts +24 -2
  23. package/dist/cli.js +189 -21
  24. package/dist/composers/root.d.ts +6 -0
  25. package/dist/composers/root.js +35 -0
  26. package/dist/dependencies.d.ts +31 -9
  27. package/dist/dependencies.js +227 -60
  28. package/dist/index.d.ts +8 -2
  29. package/dist/index.js +5 -31
  30. package/dist/print.js +15 -0
  31. package/dist/prompts.d.ts +5 -0
  32. package/dist/prompts.js +19 -0
  33. package/dist/render.d.ts +11 -0
  34. package/dist/render.js +74 -0
  35. package/dist/scaffold.d.ts +64 -12
  36. package/dist/scaffold.js +1726 -1150
  37. package/dist/template-source.d.ts +20 -0
  38. package/dist/template-source.js +180 -0
  39. package/dist/templates/api.d.ts +2 -0
  40. package/dist/templates/api.js +3122 -0
  41. package/dist/templates/assets-private-proxy.d.ts +3 -0
  42. package/dist/templates/assets-private-proxy.js +277 -0
  43. package/dist/templates/desktop.d.ts +2 -0
  44. package/dist/templates/desktop.js +639 -0
  45. package/dist/templates/electron-updater.d.ts +3 -0
  46. package/dist/templates/electron-updater.js +358 -0
  47. package/dist/templates/extension.d.ts +1 -1
  48. package/dist/templates/extension.js +322 -248
  49. package/dist/templates/mobile.d.ts +2 -0
  50. package/dist/templates/mobile.js +720 -0
  51. package/dist/templates/shared.d.ts +2 -0
  52. package/dist/templates/shared.js +520 -0
  53. package/dist/templates/web.d.ts +2 -0
  54. package/dist/templates/web.js +412 -0
  55. package/dist/toolchain.d.ts +0 -8
  56. package/dist/toolchain.js +0 -21
  57. package/dist/transaction.d.ts +22 -0
  58. package/dist/transaction.js +822 -0
  59. package/dist/ui.d.ts +33 -0
  60. package/dist/ui.js +199 -0
  61. package/dist/util.d.ts +1 -5
  62. package/dist/util.js +70 -159
  63. package/dist/version.d.ts +2 -0
  64. package/dist/version.js +2 -0
  65. package/dist/workspace-config.d.ts +11 -0
  66. package/dist/workspace-config.js +372 -0
  67. package/docs/conventions.md +92 -0
  68. package/docs/master-stack-map.md +192 -0
  69. package/docs/output-tree-contract.md +482 -0
  70. package/docs/references/manual-scaffolding.md +403 -0
  71. package/docs/references/provisioning.md +220 -0
  72. package/package.json +57 -13
  73. package/templates/api-fastify/apps/api/tsconfig.json +20 -0
  74. package/templates/catalog.json +23 -0
  75. package/templates/db-drizzle/packages/db/tsconfig.json +14 -0
  76. package/templates/desktop-electron/apps/desktop/tsconfig.json +37 -0
  77. package/templates/extension-wxt/apps/extension/tsconfig.json +41 -0
  78. package/templates/mobile-expo/apps/mobile/expo-env.d.ts +1 -0
  79. package/templates/web-next/apps/web/next-env.d.ts +6 -0
  80. package/dist/branding.d.ts +0 -5
  81. package/dist/branding.js +0 -17
  82. package/dist/commands.d.ts +0 -7
  83. package/dist/commands.js +0 -24
  84. package/dist/templates/backend.d.ts +0 -2
  85. package/dist/templates/backend.js +0 -2115
  86. package/dist/templates/frontend.d.ts +0 -2
  87. package/dist/templates/frontend.js +0 -1268
@@ -0,0 +1,822 @@
1
+ import { closeSync, cpSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmdirSync, rmSync, unlinkSync, writeFileSync, } from 'node:fs';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ const LOCK_FILE = '.anhedral.lock';
6
+ const JOURNAL_FILE = '.anhedral-journal.json';
7
+ const TRANSACTION_DIRECTORY = '.anhedral-txn';
8
+ const JOURNAL_VERSION = 2;
9
+ const LOCAL_TRANSACTION_ROOT_PATTERN = /^(?:stage|backup)-\d+-[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
10
+ function assertRelativePath(relativePath) {
11
+ const normalized = path.normalize(relativePath);
12
+ const rawSegments = relativePath.split(/[\\/]/);
13
+ if (!relativePath ||
14
+ relativePath.includes('\0') ||
15
+ relativePath.includes('\\') ||
16
+ path.isAbsolute(relativePath) ||
17
+ rawSegments.some((segment) => segment === '' || segment === '.' || segment === '..') ||
18
+ normalized === '.' ||
19
+ normalized === '..' ||
20
+ normalized.startsWith(`..${path.sep}`) ||
21
+ normalized.split(path.sep).includes('..')
22
+ || rawSegments[0] === TRANSACTION_DIRECTORY
23
+ || rawSegments[0] === LOCK_FILE
24
+ || rawSegments[0] === JOURNAL_FILE) {
25
+ throw new Error(`Unsafe transaction path: ${relativePath}`);
26
+ }
27
+ }
28
+ function resolveTransactionPath(root, relativePath) {
29
+ assertRelativePath(relativePath);
30
+ const resolvedRoot = path.resolve(root);
31
+ const resolved = path.resolve(resolvedRoot, relativePath);
32
+ if (!resolved.startsWith(`${resolvedRoot}${path.sep}`)) {
33
+ throw new Error(`Unsafe transaction path: ${relativePath}`);
34
+ }
35
+ return resolved;
36
+ }
37
+ function ancestorInSet(relativePath, candidates) {
38
+ const segments = relativePath.split('/');
39
+ for (let index = 1; index < segments.length; index += 1) {
40
+ const ancestor = segments.slice(0, index).join('/');
41
+ if (candidates.has(ancestor))
42
+ return ancestor;
43
+ }
44
+ return null;
45
+ }
46
+ function assertNoPathAncestry(paths, context) {
47
+ const seen = new Set();
48
+ const ordered = [...paths].sort((left, right) => left.split('/').length - right.split('/').length || left.localeCompare(right));
49
+ for (const relativePath of ordered) {
50
+ assertRelativePath(relativePath);
51
+ const ancestor = ancestorInSet(relativePath, seen);
52
+ if (ancestor) {
53
+ throw new Error(`Ambiguous transaction ${context}: ${ancestor} is an ancestor of ${relativePath}.`);
54
+ }
55
+ seen.add(relativePath);
56
+ }
57
+ }
58
+ function normalizeTransactionPaths(rawCommitPaths, rawDeletePaths) {
59
+ const commitPaths = [...new Set(rawCommitPaths)];
60
+ for (const relativePath of commitPaths)
61
+ assertRelativePath(relativePath);
62
+ assertNoPathAncestry(commitPaths, 'write paths');
63
+ const deleteCandidates = [...new Set(rawDeletePaths)];
64
+ for (const relativePath of deleteCandidates)
65
+ assertRelativePath(relativePath);
66
+ deleteCandidates.sort((left, right) => left.split('/').length - right.split('/').length || left.localeCompare(right));
67
+ const deleteSet = new Set();
68
+ const deletePaths = deleteCandidates.filter((candidate) => {
69
+ if (ancestorInSet(candidate, deleteSet))
70
+ return false;
71
+ deleteSet.add(candidate);
72
+ return true;
73
+ });
74
+ const deleteAncestorPaths = new Set();
75
+ for (const deleted of deletePaths) {
76
+ const segments = deleted.split('/');
77
+ for (let index = 1; index < segments.length; index += 1) {
78
+ deleteAncestorPaths.add(segments.slice(0, index).join('/'));
79
+ }
80
+ }
81
+ for (const written of commitPaths) {
82
+ const deletedAncestor = ancestorInSet(written, deleteSet);
83
+ if (deleteSet.has(written) || deletedAncestor || deleteAncestorPaths.has(written)) {
84
+ throw new Error(`Ambiguous transaction write/delete paths include ${written}`
85
+ + (deletedAncestor ? ` below ${deletedAncestor}` : ''));
86
+ }
87
+ }
88
+ const uniquePaths = [...commitPaths, ...deletePaths].sort();
89
+ return { commitPaths, deletePaths, uniquePaths };
90
+ }
91
+ function isRecord(value) {
92
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
93
+ }
94
+ function assertExactObjectKeys(value, expected, context) {
95
+ const expectedKeys = new Set(expected);
96
+ const missing = expected.filter((key) => !Object.hasOwn(value, key));
97
+ const unknown = Object.keys(value).filter((key) => !expectedKeys.has(key));
98
+ if (missing.length === 0 && unknown.length === 0)
99
+ return;
100
+ const details = [
101
+ missing.length > 0 ? `missing ${missing.join(', ')}` : null,
102
+ unknown.length > 0 ? `unknown ${unknown.join(', ')}` : null,
103
+ ].filter((entry) => entry !== null).join('; ');
104
+ throw new Error(`Invalid ${context} fields (${details}).`);
105
+ }
106
+ function lstatIfPresent(target) {
107
+ try {
108
+ return lstatSync(target);
109
+ }
110
+ catch (error) {
111
+ if (error.code === 'ENOENT')
112
+ return null;
113
+ throw error;
114
+ }
115
+ }
116
+ function pathEntryExists(target) {
117
+ return lstatIfPresent(target) !== null;
118
+ }
119
+ function fingerprintEntry(target) {
120
+ const hash = createHash('sha256');
121
+ const visit = (absolutePath, relativePath) => {
122
+ const stat = lstatSync(absolutePath);
123
+ const mode = stat.mode & 0o7777;
124
+ if (stat.isDirectory()) {
125
+ hash.update(`directory\0${relativePath}\0${mode}\0`);
126
+ for (const entry of readdirSync(absolutePath).sort()) {
127
+ visit(path.join(absolutePath, entry), relativePath === '.' ? entry : `${relativePath}/${entry}`);
128
+ }
129
+ return;
130
+ }
131
+ if (stat.isFile()) {
132
+ hash.update(`file\0${relativePath}\0${mode}\0${stat.size}\0`);
133
+ hash.update(readFileSync(absolutePath));
134
+ return;
135
+ }
136
+ throw new Error(`Cannot fingerprint unsupported transaction entry: ${absolutePath}`);
137
+ };
138
+ visit(target, '.');
139
+ return hash.digest('hex');
140
+ }
141
+ function isUnsupportedDirectorySync(error, operation) {
142
+ const code = error.code;
143
+ if (code === 'ENOTSUP' || code === 'EOPNOTSUPP')
144
+ return true;
145
+ // Some filesystems expose directories as readable handles but reject fsync.
146
+ // Windows may reject opening or syncing directory handles altogether. Keep
147
+ // these narrowly scoped so permission and I/O failures remain actionable.
148
+ if (operation === 'fsync' && code === 'EINVAL')
149
+ return true;
150
+ if (process.platform !== 'win32')
151
+ return false;
152
+ if (operation === 'open')
153
+ return code === 'EISDIR' || code === 'EPERM';
154
+ return code === 'EBADF' || code === 'EPERM';
155
+ }
156
+ function syncDirectory(directory) {
157
+ let descriptor;
158
+ try {
159
+ descriptor = openSync(directory, 'r');
160
+ }
161
+ catch (error) {
162
+ if (isUnsupportedDirectorySync(error, 'open'))
163
+ return;
164
+ throw error;
165
+ }
166
+ try {
167
+ try {
168
+ fsyncSync(descriptor);
169
+ }
170
+ catch (error) {
171
+ if (!isUnsupportedDirectorySync(error, 'fsync'))
172
+ throw error;
173
+ }
174
+ }
175
+ finally {
176
+ closeSync(descriptor);
177
+ }
178
+ }
179
+ function directoryChainToRoot(directory, root) {
180
+ const resolvedRoot = path.resolve(root);
181
+ let cursor = path.resolve(directory);
182
+ if (cursor !== resolvedRoot && !cursor.startsWith(`${resolvedRoot}${path.sep}`)) {
183
+ throw new Error(`Cannot sync transaction directory outside its root: ${directory}`);
184
+ }
185
+ const directories = [];
186
+ while (true) {
187
+ directories.push(cursor);
188
+ if (cursor === resolvedRoot)
189
+ return directories;
190
+ cursor = path.dirname(cursor);
191
+ }
192
+ }
193
+ function syncDirectories(directories) {
194
+ const seen = new Set();
195
+ for (const directory of directories) {
196
+ const resolved = path.resolve(directory);
197
+ if (seen.has(resolved))
198
+ continue;
199
+ seen.add(resolved);
200
+ syncDirectory(resolved);
201
+ }
202
+ }
203
+ function syncEntryRecursively(target) {
204
+ const stat = lstatSync(target);
205
+ if (stat.isDirectory()) {
206
+ for (const entry of readdirSync(target))
207
+ syncEntryRecursively(path.join(target, entry));
208
+ syncDirectory(target);
209
+ return;
210
+ }
211
+ if (!stat.isFile())
212
+ return;
213
+ // Windows FlushFileBuffers requires a handle opened with write access. The
214
+ // staged file is owned by this transaction, so opening it read/write retains
215
+ // the durability guarantee instead of treating EPERM as an unsupported sync.
216
+ const descriptor = openSync(target, process.platform === 'win32' ? 'r+' : 'r');
217
+ try {
218
+ fsyncSync(descriptor);
219
+ }
220
+ finally {
221
+ closeSync(descriptor);
222
+ }
223
+ }
224
+ function renameDurably(source, target, root) {
225
+ renameSync(source, target);
226
+ const targetDirectories = directoryChainToRoot(path.dirname(target), root);
227
+ const sourceParent = path.dirname(source);
228
+ syncDirectories([...targetDirectories, sourceParent]);
229
+ }
230
+ function removeEntryDurably(target) {
231
+ rmSync(target, { recursive: true, force: true });
232
+ const parent = path.dirname(target);
233
+ if (pathEntryExists(parent))
234
+ syncDirectory(parent);
235
+ }
236
+ function parseLockOwner(lockPath) {
237
+ if (lstatSync(lockPath).isSymbolicLink()) {
238
+ throw new Error(`Refusing symbolic-link transaction lock at ${lockPath}.`);
239
+ }
240
+ let value;
241
+ try {
242
+ value = JSON.parse(readFileSync(lockPath, 'utf8'));
243
+ }
244
+ catch (error) {
245
+ throw new Error(`Cannot read transaction lock owner at ${lockPath}; remove it only after confirming no Anhedral process is running.`, { cause: error });
246
+ }
247
+ if (!isRecord(value) ||
248
+ value.version !== 1 ||
249
+ !Number.isSafeInteger(value.pid) ||
250
+ value.pid <= 0 ||
251
+ typeof value.hostname !== 'string' ||
252
+ typeof value.token !== 'string' ||
253
+ typeof value.createdAt !== 'string') {
254
+ throw new Error(`Invalid transaction lock owner at ${lockPath}; remove it only after confirming no Anhedral process is running.`);
255
+ }
256
+ return value;
257
+ }
258
+ function readLockOwnerIfPresent(lockPath) {
259
+ try {
260
+ return parseLockOwner(lockPath);
261
+ }
262
+ catch (error) {
263
+ const candidate = error;
264
+ if (candidate.code === 'ENOENT' || candidate.cause?.code === 'ENOENT')
265
+ return null;
266
+ throw error;
267
+ }
268
+ }
269
+ function isProcessAlive(pid) {
270
+ try {
271
+ process.kill(pid, 0);
272
+ return true;
273
+ }
274
+ catch (error) {
275
+ const code = error.code;
276
+ if (code === 'ESRCH')
277
+ return false;
278
+ if (code === 'EPERM')
279
+ return true;
280
+ throw error;
281
+ }
282
+ }
283
+ function acquireTransactionLock(root) {
284
+ const rootStat = lstatIfPresent(root);
285
+ if (!rootStat)
286
+ throw new Error(`Transaction root is not a directory: ${root}`);
287
+ if (rootStat.isSymbolicLink()) {
288
+ throw new Error(`Refusing transaction root that is a symbolic link: ${root}`);
289
+ }
290
+ if (!rootStat.isDirectory())
291
+ throw new Error(`Transaction root is not a directory: ${root}`);
292
+ const lockPath = path.join(root, LOCK_FILE);
293
+ const reclaimPath = `${lockPath}.reclaim`;
294
+ const owner = {
295
+ version: 1,
296
+ pid: process.pid,
297
+ hostname: os.hostname(),
298
+ token: `${process.pid}-${randomUUID()}`,
299
+ createdAt: new Date().toISOString(),
300
+ };
301
+ const temporaryPath = `${lockPath}.${owner.token}.tmp`;
302
+ writeFileSync(temporaryPath, JSON.stringify(owner) + '\n', { encoding: 'utf8', flag: 'wx', mode: 0o600 });
303
+ try {
304
+ 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.`);
319
+ }
320
+ try {
321
+ 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
+ }
350
+ return { lockPath, owner };
351
+ }
352
+ catch (error) {
353
+ if (error.code !== 'EEXIST')
354
+ throw error;
355
+ }
356
+ const existing = readLockOwnerIfPresent(lockPath);
357
+ if (!existing) {
358
+ 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
+ }
402
+ }
403
+ finally {
404
+ rmSync(temporaryPath, { force: true });
405
+ }
406
+ throw new Error(`Could not acquire Anhedral transaction lock (${lockPath}).`);
407
+ }
408
+ function releaseTransactionLock(lock) {
409
+ if (!pathEntryExists(lock.lockPath))
410
+ return;
411
+ const current = parseLockOwner(lock.lockPath);
412
+ if (current.token === lock.owner.token)
413
+ unlinkSync(lock.lockPath);
414
+ }
415
+ function assertExpectedTempRoot(root, candidate, kind) {
416
+ const resolved = path.resolve(candidate);
417
+ const tokenPattern = '(\\d+-[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})';
418
+ const localParent = path.join(path.resolve(root), TRANSACTION_DIRECTORY);
419
+ const localMatch = new RegExp(`^${kind}-${tokenPattern}$`, 'i').exec(path.basename(resolved));
420
+ if (path.dirname(resolved) === localParent && localMatch) {
421
+ const namespaceStat = lstatIfPresent(localParent);
422
+ if (namespaceStat && (namespaceStat.isSymbolicLink() || !namespaceStat.isDirectory())) {
423
+ throw new Error(`Unsafe reserved transaction namespace: ${localParent}`);
424
+ }
425
+ return { token: localMatch[1] };
426
+ }
427
+ throw new Error(`Unsafe transaction ${kind} root: ${candidate}`);
428
+ }
429
+ function assertNoSymlinkComponents(root, relativePath) {
430
+ resolveTransactionPath(root, relativePath);
431
+ let cursor = path.resolve(root);
432
+ if (lstatIfPresent(cursor)?.isSymbolicLink()) {
433
+ throw new Error(`Refusing transaction root that is a symbolic link: ${root}`);
434
+ }
435
+ for (const segment of relativePath.split('/')) {
436
+ cursor = path.join(cursor, segment);
437
+ if (lstatIfPresent(cursor)?.isSymbolicLink()) {
438
+ throw new Error(`Refusing transaction path containing a symbolic link: ${relativePath}`);
439
+ }
440
+ }
441
+ }
442
+ function assertNoSymlinksRecursively(target) {
443
+ const stat = lstatSync(target);
444
+ if (stat.isSymbolicLink())
445
+ throw new Error(`Refusing symbolic link in staged output: ${target}`);
446
+ if (!stat.isDirectory())
447
+ return;
448
+ for (const entry of readdirSync(target))
449
+ assertNoSymlinksRecursively(path.join(target, entry));
450
+ }
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');
461
+ if (backupIdentity.token !== stageIdentity.token) {
462
+ throw new Error('Transaction stage and backup roots do not share one token.');
463
+ }
464
+ for (const temporaryRoot of [value.backupRoot, value.stageRoot]) {
465
+ const stat = lstatIfPresent(temporaryRoot);
466
+ if (stat) {
467
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
468
+ throw new Error(`Unsafe transaction temporary root: ${temporaryRoot}`);
469
+ }
470
+ }
471
+ }
472
+ const seen = new Set();
473
+ const entries = value.entries.map((entry, index) => {
474
+ if (!isRecord(entry) || typeof entry.relativePath !== 'string' || typeof entry.installed !== 'boolean') {
475
+ throw new Error(`Invalid transaction journal entry at index ${index}`);
476
+ }
477
+ assertExactObjectKeys(entry, ['relativePath', 'backupPath', 'installed', 'installedFingerprint'], `transaction journal entry at index ${index}`);
478
+ assertRelativePath(entry.relativePath);
479
+ if (seen.has(entry.relativePath))
480
+ throw new Error(`Duplicate transaction journal path: ${entry.relativePath}`);
481
+ seen.add(entry.relativePath);
482
+ if (entry.backupPath !== null && typeof entry.backupPath !== 'string') {
483
+ throw new Error(`Invalid transaction backup path at index ${index}`);
484
+ }
485
+ const installedFingerprint = entry.installedFingerprint;
486
+ if (installedFingerprint !== null
487
+ && (typeof installedFingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(installedFingerprint))) {
488
+ throw new Error(`Invalid installed transaction fingerprint at index ${index}`);
489
+ }
490
+ if (entry.installed ? installedFingerprint === null : installedFingerprint !== null) {
491
+ throw new Error(`Transaction journal entry fingerprint state is inconsistent at index ${index}`);
492
+ }
493
+ const expectedBackup = resolveTransactionPath(value.backupRoot, entry.relativePath);
494
+ if (entry.backupPath !== null && path.resolve(entry.backupPath) !== path.resolve(expectedBackup)) {
495
+ throw new Error(`Unsafe transaction backup path: ${entry.backupPath}`);
496
+ }
497
+ return {
498
+ relativePath: entry.relativePath,
499
+ backupPath: entry.backupPath,
500
+ installed: entry.installed,
501
+ installedFingerprint,
502
+ };
503
+ });
504
+ assertNoPathAncestry(entries.map((entry) => entry.relativePath), 'journal paths');
505
+ const rawCreatedDirectories = value.createdDirectories;
506
+ if (!Array.isArray(rawCreatedDirectories)) {
507
+ throw new Error(`Invalid transaction created-directory inventory at ${path.join(root, JOURNAL_FILE)}`);
508
+ }
509
+ const createdDirectories = rawCreatedDirectories.map((entry, index) => {
510
+ if (typeof entry !== 'string')
511
+ throw new Error(`Invalid transaction created directory at index ${index}`);
512
+ assertRelativePath(entry);
513
+ return entry;
514
+ });
515
+ if (new Set(createdDirectories).size !== createdDirectories.length) {
516
+ throw new Error('Duplicate transaction created-directory path.');
517
+ }
518
+ for (const directory of createdDirectories) {
519
+ if (!entries.some((entry) => entry.relativePath.startsWith(`${directory}/`))) {
520
+ throw new Error(`Transaction created directory is not a parent of any journal entry: ${directory}`);
521
+ }
522
+ }
523
+ return { version: JOURNAL_VERSION, backupRoot: value.backupRoot, stageRoot: value.stageRoot, entries, createdDirectories };
524
+ }
525
+ function writeJournal(root, journal) {
526
+ const journalPath = path.join(root, JOURNAL_FILE);
527
+ const temporaryPath = `${journalPath}.tmp-${process.pid}-${randomUUID()}`;
528
+ let descriptor = null;
529
+ try {
530
+ descriptor = openSync(temporaryPath, 'wx', 0o600);
531
+ writeFileSync(descriptor, JSON.stringify(journal, null, 2) + '\n', { encoding: 'utf8' });
532
+ fsyncSync(descriptor);
533
+ const completedDescriptor = descriptor;
534
+ descriptor = null;
535
+ closeSync(completedDescriptor);
536
+ renameSync(temporaryPath, journalPath);
537
+ syncDirectory(path.dirname(journalPath));
538
+ }
539
+ finally {
540
+ if (descriptor !== null)
541
+ closeSync(descriptor);
542
+ rmSync(temporaryPath, { force: true });
543
+ }
544
+ }
545
+ function removeJournal(root) {
546
+ const journalPath = path.join(root, JOURNAL_FILE);
547
+ try {
548
+ unlinkSync(journalPath);
549
+ }
550
+ catch (error) {
551
+ if (error.code === 'ENOENT')
552
+ return;
553
+ throw error;
554
+ }
555
+ syncDirectory(path.dirname(journalPath));
556
+ }
557
+ function removeEmptyTransactionDirectory(root) {
558
+ const transactionDirectory = path.join(path.resolve(root), TRANSACTION_DIRECTORY);
559
+ try {
560
+ rmdirSync(transactionDirectory);
561
+ }
562
+ catch (error) {
563
+ const code = error.code;
564
+ if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(code ?? ''))
565
+ throw error;
566
+ }
567
+ }
568
+ function cleanupOrphanTransactionRoots(root) {
569
+ const transactionDirectory = path.join(path.resolve(root), TRANSACTION_DIRECTORY);
570
+ const namespaceStat = lstatIfPresent(transactionDirectory);
571
+ if (!namespaceStat)
572
+ return;
573
+ if (namespaceStat.isSymbolicLink() || !namespaceStat.isDirectory()) {
574
+ throw new Error(`Unsafe reserved transaction namespace requires manual inspection: ${transactionDirectory}`);
575
+ }
576
+ for (const entry of readdirSync(transactionDirectory)) {
577
+ if (!LOCAL_TRANSACTION_ROOT_PATTERN.test(entry)) {
578
+ throw new Error(`Unknown artifact in reserved transaction namespace requires manual inspection: ${path.join(transactionDirectory, entry)}`);
579
+ }
580
+ const candidate = path.join(transactionDirectory, entry);
581
+ const stat = lstatIfPresent(candidate);
582
+ if (!stat)
583
+ continue;
584
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
585
+ throw new Error(`Unsafe orphan transaction root requires manual inspection: ${candidate}`);
586
+ }
587
+ rmSync(candidate, { recursive: true });
588
+ }
589
+ removeEmptyTransactionDirectory(root);
590
+ }
591
+ function restoreJournal(root, journal) {
592
+ for (const entry of [...journal.entries].reverse()) {
593
+ assertNoSymlinkComponents(root, entry.relativePath);
594
+ 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
+ }
612
+ if (entry.backupPath) {
613
+ assertNoSymlinkComponents(journal.backupRoot, entry.relativePath);
614
+ if (pathEntryExists(entry.backupPath)) {
615
+ mkdirSync(path.dirname(target), { recursive: true });
616
+ renameDurably(entry.backupPath, target, root);
617
+ }
618
+ else if (!pathEntryExists(target)) {
619
+ throw new Error(`Cannot recover missing transaction backup: ${entry.backupPath}`);
620
+ }
621
+ entry.backupPath = null;
622
+ writeJournal(root, journal);
623
+ }
624
+ }
625
+ for (const relativePath of [...journal.createdDirectories]
626
+ .sort((left, right) => right.split('/').length - left.split('/').length || right.localeCompare(left))) {
627
+ assertNoSymlinkComponents(root, relativePath);
628
+ const target = resolveTransactionPath(root, relativePath);
629
+ try {
630
+ rmdirSync(target);
631
+ syncDirectory(path.dirname(target));
632
+ }
633
+ catch (error) {
634
+ const code = error.code;
635
+ if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(code ?? ''))
636
+ throw error;
637
+ }
638
+ }
639
+ removeJournal(root);
640
+ rmSync(journal.stageRoot, { recursive: true, force: true });
641
+ rmSync(journal.backupRoot, { recursive: true, force: true });
642
+ removeEmptyTransactionDirectory(root);
643
+ }
644
+ function recoverInterruptedTransactionUnlocked(root) {
645
+ const journalPath = path.join(root, JOURNAL_FILE);
646
+ if (!pathEntryExists(journalPath))
647
+ return false;
648
+ if (lstatSync(journalPath).isSymbolicLink()) {
649
+ throw new Error(`Refusing symbolic-link transaction journal at ${journalPath}.`);
650
+ }
651
+ const journal = validateJournal(root, JSON.parse(readFileSync(journalPath, 'utf8')));
652
+ restoreJournal(root, journal);
653
+ return true;
654
+ }
655
+ /** Recover an interrupted commit while holding the same lock used by normal transactions. */
656
+ export function recoverInterruptedTransaction(root) {
657
+ const lock = acquireTransactionLock(root);
658
+ try {
659
+ const recovered = recoverInterruptedTransactionUnlocked(root);
660
+ cleanupOrphanTransactionRoots(root);
661
+ return recovered;
662
+ }
663
+ finally {
664
+ releaseTransactionLock(lock);
665
+ }
666
+ }
667
+ /** The filesystem commit succeeded, but a follow-up action such as install failed. */
668
+ export class PostCommitError extends Error {
669
+ committedPaths;
670
+ constructor(committedPaths, cause) {
671
+ const causeMessage = cause instanceof Error ? cause.message : String(cause);
672
+ super(`Changes were committed successfully, but the post-commit action failed: ${causeMessage}. `
673
+ + 'The generated files were kept; resolve the failure and rerun the post-commit command.', { cause });
674
+ this.name = 'PostCommitError';
675
+ this.committedPaths = Object.freeze([...committedPaths]);
676
+ }
677
+ }
678
+ /**
679
+ * Build changes away from the destination and install only the declared paths.
680
+ * Every replaced path is journaled first, so errors and later invocations can
681
+ * restore the original workspace.
682
+ */
683
+ export async function runStagedTransaction(root, options) {
684
+ const lock = acquireTransactionLock(root);
685
+ const token = `${process.pid}-${randomUUID()}`;
686
+ const transactionDirectory = path.join(path.resolve(root), TRANSACTION_DIRECTORY);
687
+ const stageRoot = path.join(transactionDirectory, `stage-${token}`);
688
+ const backupRoot = path.join(transactionDirectory, `backup-${token}`);
689
+ try {
690
+ if (options.dryRun && pathEntryExists(path.join(root, JOURNAL_FILE))) {
691
+ throw new Error('Dry-run cannot recover an interrupted transaction. Re-run the original command without --dry-run first.');
692
+ }
693
+ recoverInterruptedTransactionUnlocked(root);
694
+ if (!options.dryRun)
695
+ cleanupOrphanTransactionRoots(root);
696
+ if (await options.prepare?.() === false)
697
+ 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
+ }
711
+ mkdirSync(stageRoot, { recursive: true, mode: 0o700 });
712
+ 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
+ }
724
+ await options.build(stageRoot);
725
+ const normalizedPaths = normalizeTransactionPaths(options.commitPaths, options.deletePaths ?? []);
726
+ const deletePaths = new Set(normalizedPaths.deletePaths);
727
+ const uniquePaths = normalizedPaths.uniquePaths;
728
+ options.onPlan?.(Object.freeze([...uniquePaths]));
729
+ if (options.dryRun)
730
+ return Object.freeze(uniquePaths);
731
+ const journal = {
732
+ version: JOURNAL_VERSION,
733
+ backupRoot,
734
+ stageRoot,
735
+ createdDirectories: [],
736
+ entries: uniquePaths.map((relativePath) => {
737
+ assertRelativePath(relativePath);
738
+ return { relativePath, backupPath: null, installed: false, installedFingerprint: null };
739
+ }),
740
+ };
741
+ const createdDirectorySet = new Set(journal.createdDirectories);
742
+ writeJournal(root, journal);
743
+ 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
+ }
793
+ }
794
+ catch (error) {
795
+ restoreJournal(root, journal);
796
+ throw error;
797
+ }
798
+ removeJournal(root);
799
+ rmSync(stageRoot, { recursive: true, force: true });
800
+ rmSync(backupRoot, { recursive: true, force: true });
801
+ removeEmptyTransactionDirectory(root);
802
+ try {
803
+ await options.afterCommit?.();
804
+ }
805
+ catch (error) {
806
+ throw new PostCommitError(uniquePaths, error);
807
+ }
808
+ return Object.freeze(uniquePaths);
809
+ }
810
+ finally {
811
+ try {
812
+ if (!pathEntryExists(path.join(root, JOURNAL_FILE))) {
813
+ rmSync(stageRoot, { recursive: true, force: true });
814
+ rmSync(backupRoot, { recursive: true, force: true });
815
+ removeEmptyTransactionDirectory(root);
816
+ }
817
+ }
818
+ finally {
819
+ releaseTransactionLock(lock);
820
+ }
821
+ }
822
+ }