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