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.
- package/README.md +42 -5
- package/build/dts/ApiClient.d.ts +128 -194
- package/build/dts/ApiClient.d.ts.map +1 -1
- package/build/dts/Auth/Domain.d.ts +95 -0
- package/build/dts/Auth/Domain.d.ts.map +1 -0
- package/build/dts/Auth/ManualAuthorization.d.ts +107 -0
- package/build/dts/Auth/ManualAuthorization.d.ts.map +1 -0
- package/build/dts/Auth/PendingInit.d.ts +35 -0
- package/build/dts/Auth/PendingInit.d.ts.map +1 -0
- package/build/dts/Auth/Pkce.d.ts +10 -0
- package/build/dts/Auth/Pkce.d.ts.map +1 -0
- package/build/dts/Auth/SetupGrantAuthorization.d.ts +34 -0
- package/build/dts/Auth/SetupGrantAuthorization.d.ts.map +1 -0
- package/build/dts/Authentication.d.ts +10 -9
- package/build/dts/Authentication.d.ts.map +1 -1
- package/build/dts/BrowserOAuth.d.ts +7 -7
- package/build/dts/BrowserOAuth.d.ts.map +1 -1
- package/build/dts/CommandPath.d.ts +2 -0
- package/build/dts/CommandPath.d.ts.map +1 -0
- package/build/dts/Commands/Fetch.d.ts.map +1 -1
- package/build/dts/Commands/Hook.d.ts +5 -5
- package/build/dts/Commands/Init.d.ts +55 -0
- package/build/dts/Commands/Init.d.ts.map +1 -0
- package/build/dts/Commands/Mcp.d.ts +6 -6
- package/build/dts/Commands/Search.d.ts.map +1 -1
- package/build/dts/Commands/Skill.d.ts +4 -4
- package/build/dts/Commands/Status.d.ts +27 -0
- package/build/dts/Commands/Status.d.ts.map +1 -0
- package/build/dts/Commands/Telemetry.d.ts +7 -0
- package/build/dts/Commands/Telemetry.d.ts.map +1 -0
- package/build/dts/Commands/Uninstall.d.ts +4 -4
- package/build/dts/Config.d.ts +108 -17
- package/build/dts/Config.d.ts.map +1 -1
- package/build/dts/Hook/SnapshotNote.d.ts +8 -4
- package/build/dts/Hook/SnapshotNote.d.ts.map +1 -1
- package/build/dts/Init/ClaudeAdapter.d.ts +61 -0
- package/build/dts/Init/ClaudeAdapter.d.ts.map +1 -0
- package/build/dts/Init/ClaudeProfile.d.ts +78 -0
- package/build/dts/Init/ClaudeProfile.d.ts.map +1 -0
- package/build/dts/Init/Domain.d.ts +139 -0
- package/build/dts/Init/Domain.d.ts.map +1 -0
- package/build/dts/Init/HarnessAdapter.d.ts +33 -0
- package/build/dts/Init/HarnessAdapter.d.ts.map +1 -0
- package/build/dts/Init/HarnessDetector.d.ts +19 -0
- package/build/dts/Init/HarnessDetector.d.ts.map +1 -0
- package/build/dts/Init/HarnessRegistry.d.ts +50 -0
- package/build/dts/Init/HarnessRegistry.d.ts.map +1 -0
- package/build/dts/Init/MutationTransaction.d.ts +96 -0
- package/build/dts/Init/MutationTransaction.d.ts.map +1 -0
- package/build/dts/Init/Service.d.ts +72 -0
- package/build/dts/Init/Service.d.ts.map +1 -0
- package/build/dts/Init/StandardAdapters.d.ts +42 -0
- package/build/dts/Init/StandardAdapters.d.ts.map +1 -0
- package/build/dts/Init/Status.d.ts +149 -0
- package/build/dts/Init/Status.d.ts.map +1 -0
- package/build/dts/Install/Claude/Hook.d.ts +85 -25
- package/build/dts/Install/Claude/Hook.d.ts.map +1 -1
- package/build/dts/Install/Claude/Mcp.d.ts +21 -18
- package/build/dts/Install/Claude/Mcp.d.ts.map +1 -1
- package/build/dts/Install/Claude/Skill.d.ts +14 -15
- package/build/dts/Install/Claude/Skill.d.ts.map +1 -1
- package/build/dts/Install/Codex/Mcp.d.ts +14 -8
- package/build/dts/Install/Codex/Mcp.d.ts.map +1 -1
- package/build/dts/Install/Codex/Skill.d.ts +4 -2
- package/build/dts/Install/Codex/Skill.d.ts.map +1 -1
- package/build/dts/Install/Cursor/Mcp.d.ts +13 -13
- package/build/dts/Install/Cursor/Mcp.d.ts.map +1 -1
- package/build/dts/Install/Cursor/Skill.d.ts +4 -2
- package/build/dts/Install/Cursor/Skill.d.ts.map +1 -1
- package/build/dts/Install/Domain.d.ts +35 -5
- package/build/dts/Install/Domain.d.ts.map +1 -1
- package/build/dts/Install/Hook.d.ts +4 -4
- package/build/dts/Install/Hook.d.ts.map +1 -1
- package/build/dts/Install/Mcp.d.ts +5 -5
- package/build/dts/Install/Mcp.d.ts.map +1 -1
- package/build/dts/Install/Opencode/Hook.d.ts +4 -3
- package/build/dts/Install/Opencode/Hook.d.ts.map +1 -1
- package/build/dts/Install/Opencode/Mcp.d.ts +11 -11
- package/build/dts/Install/Opencode/Mcp.d.ts.map +1 -1
- package/build/dts/Install/Opencode/Skill.d.ts +4 -2
- package/build/dts/Install/Opencode/Skill.d.ts.map +1 -1
- package/build/dts/Install/Skill.d.ts +3 -3
- package/build/dts/Install/Uninstall.d.ts +6 -6
- package/build/dts/Install/Uninstall.d.ts.map +1 -1
- package/build/dts/MarkdownAnsi.d.ts +29 -0
- package/build/dts/MarkdownAnsi.d.ts.map +1 -0
- package/build/dts/Mcp/ExpandDocs.d.ts +17 -5
- package/build/dts/Mcp/ExpandDocs.d.ts.map +1 -1
- package/build/dts/Mcp/Server.d.ts +1 -1
- package/build/dts/Mcp/Server.d.ts.map +1 -1
- package/build/dts/Mcp/tools/Citations.d.ts +100 -71
- package/build/dts/Mcp/tools/Citations.d.ts.map +1 -1
- package/build/dts/Mcp/tools/Fetch.d.ts +16 -15
- package/build/dts/Mcp/tools/Fetch.d.ts.map +1 -1
- package/build/dts/SecureFile.d.ts +23 -0
- package/build/dts/SecureFile.d.ts.map +1 -0
- package/build/dts/Snippets.d.ts +11 -12
- package/build/dts/Snippets.d.ts.map +1 -1
- package/build/dts/Telemetry.d.ts +30 -0
- package/build/dts/Telemetry.d.ts.map +1 -0
- package/build/dts/Version.d.ts +2 -0
- package/build/dts/Version.d.ts.map +1 -0
- package/build/dts/Xdg.d.ts +11 -0
- package/build/dts/Xdg.d.ts.map +1 -0
- package/build/esm/ApiClient.js +133 -10
- package/build/esm/ApiClient.js.map +1 -1
- package/build/esm/Auth/Domain.js +56 -0
- package/build/esm/Auth/Domain.js.map +1 -0
- package/build/esm/Auth/ManualAuthorization.js +253 -0
- package/build/esm/Auth/ManualAuthorization.js.map +1 -0
- package/build/esm/Auth/PendingInit.js +267 -0
- package/build/esm/Auth/PendingInit.js.map +1 -0
- package/build/esm/Auth/Pkce.js +23 -0
- package/build/esm/Auth/Pkce.js.map +1 -0
- package/build/esm/Auth/SetupGrantAuthorization.js +91 -0
- package/build/esm/Auth/SetupGrantAuthorization.js.map +1 -0
- package/build/esm/Authentication.js +38 -7
- package/build/esm/Authentication.js.map +1 -1
- package/build/esm/BrowserOAuth.js +7 -10
- package/build/esm/BrowserOAuth.js.map +1 -1
- package/build/esm/CommandPath.js +31 -0
- package/build/esm/CommandPath.js.map +1 -0
- package/build/esm/Commands/Fetch.js +30 -6
- package/build/esm/Commands/Fetch.js.map +1 -1
- package/build/esm/Commands/Init.js +371 -0
- package/build/esm/Commands/Init.js.map +1 -0
- package/build/esm/Commands/Search.js +6 -3
- package/build/esm/Commands/Search.js.map +1 -1
- package/build/esm/Commands/Status.js +186 -0
- package/build/esm/Commands/Status.js.map +1 -0
- package/build/esm/Commands/Telemetry.js +23 -0
- package/build/esm/Commands/Telemetry.js.map +1 -0
- package/build/esm/Config.js +502 -28
- package/build/esm/Config.js.map +1 -1
- package/build/esm/Hook/Claude.js +1 -1
- package/build/esm/Hook/Claude.js.map +1 -1
- package/build/esm/Hook/SnapshotNote.js +54 -7
- package/build/esm/Hook/SnapshotNote.js.map +1 -1
- package/build/esm/Init/ClaudeAdapter.js +137 -0
- package/build/esm/Init/ClaudeAdapter.js.map +1 -0
- package/build/esm/Init/ClaudeProfile.js +380 -0
- package/build/esm/Init/ClaudeProfile.js.map +1 -0
- package/build/esm/Init/Domain.js +118 -0
- package/build/esm/Init/Domain.js.map +1 -0
- package/build/esm/Init/HarnessAdapter.js +21 -0
- package/build/esm/Init/HarnessAdapter.js.map +1 -0
- package/build/esm/Init/HarnessDetector.js +65 -0
- package/build/esm/Init/HarnessDetector.js.map +1 -0
- package/build/esm/Init/HarnessRegistry.js +58 -0
- package/build/esm/Init/HarnessRegistry.js.map +1 -0
- package/build/esm/Init/MutationTransaction.js +494 -0
- package/build/esm/Init/MutationTransaction.js.map +1 -0
- package/build/esm/Init/Service.js +319 -0
- package/build/esm/Init/Service.js.map +1 -0
- package/build/esm/Init/StandardAdapters.js +116 -0
- package/build/esm/Init/StandardAdapters.js.map +1 -0
- package/build/esm/Init/Status.js +215 -0
- package/build/esm/Init/Status.js.map +1 -0
- package/build/esm/Install/Claude/Hook.js +182 -50
- package/build/esm/Install/Claude/Hook.js.map +1 -1
- package/build/esm/Install/Claude/Mcp.js +56 -26
- package/build/esm/Install/Claude/Mcp.js.map +1 -1
- package/build/esm/Install/Claude/Skill.js +49 -27
- package/build/esm/Install/Claude/Skill.js.map +1 -1
- package/build/esm/Install/Codex/Mcp.js +9 -4
- package/build/esm/Install/Codex/Mcp.js.map +1 -1
- package/build/esm/Install/Codex/Skill.js +4 -1
- package/build/esm/Install/Codex/Skill.js.map +1 -1
- package/build/esm/Install/Cursor/Mcp.js +7 -4
- package/build/esm/Install/Cursor/Mcp.js.map +1 -1
- package/build/esm/Install/Cursor/Skill.js +4 -1
- package/build/esm/Install/Cursor/Skill.js.map +1 -1
- package/build/esm/Install/Domain.js +60 -3
- package/build/esm/Install/Domain.js.map +1 -1
- package/build/esm/Install/Hook.js.map +1 -1
- package/build/esm/Install/Mcp.js +1 -1
- package/build/esm/Install/Mcp.js.map +1 -1
- package/build/esm/Install/Opencode/Hook.js +1 -1
- package/build/esm/Install/Opencode/Hook.js.map +1 -1
- package/build/esm/Install/Opencode/Mcp.js +6 -3
- package/build/esm/Install/Opencode/Mcp.js.map +1 -1
- package/build/esm/Install/Opencode/Skill.js +4 -1
- package/build/esm/Install/Opencode/Skill.js.map +1 -1
- package/build/esm/Install/Opencode/expandai-webfetch.js +27 -9
- package/build/esm/Install/Skill.js.map +1 -1
- package/build/esm/Install/Uninstall.js +2 -2
- package/build/esm/Install/Uninstall.js.map +1 -1
- package/build/esm/MarkdownAnsi.js +142 -0
- package/build/esm/MarkdownAnsi.js.map +1 -0
- package/build/esm/Mcp/ExpandDocs.js +44 -52
- package/build/esm/Mcp/ExpandDocs.js.map +1 -1
- package/build/esm/Mcp/RequestHeaders.js.map +1 -1
- package/build/esm/Mcp/Server.js +2 -1
- package/build/esm/Mcp/Server.js.map +1 -1
- package/build/esm/Mcp/tools/Citations.js +109 -8
- package/build/esm/Mcp/tools/Citations.js.map +1 -1
- package/build/esm/Mcp/tools/Fetch.js +36 -12
- package/build/esm/Mcp/tools/Fetch.js.map +1 -1
- package/build/esm/SecureFile.js +36 -0
- package/build/esm/SecureFile.js.map +1 -0
- package/build/esm/Snippets.js +12 -1
- package/build/esm/Snippets.js.map +1 -1
- package/build/esm/Telemetry.js +157 -0
- package/build/esm/Telemetry.js.map +1 -0
- package/build/esm/Version.js +4 -0
- package/build/esm/Version.js.map +1 -0
- package/build/esm/Xdg.js +15 -0
- package/build/esm/Xdg.js.map +1 -0
- package/build/esm/main.js +122 -14
- package/build/esm/main.js.map +1 -1
- package/build/skills/expandai/SKILL.md +26 -3
- package/package.json +4 -4
- package/src/ApiClient.ts +206 -13
- package/src/Auth/Domain.ts +63 -0
- package/src/Auth/ManualAuthorization.ts +296 -0
- package/src/Auth/PendingInit.ts +338 -0
- package/src/Auth/Pkce.ts +26 -0
- package/src/Auth/SetupGrantAuthorization.ts +116 -0
- package/src/Authentication.ts +49 -11
- package/src/BrowserOAuth.ts +10 -12
- package/src/CommandPath.ts +30 -0
- package/src/Commands/Fetch.ts +42 -11
- package/src/Commands/Init.ts +489 -0
- package/src/Commands/Search.ts +8 -3
- package/src/Commands/Status.ts +228 -0
- package/src/Commands/Telemetry.ts +42 -0
- package/src/Config.ts +695 -50
- package/src/Hook/Claude.ts +1 -1
- package/src/Hook/SnapshotNote.ts +55 -7
- package/src/Init/ClaudeAdapter.ts +191 -0
- package/src/Init/ClaudeProfile.ts +496 -0
- package/src/Init/Domain.ts +153 -0
- package/src/Init/HarnessAdapter.ts +35 -0
- package/src/Init/HarnessDetector.ts +77 -0
- package/src/Init/HarnessRegistry.ts +87 -0
- package/src/Init/MutationTransaction.ts +747 -0
- package/src/Init/Service.ts +385 -0
- package/src/Init/StandardAdapters.ts +141 -0
- package/src/Init/Status.ts +253 -0
- package/src/Install/Claude/Hook.ts +234 -81
- package/src/Install/Claude/Mcp.ts +80 -33
- package/src/Install/Claude/Skill.ts +70 -37
- package/src/Install/Codex/Mcp.ts +14 -9
- package/src/Install/Codex/Skill.ts +9 -5
- package/src/Install/Cursor/Mcp.ts +12 -9
- package/src/Install/Cursor/Skill.ts +9 -5
- package/src/Install/Domain.ts +77 -4
- package/src/Install/Hook.ts +3 -3
- package/src/Install/Mcp.ts +6 -6
- package/src/Install/Opencode/Hook.ts +5 -5
- package/src/Install/Opencode/Mcp.ts +11 -8
- package/src/Install/Opencode/Skill.ts +9 -5
- package/src/Install/Opencode/expandai-webfetch.js +27 -9
- package/src/Install/Skill.ts +3 -3
- package/src/Install/Uninstall.ts +3 -3
- package/src/MarkdownAnsi.ts +162 -0
- package/src/Mcp/ExpandDocs.ts +46 -55
- package/src/Mcp/Server.ts +2 -1
- package/src/Mcp/tools/Citations.ts +117 -8
- package/src/Mcp/tools/Fetch.ts +40 -14
- package/src/SecureFile.ts +70 -0
- package/src/Snippets.ts +19 -12
- package/src/Telemetry.ts +186 -0
- package/src/Version.ts +3 -0
- package/src/Xdg.ts +18 -0
- package/src/main.ts +171 -12
- package/build/dts/DeviceAuth.d.ts +0 -87
- package/build/dts/DeviceAuth.d.ts.map +0 -1
- package/build/esm/DeviceAuth.js +0 -127
- package/build/esm/DeviceAuth.js.map +0 -1
- 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
|
+
) {}
|