expandai 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/README.md +42 -5
  2. package/build/dts/ApiClient.d.ts +128 -194
  3. package/build/dts/ApiClient.d.ts.map +1 -1
  4. package/build/dts/Auth/Domain.d.ts +95 -0
  5. package/build/dts/Auth/Domain.d.ts.map +1 -0
  6. package/build/dts/Auth/ManualAuthorization.d.ts +107 -0
  7. package/build/dts/Auth/ManualAuthorization.d.ts.map +1 -0
  8. package/build/dts/Auth/PendingInit.d.ts +35 -0
  9. package/build/dts/Auth/PendingInit.d.ts.map +1 -0
  10. package/build/dts/Auth/Pkce.d.ts +10 -0
  11. package/build/dts/Auth/Pkce.d.ts.map +1 -0
  12. package/build/dts/Auth/SetupGrantAuthorization.d.ts +34 -0
  13. package/build/dts/Auth/SetupGrantAuthorization.d.ts.map +1 -0
  14. package/build/dts/Authentication.d.ts +10 -9
  15. package/build/dts/Authentication.d.ts.map +1 -1
  16. package/build/dts/BrowserOAuth.d.ts +7 -7
  17. package/build/dts/BrowserOAuth.d.ts.map +1 -1
  18. package/build/dts/CommandPath.d.ts +2 -0
  19. package/build/dts/CommandPath.d.ts.map +1 -0
  20. package/build/dts/Commands/Fetch.d.ts.map +1 -1
  21. package/build/dts/Commands/Hook.d.ts +5 -5
  22. package/build/dts/Commands/Init.d.ts +55 -0
  23. package/build/dts/Commands/Init.d.ts.map +1 -0
  24. package/build/dts/Commands/Mcp.d.ts +6 -6
  25. package/build/dts/Commands/Search.d.ts.map +1 -1
  26. package/build/dts/Commands/Skill.d.ts +4 -4
  27. package/build/dts/Commands/Status.d.ts +27 -0
  28. package/build/dts/Commands/Status.d.ts.map +1 -0
  29. package/build/dts/Commands/Telemetry.d.ts +7 -0
  30. package/build/dts/Commands/Telemetry.d.ts.map +1 -0
  31. package/build/dts/Commands/Uninstall.d.ts +4 -4
  32. package/build/dts/Config.d.ts +108 -17
  33. package/build/dts/Config.d.ts.map +1 -1
  34. package/build/dts/Hook/SnapshotNote.d.ts +8 -4
  35. package/build/dts/Hook/SnapshotNote.d.ts.map +1 -1
  36. package/build/dts/Init/ClaudeAdapter.d.ts +61 -0
  37. package/build/dts/Init/ClaudeAdapter.d.ts.map +1 -0
  38. package/build/dts/Init/ClaudeProfile.d.ts +78 -0
  39. package/build/dts/Init/ClaudeProfile.d.ts.map +1 -0
  40. package/build/dts/Init/Domain.d.ts +139 -0
  41. package/build/dts/Init/Domain.d.ts.map +1 -0
  42. package/build/dts/Init/HarnessAdapter.d.ts +33 -0
  43. package/build/dts/Init/HarnessAdapter.d.ts.map +1 -0
  44. package/build/dts/Init/HarnessDetector.d.ts +19 -0
  45. package/build/dts/Init/HarnessDetector.d.ts.map +1 -0
  46. package/build/dts/Init/HarnessRegistry.d.ts +50 -0
  47. package/build/dts/Init/HarnessRegistry.d.ts.map +1 -0
  48. package/build/dts/Init/MutationTransaction.d.ts +96 -0
  49. package/build/dts/Init/MutationTransaction.d.ts.map +1 -0
  50. package/build/dts/Init/Service.d.ts +72 -0
  51. package/build/dts/Init/Service.d.ts.map +1 -0
  52. package/build/dts/Init/StandardAdapters.d.ts +42 -0
  53. package/build/dts/Init/StandardAdapters.d.ts.map +1 -0
  54. package/build/dts/Init/Status.d.ts +149 -0
  55. package/build/dts/Init/Status.d.ts.map +1 -0
  56. package/build/dts/Install/Claude/Hook.d.ts +85 -25
  57. package/build/dts/Install/Claude/Hook.d.ts.map +1 -1
  58. package/build/dts/Install/Claude/Mcp.d.ts +21 -18
  59. package/build/dts/Install/Claude/Mcp.d.ts.map +1 -1
  60. package/build/dts/Install/Claude/Skill.d.ts +14 -15
  61. package/build/dts/Install/Claude/Skill.d.ts.map +1 -1
  62. package/build/dts/Install/Codex/Mcp.d.ts +14 -8
  63. package/build/dts/Install/Codex/Mcp.d.ts.map +1 -1
  64. package/build/dts/Install/Codex/Skill.d.ts +4 -2
  65. package/build/dts/Install/Codex/Skill.d.ts.map +1 -1
  66. package/build/dts/Install/Cursor/Mcp.d.ts +13 -13
  67. package/build/dts/Install/Cursor/Mcp.d.ts.map +1 -1
  68. package/build/dts/Install/Cursor/Skill.d.ts +4 -2
  69. package/build/dts/Install/Cursor/Skill.d.ts.map +1 -1
  70. package/build/dts/Install/Domain.d.ts +35 -5
  71. package/build/dts/Install/Domain.d.ts.map +1 -1
  72. package/build/dts/Install/Hook.d.ts +4 -4
  73. package/build/dts/Install/Hook.d.ts.map +1 -1
  74. package/build/dts/Install/Mcp.d.ts +5 -5
  75. package/build/dts/Install/Mcp.d.ts.map +1 -1
  76. package/build/dts/Install/Opencode/Hook.d.ts +4 -3
  77. package/build/dts/Install/Opencode/Hook.d.ts.map +1 -1
  78. package/build/dts/Install/Opencode/Mcp.d.ts +11 -11
  79. package/build/dts/Install/Opencode/Mcp.d.ts.map +1 -1
  80. package/build/dts/Install/Opencode/Skill.d.ts +4 -2
  81. package/build/dts/Install/Opencode/Skill.d.ts.map +1 -1
  82. package/build/dts/Install/Skill.d.ts +3 -3
  83. package/build/dts/Install/Uninstall.d.ts +6 -6
  84. package/build/dts/Install/Uninstall.d.ts.map +1 -1
  85. package/build/dts/MarkdownAnsi.d.ts +29 -0
  86. package/build/dts/MarkdownAnsi.d.ts.map +1 -0
  87. package/build/dts/Mcp/ExpandDocs.d.ts +17 -5
  88. package/build/dts/Mcp/ExpandDocs.d.ts.map +1 -1
  89. package/build/dts/Mcp/Server.d.ts +1 -1
  90. package/build/dts/Mcp/Server.d.ts.map +1 -1
  91. package/build/dts/Mcp/tools/Citations.d.ts +100 -71
  92. package/build/dts/Mcp/tools/Citations.d.ts.map +1 -1
  93. package/build/dts/Mcp/tools/Fetch.d.ts +16 -15
  94. package/build/dts/Mcp/tools/Fetch.d.ts.map +1 -1
  95. package/build/dts/SecureFile.d.ts +23 -0
  96. package/build/dts/SecureFile.d.ts.map +1 -0
  97. package/build/dts/Snippets.d.ts +11 -12
  98. package/build/dts/Snippets.d.ts.map +1 -1
  99. package/build/dts/Telemetry.d.ts +30 -0
  100. package/build/dts/Telemetry.d.ts.map +1 -0
  101. package/build/dts/Version.d.ts +2 -0
  102. package/build/dts/Version.d.ts.map +1 -0
  103. package/build/dts/Xdg.d.ts +11 -0
  104. package/build/dts/Xdg.d.ts.map +1 -0
  105. package/build/esm/ApiClient.js +133 -10
  106. package/build/esm/ApiClient.js.map +1 -1
  107. package/build/esm/Auth/Domain.js +56 -0
  108. package/build/esm/Auth/Domain.js.map +1 -0
  109. package/build/esm/Auth/ManualAuthorization.js +253 -0
  110. package/build/esm/Auth/ManualAuthorization.js.map +1 -0
  111. package/build/esm/Auth/PendingInit.js +267 -0
  112. package/build/esm/Auth/PendingInit.js.map +1 -0
  113. package/build/esm/Auth/Pkce.js +23 -0
  114. package/build/esm/Auth/Pkce.js.map +1 -0
  115. package/build/esm/Auth/SetupGrantAuthorization.js +91 -0
  116. package/build/esm/Auth/SetupGrantAuthorization.js.map +1 -0
  117. package/build/esm/Authentication.js +38 -7
  118. package/build/esm/Authentication.js.map +1 -1
  119. package/build/esm/BrowserOAuth.js +7 -10
  120. package/build/esm/BrowserOAuth.js.map +1 -1
  121. package/build/esm/CommandPath.js +31 -0
  122. package/build/esm/CommandPath.js.map +1 -0
  123. package/build/esm/Commands/Fetch.js +30 -6
  124. package/build/esm/Commands/Fetch.js.map +1 -1
  125. package/build/esm/Commands/Init.js +371 -0
  126. package/build/esm/Commands/Init.js.map +1 -0
  127. package/build/esm/Commands/Search.js +6 -3
  128. package/build/esm/Commands/Search.js.map +1 -1
  129. package/build/esm/Commands/Status.js +186 -0
  130. package/build/esm/Commands/Status.js.map +1 -0
  131. package/build/esm/Commands/Telemetry.js +23 -0
  132. package/build/esm/Commands/Telemetry.js.map +1 -0
  133. package/build/esm/Config.js +502 -28
  134. package/build/esm/Config.js.map +1 -1
  135. package/build/esm/Hook/Claude.js +1 -1
  136. package/build/esm/Hook/Claude.js.map +1 -1
  137. package/build/esm/Hook/SnapshotNote.js +54 -7
  138. package/build/esm/Hook/SnapshotNote.js.map +1 -1
  139. package/build/esm/Init/ClaudeAdapter.js +137 -0
  140. package/build/esm/Init/ClaudeAdapter.js.map +1 -0
  141. package/build/esm/Init/ClaudeProfile.js +380 -0
  142. package/build/esm/Init/ClaudeProfile.js.map +1 -0
  143. package/build/esm/Init/Domain.js +118 -0
  144. package/build/esm/Init/Domain.js.map +1 -0
  145. package/build/esm/Init/HarnessAdapter.js +21 -0
  146. package/build/esm/Init/HarnessAdapter.js.map +1 -0
  147. package/build/esm/Init/HarnessDetector.js +65 -0
  148. package/build/esm/Init/HarnessDetector.js.map +1 -0
  149. package/build/esm/Init/HarnessRegistry.js +58 -0
  150. package/build/esm/Init/HarnessRegistry.js.map +1 -0
  151. package/build/esm/Init/MutationTransaction.js +494 -0
  152. package/build/esm/Init/MutationTransaction.js.map +1 -0
  153. package/build/esm/Init/Service.js +319 -0
  154. package/build/esm/Init/Service.js.map +1 -0
  155. package/build/esm/Init/StandardAdapters.js +116 -0
  156. package/build/esm/Init/StandardAdapters.js.map +1 -0
  157. package/build/esm/Init/Status.js +215 -0
  158. package/build/esm/Init/Status.js.map +1 -0
  159. package/build/esm/Install/Claude/Hook.js +182 -50
  160. package/build/esm/Install/Claude/Hook.js.map +1 -1
  161. package/build/esm/Install/Claude/Mcp.js +56 -26
  162. package/build/esm/Install/Claude/Mcp.js.map +1 -1
  163. package/build/esm/Install/Claude/Skill.js +49 -27
  164. package/build/esm/Install/Claude/Skill.js.map +1 -1
  165. package/build/esm/Install/Codex/Mcp.js +9 -4
  166. package/build/esm/Install/Codex/Mcp.js.map +1 -1
  167. package/build/esm/Install/Codex/Skill.js +4 -1
  168. package/build/esm/Install/Codex/Skill.js.map +1 -1
  169. package/build/esm/Install/Cursor/Mcp.js +7 -4
  170. package/build/esm/Install/Cursor/Mcp.js.map +1 -1
  171. package/build/esm/Install/Cursor/Skill.js +4 -1
  172. package/build/esm/Install/Cursor/Skill.js.map +1 -1
  173. package/build/esm/Install/Domain.js +60 -3
  174. package/build/esm/Install/Domain.js.map +1 -1
  175. package/build/esm/Install/Hook.js.map +1 -1
  176. package/build/esm/Install/Mcp.js +1 -1
  177. package/build/esm/Install/Mcp.js.map +1 -1
  178. package/build/esm/Install/Opencode/Hook.js +1 -1
  179. package/build/esm/Install/Opencode/Hook.js.map +1 -1
  180. package/build/esm/Install/Opencode/Mcp.js +6 -3
  181. package/build/esm/Install/Opencode/Mcp.js.map +1 -1
  182. package/build/esm/Install/Opencode/Skill.js +4 -1
  183. package/build/esm/Install/Opencode/Skill.js.map +1 -1
  184. package/build/esm/Install/Opencode/expandai-webfetch.js +27 -9
  185. package/build/esm/Install/Skill.js.map +1 -1
  186. package/build/esm/Install/Uninstall.js +2 -2
  187. package/build/esm/Install/Uninstall.js.map +1 -1
  188. package/build/esm/MarkdownAnsi.js +142 -0
  189. package/build/esm/MarkdownAnsi.js.map +1 -0
  190. package/build/esm/Mcp/ExpandDocs.js +44 -52
  191. package/build/esm/Mcp/ExpandDocs.js.map +1 -1
  192. package/build/esm/Mcp/RequestHeaders.js.map +1 -1
  193. package/build/esm/Mcp/Server.js +2 -1
  194. package/build/esm/Mcp/Server.js.map +1 -1
  195. package/build/esm/Mcp/tools/Citations.js +109 -8
  196. package/build/esm/Mcp/tools/Citations.js.map +1 -1
  197. package/build/esm/Mcp/tools/Fetch.js +36 -12
  198. package/build/esm/Mcp/tools/Fetch.js.map +1 -1
  199. package/build/esm/SecureFile.js +36 -0
  200. package/build/esm/SecureFile.js.map +1 -0
  201. package/build/esm/Snippets.js +12 -1
  202. package/build/esm/Snippets.js.map +1 -1
  203. package/build/esm/Telemetry.js +157 -0
  204. package/build/esm/Telemetry.js.map +1 -0
  205. package/build/esm/Version.js +4 -0
  206. package/build/esm/Version.js.map +1 -0
  207. package/build/esm/Xdg.js +15 -0
  208. package/build/esm/Xdg.js.map +1 -0
  209. package/build/esm/main.js +122 -14
  210. package/build/esm/main.js.map +1 -1
  211. package/build/skills/expandai/SKILL.md +26 -3
  212. package/package.json +4 -4
  213. package/src/ApiClient.ts +206 -13
  214. package/src/Auth/Domain.ts +63 -0
  215. package/src/Auth/ManualAuthorization.ts +296 -0
  216. package/src/Auth/PendingInit.ts +338 -0
  217. package/src/Auth/Pkce.ts +26 -0
  218. package/src/Auth/SetupGrantAuthorization.ts +116 -0
  219. package/src/Authentication.ts +49 -11
  220. package/src/BrowserOAuth.ts +10 -12
  221. package/src/CommandPath.ts +30 -0
  222. package/src/Commands/Fetch.ts +42 -11
  223. package/src/Commands/Init.ts +489 -0
  224. package/src/Commands/Search.ts +8 -3
  225. package/src/Commands/Status.ts +228 -0
  226. package/src/Commands/Telemetry.ts +42 -0
  227. package/src/Config.ts +695 -50
  228. package/src/Hook/Claude.ts +1 -1
  229. package/src/Hook/SnapshotNote.ts +55 -7
  230. package/src/Init/ClaudeAdapter.ts +191 -0
  231. package/src/Init/ClaudeProfile.ts +496 -0
  232. package/src/Init/Domain.ts +153 -0
  233. package/src/Init/HarnessAdapter.ts +35 -0
  234. package/src/Init/HarnessDetector.ts +77 -0
  235. package/src/Init/HarnessRegistry.ts +87 -0
  236. package/src/Init/MutationTransaction.ts +747 -0
  237. package/src/Init/Service.ts +385 -0
  238. package/src/Init/StandardAdapters.ts +141 -0
  239. package/src/Init/Status.ts +253 -0
  240. package/src/Install/Claude/Hook.ts +234 -81
  241. package/src/Install/Claude/Mcp.ts +80 -33
  242. package/src/Install/Claude/Skill.ts +70 -37
  243. package/src/Install/Codex/Mcp.ts +14 -9
  244. package/src/Install/Codex/Skill.ts +9 -5
  245. package/src/Install/Cursor/Mcp.ts +12 -9
  246. package/src/Install/Cursor/Skill.ts +9 -5
  247. package/src/Install/Domain.ts +77 -4
  248. package/src/Install/Hook.ts +3 -3
  249. package/src/Install/Mcp.ts +6 -6
  250. package/src/Install/Opencode/Hook.ts +5 -5
  251. package/src/Install/Opencode/Mcp.ts +11 -8
  252. package/src/Install/Opencode/Skill.ts +9 -5
  253. package/src/Install/Opencode/expandai-webfetch.js +27 -9
  254. package/src/Install/Skill.ts +3 -3
  255. package/src/Install/Uninstall.ts +3 -3
  256. package/src/MarkdownAnsi.ts +162 -0
  257. package/src/Mcp/ExpandDocs.ts +46 -55
  258. package/src/Mcp/Server.ts +2 -1
  259. package/src/Mcp/tools/Citations.ts +117 -8
  260. package/src/Mcp/tools/Fetch.ts +40 -14
  261. package/src/SecureFile.ts +70 -0
  262. package/src/Snippets.ts +19 -12
  263. package/src/Telemetry.ts +186 -0
  264. package/src/Version.ts +3 -0
  265. package/src/Xdg.ts +18 -0
  266. package/src/main.ts +171 -12
  267. package/build/dts/DeviceAuth.d.ts +0 -87
  268. package/build/dts/DeviceAuth.d.ts.map +0 -1
  269. package/build/esm/DeviceAuth.js +0 -127
  270. package/build/esm/DeviceAuth.js.map +0 -1
  271. package/src/DeviceAuth.ts +0 -193
@@ -0,0 +1,747 @@
1
+ import { FileSystem, Path, Error as PlatformError } from '@effect/platform'
2
+ import { NodeFileSystem, NodePath } from '@effect/platform-node'
3
+ import { Array, Cause, Chunk, Effect, Either, Exit, Inspectable, Option, Schema, Scope } from 'effect'
4
+
5
+ type MutationType = 'write' | 'remove' | 'mkdir'
6
+
7
+ interface MutationBase {
8
+ readonly type: MutationType
9
+ readonly path: string
10
+ readonly expected?: MutationExpectedState
11
+ readonly validate?: Effect.Effect<unknown, unknown>
12
+ }
13
+
14
+ export type MutationExpectedState =
15
+ | { readonly exists: false }
16
+ | {
17
+ readonly exists: true
18
+ readonly type: 'File'
19
+ readonly bytes: Uint8Array
20
+ readonly mode: number
21
+ }
22
+ | {
23
+ readonly exists: true
24
+ readonly type: 'Directory'
25
+ readonly mode: number
26
+ }
27
+
28
+ export interface WriteMutation extends MutationBase {
29
+ readonly type: 'write'
30
+ readonly bytes: Uint8Array
31
+ readonly mode?: number
32
+ }
33
+
34
+ export interface RemoveMutation extends MutationBase {
35
+ readonly type: 'remove'
36
+ }
37
+
38
+ export interface MakeDirectoryMutation extends MutationBase {
39
+ readonly type: 'mkdir'
40
+ readonly mode?: number
41
+ }
42
+
43
+ export type MutationOperation = WriteMutation | RemoveMutation | MakeDirectoryMutation
44
+
45
+ const MutationFailure = Schema.Struct({
46
+ path: Schema.String,
47
+ operation: Schema.Literal('write', 'remove', 'mkdir'),
48
+ error: Schema.Defect,
49
+ })
50
+
51
+ export type MutationFailure = typeof MutationFailure.Type
52
+
53
+ export class MutationSymlinkError extends Schema.TaggedError<MutationSymlinkError>()('MutationSymlinkError', {
54
+ path: Schema.String,
55
+ }) {
56
+ get message() {
57
+ return `Refusing to mutate through symbolic link ${this.path}`
58
+ }
59
+ }
60
+
61
+ class MutationPathChangedError extends Schema.TaggedError<MutationPathChangedError>()('MutationPathChangedError', {
62
+ path: Schema.String,
63
+ }) {
64
+ get message() {
65
+ return `Path lineage changed while the mutation transaction was using ${this.path}`
66
+ }
67
+ }
68
+
69
+ class MutationRollbackConflictError extends Schema.TaggedError<MutationRollbackConflictError>()(
70
+ 'MutationRollbackConflictError',
71
+ {
72
+ path: Schema.String,
73
+ },
74
+ ) {
75
+ get message() {
76
+ return `Rollback preserved concurrent state at ${this.path}`
77
+ }
78
+ }
79
+
80
+ export class MutationValidationError extends Schema.TaggedError<MutationValidationError>()(
81
+ 'MutationValidationError',
82
+ MutationFailure.fields,
83
+ ) {
84
+ get message() {
85
+ return `Mutation validation failed for ${this.path}: ${Inspectable.format(this.error)}`
86
+ }
87
+ }
88
+
89
+ export class MutationApplicationError extends Schema.TaggedError<MutationApplicationError>()(
90
+ 'MutationApplicationError',
91
+ {
92
+ ...MutationFailure.fields,
93
+ rollbackFailures: Schema.Array(MutationFailure),
94
+ },
95
+ ) {
96
+ get message() {
97
+ const rollbackSummary =
98
+ this.rollbackFailures.length === 0 ? '' : `; ${this.rollbackFailures.length} rollback operation(s) also failed`
99
+ return `Mutation failed for ${this.path}: ${Inspectable.format(this.error)}${rollbackSummary}`
100
+ }
101
+ }
102
+
103
+ export class MutationRollbackError extends Schema.TaggedError<MutationRollbackError>()('MutationRollbackError', {
104
+ rollbackFailures: Schema.Array(MutationFailure),
105
+ }) {
106
+ get message() {
107
+ return `${this.rollbackFailures.length} rollback operation(s) failed after a defect or interruption`
108
+ }
109
+ }
110
+
111
+ export type MutationTransactionError =
112
+ | MutationSymlinkError
113
+ | MutationValidationError
114
+ | MutationApplicationError
115
+ | MutationRollbackError
116
+
117
+ interface Snapshot {
118
+ readonly exists: boolean
119
+ readonly bytes?: Uint8Array
120
+ readonly dev?: number
121
+ readonly entries?: string[]
122
+ readonly ino?: number
123
+ readonly mode?: number
124
+ readonly realPath?: string
125
+ readonly type?: 'File' | 'Directory'
126
+ }
127
+
128
+ interface PathIdentity {
129
+ readonly path: string
130
+ readonly exists: boolean
131
+ readonly dev?: number
132
+ readonly ino?: number
133
+ readonly realPath?: string
134
+ readonly type?: FileSystem.File.Type
135
+ }
136
+
137
+ interface PlannedMutation {
138
+ readonly operation: MutationOperation
139
+ readonly path: string
140
+ readonly snapshot: Snapshot
141
+ }
142
+
143
+ const MODE_MAX = 0o7777
144
+
145
+ function pathLineage(pathService: Path.Path, target: string): string[] {
146
+ const absolute = pathService.resolve(target)
147
+ const root = pathService.parse(absolute).root
148
+
149
+ function collect(current: string): string[] {
150
+ if (current === root) return [root]
151
+ return [...collect(pathService.dirname(current)), current]
152
+ }
153
+
154
+ return collect(absolute)
155
+ }
156
+
157
+ function bytesEqual(left: Uint8Array | undefined, right: Uint8Array | undefined): boolean {
158
+ if (left === undefined || right === undefined) return left === right
159
+ if (left.byteLength !== right.byteLength) return false
160
+ return left.every((value, index) => value === right[index])
161
+ }
162
+
163
+ function entriesEqual(left: string[] | undefined, right: string[] | undefined): boolean {
164
+ if (left === undefined || right === undefined) return left === right
165
+ return left.length === right.length && left.every((value, index) => value === right[index])
166
+ }
167
+
168
+ function statesEqual(left: Snapshot, right: Snapshot): boolean {
169
+ return (
170
+ left.exists === right.exists &&
171
+ left.type === right.type &&
172
+ left.mode === right.mode &&
173
+ bytesEqual(left.bytes, right.bytes) &&
174
+ entriesEqual(left.entries, right.entries)
175
+ )
176
+ }
177
+
178
+ function ownershipEqual(left: Snapshot, right: Snapshot): boolean {
179
+ return (
180
+ statesEqual(left, right) &&
181
+ (!left.exists || (left.dev === right.dev && left.ino === right.ino && left.realPath === right.realPath))
182
+ )
183
+ }
184
+
185
+ function matchesExpectedState(actual: Snapshot, expected: MutationExpectedState): boolean {
186
+ if (actual.exists !== expected.exists) return false
187
+ if (!expected.exists) return true
188
+ if (actual.type !== expected.type || actual.mode !== expected.mode) return false
189
+ return expected.type === 'Directory' || bytesEqual(actual.bytes, expected.bytes)
190
+ }
191
+
192
+ function isErrnoCode(input: unknown, code: string): boolean {
193
+ return typeof input === 'object' && input !== null && 'code' in input && input.code === code
194
+ }
195
+
196
+ function identitiesEqual(left: ReadonlyArray<PathIdentity>, right: ReadonlyArray<PathIdentity>): boolean {
197
+ return (
198
+ left.length === right.length &&
199
+ left.every((entry, index) => {
200
+ const other = right[index]
201
+ return (
202
+ other !== undefined &&
203
+ entry.path === other.path &&
204
+ entry.exists === other.exists &&
205
+ entry.dev === other.dev &&
206
+ entry.ino === other.ino &&
207
+ entry.realPath === other.realPath &&
208
+ entry.type === other.type
209
+ )
210
+ })
211
+ )
212
+ }
213
+
214
+ function validationError(operation: MutationOperation, error: unknown): MutationValidationError {
215
+ return new MutationValidationError({ path: operation.path, operation: operation.type, error })
216
+ }
217
+
218
+ function invalidMutation(operation: MutationOperation, message: string): MutationValidationError {
219
+ return validationError(operation, new Error(message))
220
+ }
221
+
222
+ function applicationError(operation: MutationOperation, error: unknown): MutationApplicationError {
223
+ return new MutationApplicationError({
224
+ path: operation.path,
225
+ operation: operation.type,
226
+ error,
227
+ rollbackFailures: [],
228
+ })
229
+ }
230
+
231
+ const makeMutationTransaction = Effect.gen(function* () {
232
+ const fileSystem = yield* FileSystem.FileSystem
233
+ const pathService = yield* Path.Path
234
+
235
+ const assertNotSymbolicLink = Effect.fnUntraced(function* (candidate: string) {
236
+ const result = yield* Effect.either(fileSystem.readLink(candidate))
237
+ if (Either.isRight(result)) return yield* new MutationSymlinkError({ path: candidate })
238
+ const error = result.left
239
+ if (
240
+ error instanceof PlatformError.SystemError &&
241
+ (error.reason === 'InvalidData' ||
242
+ error.reason === 'NotFound' ||
243
+ (error.method === 'readLink' && isErrnoCode(error.cause, 'EINVAL')))
244
+ ) {
245
+ return
246
+ }
247
+ return yield* error
248
+ })
249
+
250
+ const inspectLineage = Effect.fnUntraced(function* (target: string) {
251
+ return yield* Effect.forEach(pathLineage(pathService, target), (candidate) =>
252
+ Effect.gen(function* () {
253
+ yield* assertNotSymbolicLink(candidate)
254
+ const exists = yield* fileSystem.exists(candidate)
255
+ if (!exists) {
256
+ const missing: PathIdentity = { path: candidate, exists: false }
257
+ return missing
258
+ }
259
+ const info = yield* fileSystem.stat(candidate)
260
+ const identity: PathIdentity = {
261
+ path: candidate,
262
+ exists: true,
263
+ dev: info.dev,
264
+ ino: Option.getOrUndefined(info.ino),
265
+ realPath: yield* fileSystem.realPath(candidate),
266
+ type: info.type,
267
+ }
268
+ return identity
269
+ }),
270
+ )
271
+ })
272
+
273
+ // @effect/platform and Node expose only path-based mutation APIs here: this
274
+ // closes detected lineage races but cannot eliminate the final syscall TOCTOU
275
+ // window without non-portable openat/renameat/unlinkat-style primitives.
276
+ const withStableLineage = Effect.fnUntraced(function* <A, E, R>(target: string, action: Effect.Effect<A, E, R>) {
277
+ const before = yield* inspectLineage(target)
278
+ const actionExit = yield* Effect.exit(action)
279
+ const afterExit = yield* Effect.exit(inspectLineage(target))
280
+ if (Exit.isFailure(afterExit)) {
281
+ return yield* Exit.isFailure(actionExit)
282
+ ? Effect.failCause(Cause.sequential(actionExit.cause, afterExit.cause))
283
+ : Effect.failCause(afterExit.cause)
284
+ }
285
+ if (!identitiesEqual(before, afterExit.value)) {
286
+ const changed = new MutationPathChangedError({ path: target })
287
+ return yield* Exit.isFailure(actionExit)
288
+ ? Effect.failCause(Cause.sequential(actionExit.cause, Cause.fail(changed)))
289
+ : changed
290
+ }
291
+ return yield* Exit.isFailure(actionExit) ? Effect.failCause(actionExit.cause) : Effect.succeed(actionExit.value)
292
+ })
293
+
294
+ const rawSnapshot = Effect.fnUntraced(function* (target: string, includeDirectoryEntries: boolean) {
295
+ const exists = yield* fileSystem.exists(target)
296
+ if (!exists) {
297
+ const absent: Snapshot = { exists: false }
298
+ return absent
299
+ }
300
+ const info = yield* fileSystem.stat(target)
301
+ if (info.type === 'File') {
302
+ const file: Snapshot = {
303
+ exists: true,
304
+ bytes: yield* fileSystem.readFile(target),
305
+ dev: info.dev,
306
+ ino: Option.getOrUndefined(info.ino),
307
+ mode: info.mode & MODE_MAX,
308
+ realPath: yield* fileSystem.realPath(target),
309
+ type: 'File',
310
+ }
311
+ return file
312
+ }
313
+ if (info.type === 'Directory') {
314
+ const entries = includeDirectoryEntries ? [...(yield* fileSystem.readDirectory(target))].sort() : undefined
315
+ const directory: Snapshot = {
316
+ exists: true,
317
+ dev: info.dev,
318
+ ...(entries === undefined ? {} : { entries }),
319
+ ino: Option.getOrUndefined(info.ino),
320
+ mode: info.mode & MODE_MAX,
321
+ realPath: yield* fileSystem.realPath(target),
322
+ type: 'Directory',
323
+ }
324
+ return directory
325
+ }
326
+ return yield* new MutationPathChangedError({ path: target })
327
+ })
328
+
329
+ const snapshot = Effect.fnUntraced(function* (target: string, includeDirectoryEntries: boolean = true) {
330
+ return yield* withStableLineage(
331
+ target,
332
+ rawSnapshot(target, includeDirectoryEntries).pipe(
333
+ Effect.flatMap((first) =>
334
+ rawSnapshot(target, includeDirectoryEntries).pipe(
335
+ Effect.flatMap((second) =>
336
+ statesEqual(first, second) ? Effect.succeed(second) : new MutationPathChangedError({ path: target }),
337
+ ),
338
+ ),
339
+ ),
340
+ ),
341
+ )
342
+ })
343
+
344
+ const validateMode = Effect.fnUntraced(function* (operation: MutationOperation) {
345
+ if ('mode' in operation && operation.mode !== undefined) {
346
+ const valid = Number.isInteger(operation.mode) && operation.mode >= 0 && operation.mode <= MODE_MAX
347
+ if (!valid) return yield* invalidMutation(operation, `Invalid file mode ${operation.mode}`)
348
+ }
349
+ })
350
+
351
+ const validateOperations = Effect.fnUntraced(function* (operations: ReadonlyArray<MutationOperation>) {
352
+ const targets = new Set<string>()
353
+ const plannedDirectories = new Set<string>()
354
+ return yield* Effect.forEach(operations, (operation) => {
355
+ const absolutePath = pathService.resolve(operation.path)
356
+ return Effect.gen(function* () {
357
+ if (targets.has(absolutePath)) {
358
+ return yield* invalidMutation(operation, `Multiple mutations target ${absolutePath}`)
359
+ }
360
+ targets.add(absolutePath)
361
+ yield* validateMode(operation)
362
+
363
+ const lineage = yield* inspectLineage(absolutePath)
364
+ yield* Effect.forEach(
365
+ lineage,
366
+ (identity, index) => {
367
+ const isTarget = index === lineage.length - 1
368
+ if (!identity.exists) {
369
+ const permitted = operation.type === 'mkdir' || isTarget || plannedDirectories.has(identity.path)
370
+ return permitted
371
+ ? Effect.void
372
+ : invalidMutation(operation, `Parent directory does not exist: ${identity.path}`)
373
+ }
374
+ if (!isTarget && identity.type !== 'Directory') {
375
+ return invalidMutation(operation, `Parent path is not a directory: ${identity.path}`)
376
+ }
377
+ if (isTarget && operation.type === 'mkdir' && identity.type !== 'Directory') {
378
+ return invalidMutation(operation, `Directory mutation targets ${identity.type}: ${identity.path}`)
379
+ }
380
+ if (isTarget && operation.type !== 'mkdir' && identity.type !== 'File') {
381
+ return invalidMutation(operation, `File mutation targets ${identity.type}: ${identity.path}`)
382
+ }
383
+ return Effect.void
384
+ },
385
+ { discard: true },
386
+ )
387
+
388
+ if (operation.validate !== undefined) yield* operation.validate
389
+ if (operation.type === 'mkdir') {
390
+ Array.forEach(pathLineage(pathService, absolutePath), (candidate) => plannedDirectories.add(candidate))
391
+ }
392
+ return { operation, absolutePath } as const
393
+ }).pipe(
394
+ Effect.mapError((error) =>
395
+ error instanceof MutationValidationError || error instanceof MutationSymlinkError
396
+ ? error
397
+ : validationError(operation, error),
398
+ ),
399
+ )
400
+ })
401
+ })
402
+
403
+ /**
404
+ * Scoped per call, not per transaction. The staging container lives inside the
405
+ * target's own parent directory, so while it exists it is an entry in that
406
+ * directory — and `statesEqual` compares directory entries. Holding the
407
+ * finalizer until the end of `run` therefore made rollback fail on exactly the
408
+ * case it exists for: a write into a directory this transaction created, whose
409
+ * restore compares the parent against a snapshot taken before the container
410
+ * appeared, sees one extra entry, and aborts with a rollback conflict.
411
+ */
412
+ const writeAtomically = Effect.fnUntraced(function* (
413
+ target: string,
414
+ contents: Uint8Array,
415
+ expectedCurrent: Snapshot,
416
+ mode?: number,
417
+ onInstalled?: (installed: Snapshot) => Effect.Effect<void>,
418
+ ) {
419
+ const parent = pathService.dirname(target)
420
+ const temporary = yield* Effect.acquireRelease(
421
+ withStableLineage(parent, fileSystem.makeTempFile({ directory: parent, prefix: '.expandai-init-' })),
422
+ // `makeTempFile` nests the file inside its own `.expandai-init-*`
423
+ // directory, so removing just the file left that directory behind on
424
+ // every atomic write — they accumulated in the user's home and survived
425
+ // `uninstall`. Remove the container, never the target's own parent.
426
+ (temporary) => {
427
+ const container = pathService.dirname(temporary)
428
+ const stray = pathService.resolve(container) !== pathService.resolve(parent)
429
+ return withStableLineage(
430
+ parent,
431
+ stray
432
+ ? fileSystem.remove(container, { force: true, recursive: true })
433
+ : fileSystem.remove(temporary, { force: true }),
434
+ ).pipe(Effect.ignore)
435
+ },
436
+ )
437
+ yield* withStableLineage(temporary, fileSystem.writeFile(temporary, contents))
438
+ if (mode !== undefined) yield* withStableLineage(temporary, fileSystem.chmod(temporary, mode))
439
+ const temporarySnapshot = yield* snapshot(temporary)
440
+ const expectedInstalled: Snapshot = { ...temporarySnapshot, realPath: pathService.resolve(target) }
441
+ yield* withStableLineage(
442
+ parent,
443
+ Effect.gen(function* () {
444
+ const current = yield* snapshot(target)
445
+ if (!ownershipEqual(current, expectedCurrent)) {
446
+ return yield* new MutationPathChangedError({ path: target })
447
+ }
448
+ // Install and register-the-undo, atomically. Everything after this point
449
+ // — including the post-install verification below — can fail freely:
450
+ // the undo is already on the scope, so rollback knows about the change.
451
+ // This is the only mask left, and it is two statements wide.
452
+ yield* Effect.uninterruptible(
453
+ fileSystem
454
+ .rename(temporary, target)
455
+ .pipe(Effect.zipRight(onInstalled === undefined ? Effect.void : onInstalled(expectedInstalled))),
456
+ )
457
+ }),
458
+ )
459
+ const installed = yield* snapshot(target)
460
+ if (!ownershipEqual(installed, expectedInstalled)) return yield* new MutationPathChangedError({ path: target })
461
+ return installed
462
+ }, Effect.scoped)
463
+
464
+ const removeSafely = Effect.fnUntraced(function* (
465
+ target: string,
466
+ expectedCurrent: Snapshot,
467
+ onInstalled?: (installed: Snapshot) => Effect.Effect<void>,
468
+ ) {
469
+ const installed: Snapshot = { exists: false }
470
+ const remove =
471
+ expectedCurrent.type === 'Directory' ? fileSystem.remove(target, { recursive: true }) : fileSystem.remove(target)
472
+ yield* withStableLineage(
473
+ pathService.dirname(target),
474
+ Effect.gen(function* () {
475
+ const current = yield* snapshot(target)
476
+ if (!ownershipEqual(current, expectedCurrent)) {
477
+ return yield* new MutationPathChangedError({ path: target })
478
+ }
479
+ yield* Effect.uninterruptible(
480
+ remove.pipe(Effect.zipRight(onInstalled === undefined ? Effect.void : onInstalled(installed))),
481
+ )
482
+ }),
483
+ )
484
+ const verified = yield* snapshot(target)
485
+ if (!statesEqual(verified, installed)) return yield* new MutationPathChangedError({ path: target })
486
+ return installed
487
+ })
488
+
489
+ const makeDirectoryAtomically = Effect.fnUntraced(function* (
490
+ target: string,
491
+ expectedCurrent: Snapshot,
492
+ mode?: number,
493
+ onInstalled?: (installed: Snapshot) => Effect.Effect<void>,
494
+ ) {
495
+ const parent = pathService.dirname(target)
496
+ const temporary = yield* Effect.acquireRelease(
497
+ withStableLineage(
498
+ parent,
499
+ fileSystem.makeTempDirectory({ directory: parent, prefix: '.expandai-init-directory-' }),
500
+ ),
501
+ (temporary) =>
502
+ withStableLineage(parent, fileSystem.remove(temporary, { force: true, recursive: true })).pipe(Effect.ignore),
503
+ )
504
+ if (mode !== undefined) yield* withStableLineage(temporary, fileSystem.chmod(temporary, mode))
505
+ const temporarySnapshot = yield* snapshot(temporary)
506
+ const expectedInstalled: Snapshot = { ...temporarySnapshot, realPath: pathService.resolve(target) }
507
+ yield* withStableLineage(
508
+ parent,
509
+ Effect.gen(function* () {
510
+ const current = yield* snapshot(target)
511
+ if (!ownershipEqual(current, expectedCurrent)) {
512
+ return yield* new MutationPathChangedError({ path: target })
513
+ }
514
+ // Install and register-the-undo, atomically. Everything after this point
515
+ // — including the post-install verification below — can fail freely:
516
+ // the undo is already on the scope, so rollback knows about the change.
517
+ // This is the only mask left, and it is two statements wide.
518
+ yield* Effect.uninterruptible(
519
+ fileSystem
520
+ .rename(temporary, target)
521
+ .pipe(Effect.zipRight(onInstalled === undefined ? Effect.void : onInstalled(expectedInstalled))),
522
+ )
523
+ }),
524
+ )
525
+ const installed = yield* snapshot(target)
526
+ if (!ownershipEqual(installed, expectedInstalled)) return yield* new MutationPathChangedError({ path: target })
527
+ return installed
528
+ }, Effect.scoped)
529
+
530
+ /**
531
+ * Each undo banks its own cause: `addFinalizer` requires `Effect<X, never, R>`,
532
+ * so a release cannot fail in the typed channel. `run` folds these into the
533
+ * transaction's failure once the scope has closed, preserving the reporting the
534
+ * old `attachRollbackFailures` path gave us.
535
+ */
536
+ const bankFailure = (
537
+ failures: MutationFailure[],
538
+ path: string,
539
+ operation: MutationType,
540
+ undo: Effect.Effect<unknown, unknown>,
541
+ ) =>
542
+ Effect.exit(undo).pipe(
543
+ Effect.flatMap((exit) =>
544
+ Exit.isSuccess(exit)
545
+ ? Effect.void
546
+ : Effect.sync(() => {
547
+ failures.push({ path, operation, error: exit.cause })
548
+ }).pipe(
549
+ // Also logged, not only banked. When the transaction is
550
+ // interrupted rather than failed, the fiber may never reach the
551
+ // bookkeeping that folds these into the returned cause — and
552
+ // "rollback was incomplete" is the one thing an operator must not
553
+ // lose. The typed cause still carries them on every other path.
554
+ Effect.zipRight(Effect.logError(`Rollback failed for ${path} (${operation})`, Cause.pretty(exit.cause))),
555
+ ),
556
+ ),
557
+ )
558
+
559
+ /**
560
+ * Confirms the change is still ours, applies `restore`, and confirms the result
561
+ * matches the state we started from.
562
+ *
563
+ * The *shape* of the restoration comes from the caller — the code that knows
564
+ * what it just did — not from inspecting `before` here. The previous version
565
+ * branched on `before.type` with no branch for a directory, so an unhandled
566
+ * prior state surfaced as a rollback *conflict*, blaming a concurrent writer
567
+ * for a case nobody had implemented.
568
+ */
569
+ const undoWith = Effect.fnUntraced(function* (
570
+ path: string,
571
+ installed: Snapshot,
572
+ before: Snapshot,
573
+ restore: (current: Snapshot) => Effect.Effect<unknown, unknown>,
574
+ ) {
575
+ const current = yield* snapshot(path)
576
+ if (!ownershipEqual(current, installed)) return yield* new MutationRollbackConflictError({ path })
577
+ yield* restore(current)
578
+ const restored = yield* snapshot(path)
579
+ if (!statesEqual(restored, before)) return yield* new MutationRollbackConflictError({ path })
580
+ })
581
+
582
+ /** Undo for something created where nothing was: remove it again. */
583
+ const undoToAbsent = (path: string, installed: Snapshot, before: Snapshot) =>
584
+ undoWith(path, installed, before, (current) => removeSafely(path, current))
585
+
586
+ /** Undo for something replaced or deleted: put the original bytes back. */
587
+ const undoToFile = (path: string, installed: Snapshot, before: Snapshot, bytes: Uint8Array) =>
588
+ undoWith(path, installed, before, (current) => writeAtomically(path, bytes, current, before.mode))
589
+
590
+ /**
591
+ * Applies one mutation, registering the effect that reverses it on `scope` at
592
+ * the instant it lands.
593
+ *
594
+ * The scope is passed explicitly rather than taken from the environment: an
595
+ * ambient scope of transaction lifetime would silently adopt any future
596
+ * unscoped `acquireRelease` in this file and hold its resource open for the
597
+ * whole transaction — the defect that once made rollback fail. Staging
598
+ * resources still scope to the single operation that creates them.
599
+ */
600
+ const applyOne = Effect.fnUntraced(function* (
601
+ planned: PlannedMutation,
602
+ scope: Scope.Scope,
603
+ failures: MutationFailure[],
604
+ ) {
605
+ const { operation, path, snapshot: before } = planned
606
+ const current = yield* snapshot(path)
607
+ if (!ownershipEqual(current, before)) return yield* new MutationPathChangedError({ path })
608
+
609
+ const register =
610
+ (undoPath: string, undo: (installed: Snapshot) => Effect.Effect<unknown, unknown>) => (installed: Snapshot) =>
611
+ Scope.addFinalizerExit(scope, (exit) =>
612
+ Exit.isSuccess(exit) ? Effect.void : bankFailure(failures, undoPath, operation.type, undo(installed)),
613
+ )
614
+
615
+ if (operation.type === 'write') {
616
+ const desiredMode = operation.mode ?? before.mode
617
+ if (before.type === 'File' && bytesEqual(before.bytes, operation.bytes) && before.mode === desiredMode) return
618
+ const original = before.exists ? before.bytes : undefined
619
+ yield* writeAtomically(
620
+ path,
621
+ operation.bytes,
622
+ before,
623
+ desiredMode,
624
+ register(path, (installed) =>
625
+ original === undefined
626
+ ? undoToAbsent(path, installed, before)
627
+ : undoToFile(path, installed, before, original),
628
+ ),
629
+ )
630
+ return
631
+ }
632
+ if (operation.type === 'remove') {
633
+ if (!before.exists) return
634
+ // Validation refuses a non-`File` target, so the bytes are present.
635
+ const original = before.bytes
636
+ if (original === undefined) return yield* new MutationPathChangedError({ path })
637
+ yield* removeSafely(
638
+ path,
639
+ before,
640
+ register(path, (installed) => undoToFile(path, installed, before, original)),
641
+ )
642
+ return
643
+ }
644
+
645
+ if (before.exists) return
646
+ yield* Effect.forEach(
647
+ pathLineage(pathService, path),
648
+ (candidate) =>
649
+ Effect.gen(function* () {
650
+ // Existing ancestors are owned by other processes. Their identity
651
+ // must stay stable, but unrelated sibling entries may legitimately churn.
652
+ const candidateBefore = yield* snapshot(candidate, false)
653
+ if (candidateBefore.exists) return
654
+ yield* makeDirectoryAtomically(
655
+ candidate,
656
+ candidateBefore,
657
+ operation.mode,
658
+ register(candidate, (installed) => undoToAbsent(candidate, installed, candidateBefore)),
659
+ )
660
+ }),
661
+ { discard: true },
662
+ )
663
+ })
664
+
665
+ const attachRollbackFailures = Effect.fnUntraced(function* (
666
+ cause: Cause.Cause<MutationTransactionError>,
667
+ rollbackFailures: ReadonlyArray<MutationFailure>,
668
+ ) {
669
+ if (rollbackFailures.length === 0) return cause
670
+ const mapped = Cause.map(cause, (error) =>
671
+ error instanceof MutationApplicationError
672
+ ? new MutationApplicationError({
673
+ path: error.path,
674
+ operation: error.operation,
675
+ error: error.error,
676
+ rollbackFailures: [...rollbackFailures],
677
+ })
678
+ : error,
679
+ )
680
+ const hasApplicationError = Chunk.some(Cause.failures(cause), (error) => error instanceof MutationApplicationError)
681
+ return hasApplicationError
682
+ ? mapped
683
+ : Cause.sequential(mapped, Cause.fail(new MutationRollbackError({ rollbackFailures: [...rollbackFailures] })))
684
+ })
685
+
686
+ const run = Effect.fn('MutationTransaction.run')(function* (operations: ReadonlyArray<MutationOperation>) {
687
+ const validated = yield* validateOperations(operations)
688
+ const planned = yield* Effect.forEach(validated, ({ operation, absolutePath }) =>
689
+ Effect.gen(function* () {
690
+ const actual = yield* snapshot(absolutePath)
691
+ if (operation.expected !== undefined && !matchesExpectedState(actual, operation.expected)) {
692
+ return yield* invalidMutation(operation, 'Expected mutation state no longer matches')
693
+ }
694
+ return { operation, path: absolutePath, snapshot: actual } satisfies PlannedMutation
695
+ }).pipe(
696
+ Effect.mapError((error) =>
697
+ error instanceof MutationValidationError ? error : validationError(operation, error),
698
+ ),
699
+ ),
700
+ )
701
+
702
+ // Rollback is a scope, not a transaction-wide mask.
703
+ //
704
+ // Each installer registers its undo on `scope` in the same uninterruptible
705
+ // step that performs the change, so no change can exist on disk that rollback
706
+ // does not know about — including one whose post-install verification then
707
+ // fails. `Effect.onExit` is `uninterruptibleMask((restore) =>
708
+ // matchCauseEffect(restore(self), ...))` (`core.ts:1113`), so closing the
709
+ // scope runs every undo, in reverse order of registration
710
+ // (`fiberRuntime.ts:3256`), and cannot itself be interrupted. Both guarantees
711
+ // come from combinators rather than from this function remembering to restore
712
+ // correctly.
713
+ const scope = yield* Scope.make()
714
+ // Per run, not per service: two transactions on one `MutationTransaction`
715
+ // instance would otherwise share one array and cross-attribute — or clear —
716
+ // each other's rollback diagnostics.
717
+ const rollbackFailures: MutationFailure[] = []
718
+ const application = Effect.forEach(
719
+ planned,
720
+ (item) =>
721
+ applyOne(item, scope, rollbackFailures).pipe(
722
+ Effect.mapError((error) =>
723
+ error instanceof MutationApplicationError ? error : applicationError(item.operation, error),
724
+ ),
725
+ ),
726
+ { discard: true },
727
+ )
728
+
729
+ const exit = yield* Effect.exit(
730
+ application.pipe(Effect.onExit((applicationExit) => Scope.close(scope, applicationExit))),
731
+ )
732
+ if (Exit.isSuccess(exit)) return
733
+
734
+ const cause = yield* attachRollbackFailures(exit.cause, rollbackFailures)
735
+ return yield* Effect.failCause(cause)
736
+ })
737
+
738
+ return { run } as const
739
+ })
740
+
741
+ export class MutationTransaction extends Effect.Service<MutationTransaction>()(
742
+ '@expandai/cli/Init/MutationTransaction',
743
+ {
744
+ dependencies: [NodeFileSystem.layer, NodePath.layer],
745
+ effect: makeMutationTransaction,
746
+ },
747
+ ) {}