@graftland/package-manager 0.1.0 → 0.2.0-beta.1
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 +237 -4
- package/README.zh-CN.md +183 -0
- package/README.zh-TW.md +184 -0
- package/dist/PackageManagerDeclaration.d.ts +6 -0
- package/dist/PackageManagerDeclaration.d.ts.map +1 -0
- package/dist/PackageManagerDeclaration.js +24 -0
- package/dist/PackageManagerDeclaration.js.map +1 -0
- package/dist/index.d.ts +46 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +177 -53
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +128 -19
- package/dist/manifest.js.map +1 -1
- package/dist/process.js +50 -0
- package/dist/process.js.map +1 -0
- package/dist/snapshot.d.ts +15 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +104 -0
- package/dist/snapshot.js.map +1 -0
- package/package.json +9 -3
- package/src/PackageManagerDeclaration.ts +42 -0
- package/src/index.ts +413 -131
- package/src/manifest.ts +231 -27
- package/src/process.ts +122 -0
- package/src/snapshot.ts +261 -0
package/src/index.ts
CHANGED
|
@@ -1,19 +1,41 @@
|
|
|
1
1
|
/** Non-interactive package installation. Provide platform services at the application boundary. */
|
|
2
2
|
import {
|
|
3
|
-
Array
|
|
3
|
+
Array,
|
|
4
|
+
Cause,
|
|
5
|
+
Data,
|
|
6
|
+
Ref,
|
|
4
7
|
Effect,
|
|
5
8
|
Exit,
|
|
6
9
|
FileSystem,
|
|
7
10
|
HashMap,
|
|
8
11
|
Option,
|
|
9
12
|
Path,
|
|
13
|
+
PlatformError,
|
|
10
14
|
Record,
|
|
11
15
|
Schema,
|
|
12
16
|
Semaphore,
|
|
13
|
-
String
|
|
17
|
+
String,
|
|
18
|
+
Stream,
|
|
14
19
|
} from "effect"
|
|
15
|
-
import type { PlatformError } from "effect"
|
|
16
20
|
import { ChildProcess, ChildProcessSpawner } from "effect/process"
|
|
21
|
+
import {
|
|
22
|
+
PackageManagerDeclaration,
|
|
23
|
+
PackageManagerName,
|
|
24
|
+
} from "./PackageManagerDeclaration"
|
|
25
|
+
export { PackageManagerDeclaration } from "./PackageManagerDeclaration"
|
|
26
|
+
import { terminateProcess } from "./process"
|
|
27
|
+
import {
|
|
28
|
+
writeSnapshot,
|
|
29
|
+
withSnapshot,
|
|
30
|
+
type SnapshotCleanupFailure,
|
|
31
|
+
inspectManagedFiles,
|
|
32
|
+
UnsupportedFileTypeError,
|
|
33
|
+
type Contents,
|
|
34
|
+
} from "./snapshot"
|
|
35
|
+
export {
|
|
36
|
+
UnsupportedFileTypeError,
|
|
37
|
+
type SnapshotCleanupFailure,
|
|
38
|
+
} from "./snapshot"
|
|
17
39
|
import {
|
|
18
40
|
applyChanges,
|
|
19
41
|
Changes,
|
|
@@ -39,8 +61,48 @@ export class PackageManagerError extends Schema.TaggedError<
|
|
|
39
61
|
message: Schema.String,
|
|
40
62
|
exitCode: Schema.optionalKey(Schema.Int),
|
|
41
63
|
}) {}
|
|
64
|
+
export class RecoveryRequiredError extends Schema.TaggedError<
|
|
65
|
+
RecoveryRequiredError,
|
|
66
|
+
{ readonly brand: unique symbol }
|
|
67
|
+
>()("RecoveryRequiredError", {
|
|
68
|
+
directory: Schema.String,
|
|
69
|
+
snapshotDirectory: Schema.String,
|
|
70
|
+
operation: Schema.Literals(["install", "recover", "rollback"]),
|
|
71
|
+
processId: Schema.optionalKey(Schema.Int),
|
|
72
|
+
message: Schema.String,
|
|
73
|
+
terminationCause: Schema.optionalKey(
|
|
74
|
+
Schema.Cause(Schema.Unknown, Schema.Unknown),
|
|
75
|
+
),
|
|
76
|
+
recoveryCause: Schema.Cause(Schema.Unknown, Schema.Unknown),
|
|
77
|
+
}) {}
|
|
78
|
+
|
|
79
|
+
type WriterState = Data.TaggedEnum<{
|
|
80
|
+
ManagedWritersIdle: {}
|
|
81
|
+
Running: {}
|
|
82
|
+
FileWrite: {}
|
|
83
|
+
Unconfirmed: {
|
|
84
|
+
readonly operation: "install" | "recover"
|
|
85
|
+
readonly processId: number
|
|
86
|
+
readonly cause: Cause.Cause<PlatformError.PlatformError>
|
|
87
|
+
}
|
|
88
|
+
}>
|
|
89
|
+
|
|
90
|
+
const WriterState = Data.taggedEnum<WriterState>()
|
|
91
|
+
|
|
92
|
+
type ManagerState = Data.TaggedEnum<{
|
|
93
|
+
Ready: {}
|
|
94
|
+
Active: { readonly writer: WriterState }
|
|
95
|
+
RecoveryRequired: { readonly error: RecoveryRequiredError }
|
|
96
|
+
}>
|
|
97
|
+
|
|
98
|
+
const ManagerState = Data.taggedEnum<ManagerState>()
|
|
99
|
+
|
|
42
100
|
export type Error =
|
|
43
|
-
|
|
101
|
+
| PackageManagerError
|
|
102
|
+
| UnsupportedFileTypeError
|
|
103
|
+
| RecoveryRequiredError
|
|
104
|
+
| PlatformError.PlatformError
|
|
105
|
+
| Schema.SchemaError
|
|
44
106
|
export interface Options {
|
|
45
107
|
/** Project directory, containing package.json. Use the workspace root for workspace installs. */
|
|
46
108
|
readonly directory: string
|
|
@@ -67,7 +129,37 @@ const backupFiles = [
|
|
|
67
129
|
".pnp.loader.mjs",
|
|
68
130
|
]
|
|
69
131
|
const executable = (agent: Agent) =>
|
|
70
|
-
|
|
132
|
+
String.startsWith("yarn")(agent) ? "yarn" : agent
|
|
133
|
+
|
|
134
|
+
const readSource = Effect.fnUntraced(function* (
|
|
135
|
+
fs: FileSystem.FileSystem,
|
|
136
|
+
file: string,
|
|
137
|
+
) {
|
|
138
|
+
const bytes = yield* fs.readFile(file)
|
|
139
|
+
return yield* Effect.try({
|
|
140
|
+
try: () =>
|
|
141
|
+
new TextDecoder("utf-8", { ignoreBOM: true, fatal: true }).decode(bytes),
|
|
142
|
+
catch: (cause) =>
|
|
143
|
+
PlatformError.badArgument({
|
|
144
|
+
module: "FileSystem",
|
|
145
|
+
method: "readManifest",
|
|
146
|
+
description: "The manifest must contain valid UTF-8",
|
|
147
|
+
cause,
|
|
148
|
+
}),
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const Executable = PackageManagerName
|
|
153
|
+
const Digits = Schema.String.check(Schema.isPattern(/^\d+$/))
|
|
154
|
+
const decodePackageManager = Schema.decodeUnknownOption(
|
|
155
|
+
PackageManagerDeclaration,
|
|
156
|
+
)
|
|
157
|
+
/** `name/version ...`, as package managers set `npm_config_user_agent`. */
|
|
158
|
+
const decodeUserAgent = Schema.decodeUnknownOption(
|
|
159
|
+
Schema.TemplateLiteralParser([Executable, "/", Digits, Schema.String]),
|
|
160
|
+
)
|
|
161
|
+
const agentOf = (name: typeof Executable.Type, major: string): Agent =>
|
|
162
|
+
name === "yarn" ? (major === "1" ? "yarn-classic" : "yarn-berry") : name
|
|
71
163
|
|
|
72
164
|
/** packageManager, then lockfile, then caller user-agent, then npm. Conflicting lockfiles fail. */
|
|
73
165
|
export const detectAgent = Effect.fnUntraced(function* (options: Options) {
|
|
@@ -75,29 +167,26 @@ export const detectAgent = Effect.fnUntraced(function* (options: Options) {
|
|
|
75
167
|
return yield* Schema.decodeEffect(Agent)(options.agent)
|
|
76
168
|
const fs = yield* FileSystem.FileSystem
|
|
77
169
|
const path = yield* Path.Path
|
|
78
|
-
const manifest = yield*
|
|
79
|
-
|
|
80
|
-
.
|
|
170
|
+
const manifest = yield* readSource(
|
|
171
|
+
fs,
|
|
172
|
+
path.join(options.directory, "package.json"),
|
|
173
|
+
).pipe(Effect.flatMap(decodeManifest))
|
|
81
174
|
if (manifest.packageManager !== undefined) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
? match[2] === "1"
|
|
92
|
-
? "yarn-classic"
|
|
93
|
-
: "yarn-berry"
|
|
94
|
-
: (match[1] as "npm" | "pnpm")
|
|
175
|
+
return yield* Option.match(decodePackageManager(manifest.packageManager), {
|
|
176
|
+
onNone: () =>
|
|
177
|
+
PackageManagerError.make({
|
|
178
|
+
operation: "detect",
|
|
179
|
+
message: `Unsupported packageManager: ${manifest.packageManager}`,
|
|
180
|
+
}),
|
|
181
|
+
onSome: ([name, , version]) =>
|
|
182
|
+
Effect.succeed(agentOf(name, `${version.major}`)),
|
|
183
|
+
})
|
|
95
184
|
}
|
|
96
185
|
const found = yield* Effect.filter(lockfiles, (file) =>
|
|
97
186
|
fs.exists(path.join(options.directory, file)),
|
|
98
187
|
)
|
|
99
|
-
const kinds =
|
|
100
|
-
|
|
188
|
+
const kinds = Array.dedupe(
|
|
189
|
+
Array.map(found, (file) =>
|
|
101
190
|
file === "pnpm-lock.yaml"
|
|
102
191
|
? "pnpm"
|
|
103
192
|
: file === "yarn.lock"
|
|
@@ -111,20 +200,33 @@ export const detectAgent = Effect.fnUntraced(function* (options: Options) {
|
|
|
111
200
|
message: "Conflicting lockfiles; select an agent explicitly",
|
|
112
201
|
})
|
|
113
202
|
if (kinds[0] === "yarn") {
|
|
114
|
-
const
|
|
115
|
-
path.join(options.directory, "yarn.lock")
|
|
116
|
-
|
|
117
|
-
|
|
203
|
+
const metadata = yield* fs
|
|
204
|
+
.stream(path.join(options.directory, "yarn.lock"))
|
|
205
|
+
.pipe(
|
|
206
|
+
Stream.decodeText(),
|
|
207
|
+
Stream.mapAccum(
|
|
208
|
+
// The initial newline models the start of the file.
|
|
209
|
+
() => "\n",
|
|
210
|
+
(tail, chunk) => {
|
|
211
|
+
const text = tail + chunk
|
|
212
|
+
// Preserve all ECMAScript line terminators, including across chunk boundaries.
|
|
213
|
+
// Eleven characters retain a partial marker and its preceding character.
|
|
214
|
+
return [
|
|
215
|
+
String.slice(-11)(text),
|
|
216
|
+
[/[\n\r\u2028\u2029]__metadata:/.test(text)],
|
|
217
|
+
]
|
|
218
|
+
},
|
|
219
|
+
),
|
|
220
|
+
Stream.filter((found) => found),
|
|
221
|
+
Stream.runHead,
|
|
222
|
+
)
|
|
223
|
+
return Option.isSome(metadata) ? "yarn-berry" : "yarn-classic"
|
|
118
224
|
}
|
|
119
225
|
if (kinds[0] !== undefined) return kinds[0]
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
: "yarn-berry"
|
|
125
|
-
: caller?.[1] === "pnpm"
|
|
126
|
-
? "pnpm"
|
|
127
|
-
: "npm"
|
|
226
|
+
return Option.match(decodeUserAgent(options.userAgent ?? ""), {
|
|
227
|
+
onNone: () => "npm" as const,
|
|
228
|
+
onSome: ([name, , major]) => agentOf(name, major),
|
|
229
|
+
})
|
|
128
230
|
})
|
|
129
231
|
|
|
130
232
|
export interface PackageState {
|
|
@@ -146,10 +248,10 @@ export interface StateDiff {
|
|
|
146
248
|
readonly updated: ReadonlyArray<PackageChange>
|
|
147
249
|
}
|
|
148
250
|
export const diffState = (before: State, after: State): StateDiff => {
|
|
149
|
-
const changes =
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
251
|
+
const changes = Array.map(
|
|
252
|
+
Array.sort(
|
|
253
|
+
Array.dedupe([...HashMap.keys(before), ...HashMap.keys(after)]),
|
|
254
|
+
String.Order,
|
|
153
255
|
),
|
|
154
256
|
(name) => ({
|
|
155
257
|
name,
|
|
@@ -158,9 +260,9 @@ export const diffState = (before: State, after: State): StateDiff => {
|
|
|
158
260
|
}),
|
|
159
261
|
)
|
|
160
262
|
return {
|
|
161
|
-
added:
|
|
162
|
-
removed:
|
|
163
|
-
updated:
|
|
263
|
+
added: Array.filter(changes, (change) => change.before === undefined),
|
|
264
|
+
removed: Array.filter(changes, (change) => change.after === undefined),
|
|
265
|
+
updated: Array.filter(
|
|
164
266
|
changes,
|
|
165
267
|
({ before, after }) =>
|
|
166
268
|
before !== undefined &&
|
|
@@ -173,19 +275,44 @@ export const diffState = (before: State, after: State): StateDiff => {
|
|
|
173
275
|
}
|
|
174
276
|
export interface InstallResult extends StateDiff {
|
|
175
277
|
readonly agent: Agent
|
|
278
|
+
/** The install committed; retry only snapshot deletion, not installation. */
|
|
279
|
+
readonly snapshotCleanupFailure: Option.Option<SnapshotCleanupFailure>
|
|
176
280
|
}
|
|
177
281
|
export interface PackageManager {
|
|
178
282
|
readonly directory: string
|
|
179
283
|
readonly agent: Agent
|
|
180
284
|
readonly readState: Effect.Effect<State, Error>
|
|
285
|
+
/** Present when manual recovery is required; the manager cannot be reused. */
|
|
286
|
+
readonly recoveryRequired: Effect.Effect<Option.Option<RecoveryRequiredError>>
|
|
181
287
|
/** Serialized per manager. Empty changes installs the current manifest, including first installs. */
|
|
182
288
|
readonly install: (changes: Changes) => Effect.Effect<InstallResult, Error>
|
|
183
289
|
}
|
|
184
290
|
|
|
185
291
|
/**
|
|
186
|
-
* Own one manager per project.
|
|
187
|
-
*
|
|
188
|
-
*
|
|
292
|
+
* Own one manager per project. The caller must give this manager and its child
|
|
293
|
+
* processes exclusive write access to the project for each install, including
|
|
294
|
+
* recovery. Do not edit project files or run another installer concurrently.
|
|
295
|
+
* Calls are serialized only within this manager; no cross-process lock is taken.
|
|
296
|
+
* Managed paths must be regular files or absent. make and install reject symbolic
|
|
297
|
+
* links and other file types with UnsupportedFileTypeError before any write.
|
|
298
|
+
*
|
|
299
|
+
* Accepted file writes finish before cancellation can start rollback or release
|
|
300
|
+
* the lock. File writes and the original process group participate in the transaction.
|
|
301
|
+
* Scripts must not create escaped writers in independent groups or daemons.
|
|
302
|
+
* Failed or interrupted installs before commit restore backed-up package files byte-for-byte
|
|
303
|
+
* with recorded permissions and ownership, and remove tracked files that did not
|
|
304
|
+
* exist before installation. Writes made
|
|
305
|
+
* during installation are overwritten by rollback, never merged. When a lockfile
|
|
306
|
+
* existed, recovery also attempts to repair node_modules. Recovery failures are
|
|
307
|
+
* combined with the original cause. If process termination cannot be confirmed,
|
|
308
|
+
* no further restoration or recovery runs. Any failed rollback step also requires
|
|
309
|
+
* manual recovery, even if all writers stopped. Only successful installation or
|
|
310
|
+
* complete rollback permits snapshot deletion and reuse. RecoveryRequiredError
|
|
311
|
+
* and recoveryRequired expose the retained snapshot directory and complete cause;
|
|
312
|
+
* this manager rejects
|
|
313
|
+
* all later installs. Complete manual recovery before making a new manager.
|
|
314
|
+
* Committed installs report snapshot deletion failures in snapshotCleanupFailure
|
|
315
|
+
* rather than failing or rolling back. No crash recovery is promised.
|
|
189
316
|
*/
|
|
190
317
|
export const make = (
|
|
191
318
|
options: Options,
|
|
@@ -199,12 +326,14 @@ export const make = (
|
|
|
199
326
|
const path = yield* Path.Path
|
|
200
327
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
201
328
|
const directory = path.resolve(options.directory)
|
|
329
|
+
yield* inspectManagedFiles(fs, path, directory, backupFiles)
|
|
202
330
|
const agent = yield* detectAgent({ ...options, directory })
|
|
203
331
|
const lock = yield* Semaphore.make(1)
|
|
332
|
+
const transaction = yield* Ref.make<ManagerState>(ManagerState.Ready())
|
|
204
333
|
const manifestFile = path.join(directory, "package.json")
|
|
205
|
-
const readManifest = fs
|
|
206
|
-
.
|
|
207
|
-
|
|
334
|
+
const readManifest = readSource(fs, manifestFile).pipe(
|
|
335
|
+
Effect.flatMap(decodeManifest),
|
|
336
|
+
)
|
|
208
337
|
const installed = Schema.decodeUnknownEffect(
|
|
209
338
|
Schema.fromJsonString(
|
|
210
339
|
Schema.Struct({ version: Schema.optionalKey(Schema.String) }),
|
|
@@ -217,7 +346,7 @@ export const make = (
|
|
|
217
346
|
message:
|
|
218
347
|
"Plug'n'Play is not supported; configure Yarn with nodeLinker: node-modules",
|
|
219
348
|
})
|
|
220
|
-
const deps =
|
|
349
|
+
const deps = Array.reduce(
|
|
221
350
|
fields,
|
|
222
351
|
{} as Readonly<Record<string, string>>,
|
|
223
352
|
(all, field) => ({ ...all, ...manifest[field] }),
|
|
@@ -232,14 +361,26 @@ export const make = (
|
|
|
232
361
|
name,
|
|
233
362
|
"package.json",
|
|
234
363
|
)
|
|
235
|
-
|
|
364
|
+
const source = yield* fs.readFileString(file).pipe(
|
|
365
|
+
Effect.asSome,
|
|
366
|
+
Effect.catchCauseIf(
|
|
367
|
+
(cause) =>
|
|
368
|
+
Array.every(
|
|
369
|
+
cause.reasons,
|
|
370
|
+
(reason) =>
|
|
371
|
+
Cause.isFailReason(reason) &&
|
|
372
|
+
reason.error.reason.module === "FileSystem" &&
|
|
373
|
+
reason.error.reason._tag === "NotFound",
|
|
374
|
+
),
|
|
375
|
+
() => Effect.succeedNone,
|
|
376
|
+
),
|
|
377
|
+
)
|
|
378
|
+
if (Option.isNone(source))
|
|
236
379
|
return [
|
|
237
380
|
name,
|
|
238
381
|
{ spec, version: undefined, path: undefined },
|
|
239
382
|
] as const
|
|
240
|
-
const pkg = yield*
|
|
241
|
-
.readFileString(file)
|
|
242
|
-
.pipe(Effect.flatMap(installed))
|
|
383
|
+
const pkg = yield* installed(source.value)
|
|
243
384
|
return [
|
|
244
385
|
name,
|
|
245
386
|
{
|
|
@@ -249,10 +390,14 @@ export const make = (
|
|
|
249
390
|
},
|
|
250
391
|
] as const
|
|
251
392
|
}),
|
|
393
|
+
{ concurrency: 16 },
|
|
252
394
|
)
|
|
253
395
|
return HashMap.fromIterable<string, PackageState>(entries)
|
|
254
396
|
})
|
|
255
397
|
const readState = Effect.flatMap(readManifest, stateOf)
|
|
398
|
+
const termination: ChildProcess.KillOptions = {
|
|
399
|
+
forceKillAfter: "5 seconds",
|
|
400
|
+
}
|
|
256
401
|
const run = Effect.fnUntraced(function* (operation: "install" | "recover") {
|
|
257
402
|
const recovery = operation === "recover"
|
|
258
403
|
const args =
|
|
@@ -278,113 +423,250 @@ export const make = (
|
|
|
278
423
|
: []),
|
|
279
424
|
]
|
|
280
425
|
: ["install", ...(recovery ? ["--immutable"] : [])]
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
426
|
+
// Acquire and install the release handler atomically, then wait interruptibly.
|
|
427
|
+
return yield* Effect.acquireUseRelease(
|
|
428
|
+
spawner
|
|
429
|
+
.spawn(
|
|
430
|
+
ChildProcess.make(executable(agent), args, {
|
|
431
|
+
cwd: directory,
|
|
432
|
+
extendEnv: true,
|
|
433
|
+
stdin: "ignore",
|
|
434
|
+
stdout: "inherit",
|
|
435
|
+
stderr: "inherit",
|
|
436
|
+
detached: true,
|
|
437
|
+
...termination,
|
|
438
|
+
env: {
|
|
439
|
+
CI: "true",
|
|
440
|
+
...(agent === "yarn-berry"
|
|
441
|
+
? {
|
|
442
|
+
YARN_ENABLE_IMMUTABLE_INSTALLS: recovery
|
|
443
|
+
? "true"
|
|
444
|
+
: "false",
|
|
445
|
+
YARN_NODE_LINKER: "node-modules",
|
|
446
|
+
}
|
|
447
|
+
: {}),
|
|
448
|
+
},
|
|
449
|
+
}),
|
|
450
|
+
)
|
|
451
|
+
.pipe(
|
|
452
|
+
Effect.tap(() =>
|
|
453
|
+
Ref.set(
|
|
454
|
+
transaction,
|
|
455
|
+
ManagerState.Active({ writer: WriterState.Running() }),
|
|
456
|
+
),
|
|
457
|
+
),
|
|
458
|
+
),
|
|
459
|
+
(process) =>
|
|
460
|
+
Effect.gen(function* () {
|
|
461
|
+
const code = yield* process.exitCode.pipe(
|
|
462
|
+
Effect.timeoutOrElse({
|
|
463
|
+
duration: "10 minutes",
|
|
464
|
+
orElse: () =>
|
|
465
|
+
PackageManagerError.make({
|
|
466
|
+
operation,
|
|
467
|
+
message: `${executable(agent)} ${operation} timed out`,
|
|
468
|
+
}),
|
|
469
|
+
}),
|
|
470
|
+
)
|
|
471
|
+
if (code !== 0)
|
|
472
|
+
return yield* PackageManagerError.make({
|
|
305
473
|
operation,
|
|
306
|
-
|
|
474
|
+
exitCode: code,
|
|
475
|
+
message: `${executable(agent)} ${operation} exited with code ${code}`,
|
|
476
|
+
})
|
|
477
|
+
}),
|
|
478
|
+
// The command owns its group; a stopped leader may leave live descendants.
|
|
479
|
+
// Observe kill failures here rather than relying on best-effort scope cleanup.
|
|
480
|
+
(process) =>
|
|
481
|
+
Effect.gen(function* () {
|
|
482
|
+
const result = yield* terminateProcess(process, termination)
|
|
483
|
+
yield* Ref.set(
|
|
484
|
+
transaction,
|
|
485
|
+
ManagerState.Active({
|
|
486
|
+
writer:
|
|
487
|
+
result._tag === "OriginalGroupStopped"
|
|
488
|
+
? WriterState.ManagedWritersIdle()
|
|
489
|
+
: WriterState.Unconfirmed({
|
|
490
|
+
operation,
|
|
491
|
+
processId: process.pid,
|
|
492
|
+
cause: result.cause,
|
|
493
|
+
}),
|
|
307
494
|
}),
|
|
495
|
+
)
|
|
496
|
+
if (result._tag === "Unconfirmed")
|
|
497
|
+
return yield* Effect.failCause(result.cause)
|
|
308
498
|
}),
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
499
|
+
)
|
|
500
|
+
}, Effect.scoped)
|
|
501
|
+
// AbortSignal does not join accepted OS writes. Keep the writer owned until completion.
|
|
502
|
+
const withFileWriter = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
503
|
+
Effect.acquireUseRelease(
|
|
504
|
+
Ref.set(
|
|
505
|
+
transaction,
|
|
506
|
+
ManagerState.Active({ writer: WriterState.FileWrite() }),
|
|
507
|
+
),
|
|
508
|
+
() => effect,
|
|
509
|
+
() =>
|
|
510
|
+
Ref.set(
|
|
511
|
+
transaction,
|
|
512
|
+
ManagerState.Active({ writer: WriterState.ManagedWritersIdle() }),
|
|
513
|
+
),
|
|
514
|
+
).pipe(Effect.uninterruptible)
|
|
515
|
+
|
|
317
516
|
const install = (changes: Changes) =>
|
|
318
517
|
lock.withPermits(1)(
|
|
319
518
|
Effect.gen(function* () {
|
|
519
|
+
const state = yield* Ref.get(transaction)
|
|
520
|
+
if (state._tag === "RecoveryRequired") return yield* state.error
|
|
320
521
|
const validated = yield* Schema.decodeEffect(Changes)(changes)
|
|
321
|
-
const
|
|
522
|
+
const inspected = yield* inspectManagedFiles(
|
|
523
|
+
fs,
|
|
524
|
+
path,
|
|
525
|
+
directory,
|
|
526
|
+
backupFiles,
|
|
527
|
+
)
|
|
528
|
+
const backups = yield* Effect.forEach(
|
|
529
|
+
inspected,
|
|
530
|
+
({ name, file, metadata }) =>
|
|
531
|
+
Effect.gen(function* () {
|
|
532
|
+
const contents = yield* Option.match(metadata, {
|
|
533
|
+
onNone: () => Effect.succeedNone,
|
|
534
|
+
onSome: (info) =>
|
|
535
|
+
fs
|
|
536
|
+
.readFile(file)
|
|
537
|
+
.pipe(
|
|
538
|
+
Effect.map((bytes) =>
|
|
539
|
+
Option.some<Contents>({ ...info, bytes }),
|
|
540
|
+
),
|
|
541
|
+
),
|
|
542
|
+
})
|
|
543
|
+
return { name, file, contents }
|
|
544
|
+
}),
|
|
545
|
+
)
|
|
546
|
+
const original = yield* readSource(fs, manifestFile)
|
|
322
547
|
const manifest = yield* decodeManifest(original)
|
|
323
548
|
const before = yield* stateOf(manifest)
|
|
324
549
|
const text = yield* encodeManifest(
|
|
325
550
|
applyChanges(manifest, validated),
|
|
326
551
|
original,
|
|
327
552
|
)
|
|
328
|
-
|
|
329
|
-
Effect.gen(function* () {
|
|
330
|
-
const file = path.join(directory, name)
|
|
331
|
-
return {
|
|
332
|
-
name,
|
|
333
|
-
file,
|
|
334
|
-
bytes: (yield* fs.exists(file))
|
|
335
|
-
? yield* fs.readFile(file).pipe(Effect.asSome)
|
|
336
|
-
: Option.none<Uint8Array>(),
|
|
337
|
-
}
|
|
338
|
-
}),
|
|
339
|
-
)
|
|
340
|
-
// Every file gets a restoration attempt; onExit combines failures.
|
|
341
|
-
const restore = Arr.reduce<
|
|
342
|
-
(typeof backups)[number],
|
|
343
|
-
Effect.Effect<void, PlatformError.PlatformError>
|
|
344
|
-
>(backups, Effect.void, (previous, { file, bytes }) =>
|
|
345
|
-
previous.pipe(
|
|
346
|
-
Effect.onExit(() =>
|
|
347
|
-
Option.match(bytes, {
|
|
348
|
-
onNone: () => fs.remove(file, { force: true }),
|
|
349
|
-
onSome: (bytes) => fs.writeFile(file, bytes),
|
|
350
|
-
}),
|
|
351
|
-
),
|
|
352
|
-
),
|
|
353
|
-
)
|
|
553
|
+
|
|
354
554
|
const agentLocks =
|
|
355
555
|
agent === "npm"
|
|
356
556
|
? ["package-lock.json", "npm-shrinkwrap.json"]
|
|
357
557
|
: agent === "pnpm"
|
|
358
558
|
? ["pnpm-lock.yaml"]
|
|
359
559
|
: ["yarn.lock"]
|
|
360
|
-
const hasLock =
|
|
560
|
+
const hasLock = Array.some(
|
|
361
561
|
backups,
|
|
362
|
-
({ name,
|
|
363
|
-
|
|
562
|
+
({ name, contents }) =>
|
|
563
|
+
Array.contains(agentLocks, name) && Option.isSome(contents),
|
|
364
564
|
)
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
()
|
|
565
|
+
const completed = yield* withSnapshot(
|
|
566
|
+
fs,
|
|
567
|
+
Ref.get(transaction).pipe(
|
|
568
|
+
Effect.map((state) => state._tag === "Ready"),
|
|
569
|
+
),
|
|
570
|
+
(snapshotDirectory) =>
|
|
368
571
|
Effect.gen(function* () {
|
|
369
|
-
yield*
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
572
|
+
const snapshot = yield* writeSnapshot(
|
|
573
|
+
fs,
|
|
574
|
+
path,
|
|
575
|
+
snapshotDirectory,
|
|
576
|
+
directory,
|
|
577
|
+
agent,
|
|
578
|
+
backups,
|
|
579
|
+
)
|
|
580
|
+
const restore = Effect.flatMap(Ref.get(transaction), (state) =>
|
|
581
|
+
state._tag === "Active" &&
|
|
582
|
+
state.writer._tag === "ManagedWritersIdle"
|
|
583
|
+
? withFileWriter(snapshot.restore(state.writer))
|
|
584
|
+
: Effect.void,
|
|
585
|
+
)
|
|
586
|
+
return yield* Effect.acquireUseRelease(
|
|
587
|
+
Ref.set(
|
|
588
|
+
transaction,
|
|
589
|
+
ManagerState.Active({
|
|
590
|
+
writer: WriterState.ManagedWritersIdle(),
|
|
591
|
+
}),
|
|
381
592
|
),
|
|
593
|
+
() =>
|
|
594
|
+
Effect.gen(function* () {
|
|
595
|
+
yield* withFileWriter(
|
|
596
|
+
fs.writeFileString(manifestFile, text),
|
|
597
|
+
)
|
|
598
|
+
yield* run("install")
|
|
599
|
+
const after = yield* readState
|
|
600
|
+
return { agent, ...diffState(before, after) }
|
|
601
|
+
}),
|
|
602
|
+
(_, exit) =>
|
|
603
|
+
Effect.gen(function* () {
|
|
604
|
+
if (Exit.isFailure(exit)) {
|
|
605
|
+
const state = yield* Ref.get(transaction)
|
|
606
|
+
if (
|
|
607
|
+
state._tag !== "Active" ||
|
|
608
|
+
state.writer._tag !== "ManagedWritersIdle"
|
|
609
|
+
)
|
|
610
|
+
return
|
|
611
|
+
yield* withFileWriter(snapshot.restore(state.writer))
|
|
612
|
+
if (hasLock)
|
|
613
|
+
yield* run("recover").pipe(
|
|
614
|
+
// Even a failed recovery must restore files, once its writer has stopped.
|
|
615
|
+
Effect.onExit(() => restore),
|
|
616
|
+
)
|
|
617
|
+
}
|
|
618
|
+
// Idle managed writers alone do not prove that the transaction completed.
|
|
619
|
+
yield* Ref.set(transaction, ManagerState.Ready())
|
|
620
|
+
}),
|
|
621
|
+
).pipe(
|
|
622
|
+
Effect.onExit((exit) =>
|
|
623
|
+
Effect.gen(function* () {
|
|
624
|
+
const state = yield* Ref.get(transaction)
|
|
625
|
+
if (Exit.isSuccess(exit) || state._tag !== "Active")
|
|
626
|
+
return
|
|
627
|
+
const writer = state.writer
|
|
628
|
+
const error = RecoveryRequiredError.make({
|
|
629
|
+
directory,
|
|
630
|
+
snapshotDirectory,
|
|
631
|
+
operation:
|
|
632
|
+
writer._tag === "Unconfirmed"
|
|
633
|
+
? writer.operation
|
|
634
|
+
: "rollback",
|
|
635
|
+
...(writer._tag === "Unconfirmed"
|
|
636
|
+
? {
|
|
637
|
+
processId: writer.processId,
|
|
638
|
+
terminationCause: writer.cause,
|
|
639
|
+
}
|
|
640
|
+
: {}),
|
|
641
|
+
message: `Manual recovery is required. Stop all writers before restoring the snapshot at ${snapshotDirectory}`,
|
|
642
|
+
recoveryCause: exit.cause,
|
|
643
|
+
})
|
|
644
|
+
yield* Ref.set(
|
|
645
|
+
transaction,
|
|
646
|
+
ManagerState.RecoveryRequired({ error }),
|
|
647
|
+
)
|
|
648
|
+
return yield* error
|
|
649
|
+
}),
|
|
650
|
+
),
|
|
651
|
+
)
|
|
652
|
+
}),
|
|
382
653
|
)
|
|
654
|
+
return {
|
|
655
|
+
...completed.value,
|
|
656
|
+
snapshotCleanupFailure: completed.snapshotCleanupFailure,
|
|
657
|
+
}
|
|
383
658
|
}),
|
|
384
659
|
)
|
|
385
660
|
return {
|
|
386
661
|
directory,
|
|
387
662
|
agent,
|
|
663
|
+
recoveryRequired: Ref.get(transaction).pipe(
|
|
664
|
+
Effect.map((state) =>
|
|
665
|
+
state._tag === "RecoveryRequired"
|
|
666
|
+
? Option.some(state.error)
|
|
667
|
+
: Option.none(),
|
|
668
|
+
),
|
|
669
|
+
),
|
|
388
670
|
readState: lock.withPermits(1)(readState),
|
|
389
671
|
install,
|
|
390
672
|
}
|