@effected/workspaces 0.25.0 → 0.27.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/LayerPolicy.js +133 -0
- package/PackedInstall.js +761 -0
- package/README.md +244 -0
- package/SourceBoundary.js +624 -0
- package/WorkspaceLayering.js +199 -0
- package/internal/dependencyFields.js +17 -0
- package/internal/packedInstallPlan.js +239 -0
- package/internal/sourceText.js +359 -0
- package/package.json +6 -1
- package/testing.d.ts +1837 -0
- package/testing.js +10 -0
package/PackedInstall.js
ADDED
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
import { WorkspaceDiscovery } from "./WorkspaceDiscovery.js";
|
|
2
|
+
import { PackageManagerName } from "./PackageManagerName.js";
|
|
3
|
+
import { binConflict, binTargetOf, closureOf, consumerFiles, fileOverridesOf, installArgs, overridePath, readPackedManifest, scrubEnv, versionOf } from "./internal/packedInstallPlan.js";
|
|
4
|
+
import { Duration, Effect, FileSystem, Option, Path, Redacted, Result, Schema } from "effect";
|
|
5
|
+
import { Yaml } from "@effected/yaml";
|
|
6
|
+
import { Run } from "@effected/commands";
|
|
7
|
+
import { ChildProcess } from "effect/unstable/process";
|
|
8
|
+
|
|
9
|
+
//#region src/PackedInstall.ts
|
|
10
|
+
/**
|
|
11
|
+
* Why a packed install could not be proven.
|
|
12
|
+
*
|
|
13
|
+
* @public
|
|
14
|
+
*/
|
|
15
|
+
var PackedInstallError = class extends Schema.TaggedError()("PackedInstallError", {
|
|
16
|
+
/** What failed. */
|
|
17
|
+
reason: Schema.Literals([
|
|
18
|
+
"UnsupportedPlatform",
|
|
19
|
+
"NoManagerAvailable",
|
|
20
|
+
"ManagerUnavailable",
|
|
21
|
+
"UnknownPackage",
|
|
22
|
+
"PackSourceMissing",
|
|
23
|
+
"PackFailed",
|
|
24
|
+
"UnresolvedProtocol",
|
|
25
|
+
"InvalidOverride",
|
|
26
|
+
"BinConflict",
|
|
27
|
+
"InstallFailed",
|
|
28
|
+
"MissingBin",
|
|
29
|
+
"BinFailed",
|
|
30
|
+
"UnownedBin",
|
|
31
|
+
"Discovery",
|
|
32
|
+
"Io"
|
|
33
|
+
]),
|
|
34
|
+
/** One line, naming the package or manager involved. */
|
|
35
|
+
message: Schema.String,
|
|
36
|
+
/** The package manager involved. */
|
|
37
|
+
manager: Schema.optionalKey(PackageManagerName),
|
|
38
|
+
/** The workspace package involved. */
|
|
39
|
+
package: Schema.optionalKey(Schema.String),
|
|
40
|
+
/** The tail of the failing command's output. */
|
|
41
|
+
output: Schema.optionalKey(Schema.String),
|
|
42
|
+
/** The originating failure. */
|
|
43
|
+
cause: Schema.optionalKey(Schema.Defect())
|
|
44
|
+
}) {};
|
|
45
|
+
const DEFAULT_BIN_TIMEOUT = "1 minute";
|
|
46
|
+
/**
|
|
47
|
+
* Whether a failed `readLink` means "this entry is not a symlink". Node reports
|
|
48
|
+
* that as `EINVAL`, which its platform layer tags `Unknown` with the errno on
|
|
49
|
+
* the cause, and `@effected/memfs` raises the same shape. Anything else,
|
|
50
|
+
* `PermissionDenied` and `BadResource` included, is a real failure.
|
|
51
|
+
*/
|
|
52
|
+
const isNotALink = (error) => error.reason._tag === "Unknown" && typeof error.reason.cause === "object" && error.reason.cause !== null && error.reason.cause.code === "EINVAL";
|
|
53
|
+
/**
|
|
54
|
+
* One scratch project, outside the workspace, with the carrier installed.
|
|
55
|
+
*
|
|
56
|
+
* @public
|
|
57
|
+
*/
|
|
58
|
+
var InstalledConsumer = class extends Schema.Class("InstalledConsumer")({
|
|
59
|
+
/** The package manager that installed it. */
|
|
60
|
+
manager: PackageManagerName,
|
|
61
|
+
/** The version it reported and was pinned to. */
|
|
62
|
+
managerVersion: Schema.String,
|
|
63
|
+
/** The consumer project directory (realpath'd). */
|
|
64
|
+
directory: Schema.String,
|
|
65
|
+
/**
|
|
66
|
+
* The scrubbed environment the install ran under, which {@link InstalledConsumer.runBin}
|
|
67
|
+
* starts from. Redacted, so printing a consumer never prints a token.
|
|
68
|
+
* `PackedInstall.run` always sets it; a hand-made consumer without it runs
|
|
69
|
+
* its bins under only `RunBinOptions.env`.
|
|
70
|
+
*/
|
|
71
|
+
env: Schema.optionalKey(Schema.Redacted(Schema.Record(Schema.String, Schema.String))),
|
|
72
|
+
/**
|
|
73
|
+
* The carrier the consumer depends on, which {@link InstalledConsumer.carrierCommand}
|
|
74
|
+
* resolves bins through. `PackedInstall.run` always sets it.
|
|
75
|
+
*/
|
|
76
|
+
carrier: Schema.optionalKey(Schema.String)
|
|
77
|
+
}) {
|
|
78
|
+
/** The installed bin `name`, in `node_modules/.bin`. POSIX: `PackedInstall` runs only there. */
|
|
79
|
+
binPath(name) {
|
|
80
|
+
return `${this.directory}/node_modules/.bin/${name}`;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Which installed package the `node_modules/.bin` entry `name` resolves into.
|
|
84
|
+
*
|
|
85
|
+
* @remarks
|
|
86
|
+
* Under a flat layout (npm, bun, and Yarn with the `node-modules` linker
|
|
87
|
+
* this run configures), a hoisted bin of the same name from another package
|
|
88
|
+
* can shadow the carrier's, and running it cannot tell you which one ran.
|
|
89
|
+
* Those managers write each `.bin` entry as a symlink, so this reads the
|
|
90
|
+
* link, realpaths its target, and walks up to the nearest `package.json`
|
|
91
|
+
* with a string `name`, staying inside the consumer directory (itself
|
|
92
|
+
* realpath'd first, so a `/var` alias of `/private/var` or a trailing
|
|
93
|
+
* slash does not move the bound). A manifest that is valid JSON but not an
|
|
94
|
+
* object, or has no string `name` (a `dist/package.json` carrying only
|
|
95
|
+
* `type`), is passed over. Assert `package` is your carrier.
|
|
96
|
+
*
|
|
97
|
+
* `undefined` means one thing: the entry exists and is not a symlink.
|
|
98
|
+
* pnpm writes `.bin` entries as shell shims, whose target this does not
|
|
99
|
+
* parse; its isolated layout links only the consumer's direct dependencies
|
|
100
|
+
* at the top level, so a shadowing bin needs a direct dependency there.
|
|
101
|
+
*
|
|
102
|
+
* An entry that does not exist, or a link whose target does not, fails
|
|
103
|
+
* `MissingBin`. A link into no named package inside the consumer fails
|
|
104
|
+
* `UnownedBin`, naming the target. Any other read failure, a
|
|
105
|
+
* `package.json` that is not JSON included, fails `Io`.
|
|
106
|
+
*
|
|
107
|
+
* @param name - The bin, as named in `node_modules/.bin`.
|
|
108
|
+
*/
|
|
109
|
+
binProvenance(name) {
|
|
110
|
+
const bin = this.binPath(name);
|
|
111
|
+
const directory = this.directory;
|
|
112
|
+
const manager = this.manager;
|
|
113
|
+
const entry = `node_modules/.bin/${name}`;
|
|
114
|
+
return Effect.gen(function* () {
|
|
115
|
+
const fs = yield* FileSystem.FileSystem;
|
|
116
|
+
const path = yield* Path.Path;
|
|
117
|
+
const io = (message) => (cause) => failure("Io", message, {
|
|
118
|
+
manager,
|
|
119
|
+
cause
|
|
120
|
+
});
|
|
121
|
+
const missing = (why) => failure("MissingBin", `${manager}: ${entry} ${why}`, { manager });
|
|
122
|
+
if (!(yield* fs.readLink(bin).pipe(Effect.as(true), Effect.catch((error) => isNotALink(error) ? Effect.succeed(false) : error.reason._tag === "NotFound" ? Effect.fail(missing("does not exist")) : Effect.fail(io(`could not read the link ${bin}`)(error)))))) {
|
|
123
|
+
if (!(yield* fs.exists(bin).pipe(Effect.mapError(io(`could not inspect ${bin}`))))) return yield* missing("does not exist");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const target = yield* fs.realPath(bin).pipe(Effect.catch((error) => error.reason._tag === "NotFound" ? Effect.fail(missing("is a link to nothing")) : Effect.fail(io(`could not resolve ${bin}`)(error))));
|
|
127
|
+
const root = yield* fs.realPath(directory).pipe(Effect.mapError(io(`could not resolve ${directory}`)));
|
|
128
|
+
for (let dir = path.dirname(target); dir.startsWith(`${root}/`); dir = path.dirname(dir)) {
|
|
129
|
+
const manifest = path.join(dir, "package.json");
|
|
130
|
+
if (!(yield* fs.exists(manifest).pipe(Effect.mapError(io(`could not inspect ${manifest}`))))) continue;
|
|
131
|
+
const text = yield* fs.readFileString(manifest).pipe(Effect.mapError(io(`could not read ${manifest}`)));
|
|
132
|
+
const parsed = yield* Effect.try({
|
|
133
|
+
try: () => JSON.parse(text),
|
|
134
|
+
catch: io(`${manifest} is not JSON`)
|
|
135
|
+
});
|
|
136
|
+
const named = typeof parsed === "object" && parsed !== null ? parsed.name : void 0;
|
|
137
|
+
if (typeof named === "string") return {
|
|
138
|
+
package: named,
|
|
139
|
+
target
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return yield* failure("UnownedBin", `${manager}: ${entry} links to ${target}, which lies in no named package inside ${root}`, { manager });
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The command {@link InstalledConsumer.runBin} spawns for the bin `name`,
|
|
147
|
+
* for a caller that drives the child itself, such as `McpProbe.initialize`
|
|
148
|
+
* from `@effected/mcp/testing`.
|
|
149
|
+
*
|
|
150
|
+
* @remarks
|
|
151
|
+
* {@link InstalledConsumer.binPath}, from the consumer's directory, under
|
|
152
|
+
* the install's scrubbed environment with `options.env` layered over it
|
|
153
|
+
* after the scrub, and nothing inherited beyond that. `runBin` builds its
|
|
154
|
+
* command here and then ignores stdin; this one leaves stdin as the
|
|
155
|
+
* spawner's default pipe, so a probe can write to it. Spawn it inside the
|
|
156
|
+
* scope that ran `PackedInstall.run`: the scratch directory is removed
|
|
157
|
+
* when that scope closes.
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* ```ts
|
|
161
|
+
* import { McpProbe } from "@effected/mcp/testing";
|
|
162
|
+
* import type { InstalledConsumer } from "@effected/workspaces/testing";
|
|
163
|
+
*
|
|
164
|
+
* declare const consumer: InstalledConsumer;
|
|
165
|
+
* declare const xdg: string;
|
|
166
|
+
*
|
|
167
|
+
* const probe = McpProbe.initialize(consumer.command("my-tool-mcp", [], { env: { XDG_DATA_HOME: xdg } }));
|
|
168
|
+
* ```
|
|
169
|
+
*
|
|
170
|
+
* @param name - The bin, as named in `node_modules/.bin`.
|
|
171
|
+
* @param args - Its arguments.
|
|
172
|
+
* @param options - Extra environment and working directory.
|
|
173
|
+
*/
|
|
174
|
+
command(name, args = [], options = {}) {
|
|
175
|
+
return ChildProcess.make(this.binPath(name), args, {
|
|
176
|
+
cwd: options.cwd ?? this.directory,
|
|
177
|
+
env: layeredEnv(this, options),
|
|
178
|
+
extendEnv: false
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Run the installed bin `name` to completion and collect what it wrote.
|
|
183
|
+
*
|
|
184
|
+
* @remarks
|
|
185
|
+
* Spawns {@link InstalledConsumer.command} with stdin ignored: the bin
|
|
186
|
+
* from the consumer's directory, under the install's scrubbed environment
|
|
187
|
+
* with `options.env` layered over it after the scrub, and nothing
|
|
188
|
+
* inherited beyond that. A
|
|
189
|
+
* non-zero exit is a result, read from `exitCode`, never a failure. A bin
|
|
190
|
+
* that cannot spawn, outlives `options.timeout` or floods its output fails
|
|
191
|
+
* `BinFailed` naming the manager and the bin. Run it inside the scope that
|
|
192
|
+
* ran `PackedInstall.run`: the scratch directory is removed when that
|
|
193
|
+
* scope closes.
|
|
194
|
+
*
|
|
195
|
+
* @param name - The bin, as named in `node_modules/.bin`.
|
|
196
|
+
* @param args - Its arguments.
|
|
197
|
+
* @param options - Extra environment, working directory and ceiling.
|
|
198
|
+
*/
|
|
199
|
+
runBin(name, args = [], options = {}) {
|
|
200
|
+
return collectBin(this.command(name, args, options), this.manager, name, options.timeout);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* The command that runs the CARRIER's own bin `name`, whichever package
|
|
204
|
+
* won the `node_modules/.bin` slot.
|
|
205
|
+
*
|
|
206
|
+
* @remarks
|
|
207
|
+
* With `allowSharedBins`, a flat layout (npm, bun, Yarn's `node-modules`
|
|
208
|
+
* linker) can link a front end's same-named bin into `.bin`, so
|
|
209
|
+
* {@link InstalledConsumer.runBin} proves only that SOME package's bin runs.
|
|
210
|
+
* This reads the carrier's installed `node_modules/<carrier>/package.json`,
|
|
211
|
+
* takes `name` from its `bin` map (a `bin` string answers to the unscoped
|
|
212
|
+
* package name), and returns `node <that file> ...args`: the carrier's shim
|
|
213
|
+
* itself, run by the `node` on the environment's `PATH`.
|
|
214
|
+
*
|
|
215
|
+
* Running it through `node` has three limits. It assumes a Node script, so
|
|
216
|
+
* a non-Node shim misruns. It drops any flags in the shim's shebang (a
|
|
217
|
+
* `#!/usr/bin/env -S node --enable-source-maps` runs without them). And it
|
|
218
|
+
* bypasses the file's executable bit, so under shared bins it does not
|
|
219
|
+
* prove the carrier's shim is executable, only that it runs: the manager
|
|
220
|
+
* that linked a front end's bin into the slot may never have made the
|
|
221
|
+
* carrier's target executable. Environment and working directory are built as
|
|
222
|
+
* {@link InstalledConsumer.command} builds them, stdin left open for a probe
|
|
223
|
+
* such as `McpProbe.initialize`.
|
|
224
|
+
*
|
|
225
|
+
* A consumer with no `carrier`, a carrier that is not installed or does not
|
|
226
|
+
* declare `name`, or a declared file that does not exist fails
|
|
227
|
+
* `MissingBin`; a manifest that cannot be read or is not JSON fails `Io`.
|
|
228
|
+
*
|
|
229
|
+
* @param name - The bin, as the carrier's `bin` map names it.
|
|
230
|
+
* @param args - Its arguments.
|
|
231
|
+
* @param options - Extra environment and working directory.
|
|
232
|
+
*/
|
|
233
|
+
carrierCommand(name, args = [], options = {}) {
|
|
234
|
+
const consumer = this;
|
|
235
|
+
const manager = this.manager;
|
|
236
|
+
return Effect.gen(function* () {
|
|
237
|
+
const fs = yield* FileSystem.FileSystem;
|
|
238
|
+
const path = yield* Path.Path;
|
|
239
|
+
const missing = (why) => failure("MissingBin", `${manager}: the carrier's bin ${name} ${why}`, {
|
|
240
|
+
manager,
|
|
241
|
+
...consumer.carrier === void 0 ? {} : { package: consumer.carrier }
|
|
242
|
+
});
|
|
243
|
+
if (consumer.carrier === void 0) return yield* missing("cannot be found: this consumer records no carrier");
|
|
244
|
+
const root = path.join(consumer.directory, "node_modules", ...consumer.carrier.split("/"));
|
|
245
|
+
const manifestPath = path.join(root, "package.json");
|
|
246
|
+
const text = yield* fs.readFileString(manifestPath).pipe(Effect.catch((error) => error.reason._tag === "NotFound" ? Effect.fail(missing(`cannot be found: ${consumer.carrier} is not installed at ${root}`)) : Effect.fail(failure("Io", `could not read ${manifestPath}`, {
|
|
247
|
+
manager,
|
|
248
|
+
cause: error
|
|
249
|
+
}))));
|
|
250
|
+
const manifest = readPackedManifest(text);
|
|
251
|
+
if (Result.isFailure(manifest)) return yield* failure("Io", `${manifestPath} is not a JSON object`, {
|
|
252
|
+
manager,
|
|
253
|
+
cause: manifest.failure
|
|
254
|
+
});
|
|
255
|
+
const target = binTargetOf(text, name);
|
|
256
|
+
if (target === void 0) return yield* missing(`is not declared by ${consumer.carrier}`);
|
|
257
|
+
const file = path.join(root, target);
|
|
258
|
+
if (!(yield* fs.exists(file).pipe(Effect.mapError((cause) => failure("Io", `could not inspect ${file}`, {
|
|
259
|
+
manager,
|
|
260
|
+
cause
|
|
261
|
+
}))))) return yield* missing(`points at ${file}, which does not exist`);
|
|
262
|
+
return ChildProcess.make("node", [file, ...args], {
|
|
263
|
+
cwd: options.cwd ?? consumer.directory,
|
|
264
|
+
env: layeredEnv(consumer, options),
|
|
265
|
+
extendEnv: false
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Run the carrier's own bin `name` to completion, whichever package won
|
|
271
|
+
* the `node_modules/.bin` slot.
|
|
272
|
+
*
|
|
273
|
+
* @remarks
|
|
274
|
+
* Spawns {@link InstalledConsumer.carrierCommand} with stdin ignored, and
|
|
275
|
+
* reports as {@link InstalledConsumer.runBin} does: a non-zero exit is a
|
|
276
|
+
* result, and a bin that cannot spawn or outlives `options.timeout` (one
|
|
277
|
+
* minute by default) fails `BinFailed`. Use it beside `runBin` under
|
|
278
|
+
* `allowSharedBins`: `runBin` proves what a user typing the bin name gets,
|
|
279
|
+
* this proves the carrier's shim itself works, within `carrierCommand`'s
|
|
280
|
+
* limits (a Node script, shebang flags dropped, executable bit not
|
|
281
|
+
* checked).
|
|
282
|
+
*
|
|
283
|
+
* @param name - The bin, as the carrier's `bin` map names it.
|
|
284
|
+
* @param args - Its arguments.
|
|
285
|
+
* @param options - Extra environment, working directory and ceiling.
|
|
286
|
+
*/
|
|
287
|
+
runCarrierBin(name, args = [], options = {}) {
|
|
288
|
+
const manager = this.manager;
|
|
289
|
+
return this.carrierCommand(name, args, options).pipe(Effect.flatMap((command) => collectBin(command, manager, name, options.timeout)));
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
/** The install's scrubbed environment with the caller's entries layered on after the scrub: a deliberate CI=true must survive it. */
|
|
293
|
+
const layeredEnv = (consumer, options) => {
|
|
294
|
+
const env = scrubEnv(consumer.env === void 0 ? {} : Redacted.value(consumer.env));
|
|
295
|
+
for (const [key, value] of Object.entries(options.env ?? {})) if (value === void 0) delete env[key];
|
|
296
|
+
else env[key] = value;
|
|
297
|
+
return env;
|
|
298
|
+
};
|
|
299
|
+
/** Run a bin's command to completion, stdin ignored, mapping a failed spawn or an expired ceiling to `BinFailed`. */
|
|
300
|
+
const collectBin = (command, manager, name, timeout = DEFAULT_BIN_TIMEOUT) => Run.collect(ChildProcess.make(command.command, command.args, {
|
|
301
|
+
...command.options,
|
|
302
|
+
stdin: "ignore"
|
|
303
|
+
}), { timeout }).pipe(Effect.mapError((cause) => failure("BinFailed", cause._tag === "CommandFailedError" && cause.kind === "timeout" ? `${manager}: ${name} timed out after ${describeDuration(timeout)}` : `${manager}: ${name} could not run`, {
|
|
304
|
+
manager,
|
|
305
|
+
cause
|
|
306
|
+
})));
|
|
307
|
+
/**
|
|
308
|
+
* What a packed install produced.
|
|
309
|
+
*
|
|
310
|
+
* @public
|
|
311
|
+
*/
|
|
312
|
+
var PackedInstallResult = class extends Schema.Class("PackedInstallResult")({
|
|
313
|
+
/** One consumer per available manager, in the order requested. */
|
|
314
|
+
consumers: Schema.Array(InstalledConsumer),
|
|
315
|
+
/** The requested managers that did not answer `--version`. */
|
|
316
|
+
unavailable: Schema.Array(PackageManagerName),
|
|
317
|
+
/** Every packed package: name to absolute tarball path. */
|
|
318
|
+
tarballs: Schema.Record(Schema.String, Schema.String),
|
|
319
|
+
/**
|
|
320
|
+
* The scratch root (realpath'd) holding the tarballs and every consumer.
|
|
321
|
+
* It is removed when the scope that ran `PackedInstall.run` closes, so a
|
|
322
|
+
* directory made under it, such as an `XDG_DATA_HOME` for the bins, is
|
|
323
|
+
* cleaned up with it.
|
|
324
|
+
*/
|
|
325
|
+
scratch: Schema.String
|
|
326
|
+
}) {};
|
|
327
|
+
const DEFAULT_PACK_FROM = { directory: "dist/prod/npm/pkg" };
|
|
328
|
+
const WORKSPACE_PROTOCOL_NOT_INSTALLED = "ERR_PNPM_CANNOT_RESOLVE_WORKSPACE_PROTOCOL";
|
|
329
|
+
const TAIL = 2e3;
|
|
330
|
+
const tail = (text) => text.length <= TAIL ? text : text.slice(-2e3);
|
|
331
|
+
const DEFAULT_INSTALL_TIMEOUT = "4 minutes";
|
|
332
|
+
const PROBE_TIMEOUT = "30 seconds";
|
|
333
|
+
const DEFAULT_PACK_TIMEOUT = "2 minutes";
|
|
334
|
+
const MANIFEST_TIMEOUT = "30 seconds";
|
|
335
|
+
/** The untimed steps a budget allows for: discovery, the consumer files and the bin checks. */
|
|
336
|
+
const UNTIMED_SLACK = "30 seconds";
|
|
337
|
+
/**
|
|
338
|
+
* Cleanup a budget allows for: removing the scratch root, every consumer's
|
|
339
|
+
* `node_modules` included, when the scope closes, and killing and reaping a
|
|
340
|
+
* child after its ceiling interrupts it.
|
|
341
|
+
*/
|
|
342
|
+
const CLEANUP_ALLOWANCE = "1 minute";
|
|
343
|
+
/** A ceiling as a person reads it: `"4m"` for `"4 minutes"`, the raw input if it does not decode. */
|
|
344
|
+
const describeDuration = (input) => Option.match(Duration.fromInput(input), {
|
|
345
|
+
onNone: () => String(input),
|
|
346
|
+
onSome: Duration.format
|
|
347
|
+
});
|
|
348
|
+
const failure = (reason, message, extra = {}) => new PackedInstallError({
|
|
349
|
+
reason,
|
|
350
|
+
message,
|
|
351
|
+
...extra
|
|
352
|
+
});
|
|
353
|
+
const namesOf = (plan) => [...plan.closure.map((pkg) => pkg.name), ...plan.replacements.map((replacement) => replacement.name)];
|
|
354
|
+
/**
|
|
355
|
+
* The one implementation of "what does this run pack", behind both
|
|
356
|
+
* `PackedInstall.closure` and `PackedInstall.run`, so the two cannot drift.
|
|
357
|
+
*/
|
|
358
|
+
const planClosure = (carrier, options) => Effect.gen(function* () {
|
|
359
|
+
const fs = yield* FileSystem.FileSystem;
|
|
360
|
+
const path = yield* Path.Path;
|
|
361
|
+
const discovery = yield* WorkspaceDiscovery;
|
|
362
|
+
const discoveryFailed = (cause) => failure("Discovery", "workspace discovery failed", { cause });
|
|
363
|
+
const packages = yield* discovery.listPackages().pipe(Effect.mapError(discoveryFailed));
|
|
364
|
+
const closure = closureOf(packages, carrier, options.closure ?? "auto");
|
|
365
|
+
if (Result.isFailure(closure)) return yield* failure("UnknownPackage", `${closure.failure} is not a package of this workspace`, { package: closure.failure });
|
|
366
|
+
const requested = Object.entries(options.overrides ?? {}).map(([name, spec]) => [name, overridePath(spec)]);
|
|
367
|
+
const root = options.workspaceOverrides === true || requested.some(([, spec]) => !path.isAbsolute(spec)) ? (yield* discovery.info().pipe(Effect.mapError(discoveryFailed))).root : "/";
|
|
368
|
+
const wanted = /* @__PURE__ */ new Map();
|
|
369
|
+
if (options.workspaceOverrides === true) {
|
|
370
|
+
const file = path.join(root, "pnpm-workspace.yaml");
|
|
371
|
+
const text = yield* fs.readFileString(file).pipe(Effect.mapError((cause) => failure("InvalidOverride", `workspaceOverrides reads ${file}, which could not be read`, { cause })));
|
|
372
|
+
const document = yield* Yaml.parse(text).pipe(Effect.mapError((cause) => failure("InvalidOverride", `${file} is not valid YAML`, { cause })));
|
|
373
|
+
for (const [name, spec] of Object.entries(fileOverridesOf(document))) wanted.set(name, spec);
|
|
374
|
+
}
|
|
375
|
+
for (const [name, spec] of requested) wanted.set(name, spec);
|
|
376
|
+
const packed = new Set(closure.success.map((pkg) => pkg.name));
|
|
377
|
+
const replacements = [];
|
|
378
|
+
for (const name of [...wanted.keys()].sort()) {
|
|
379
|
+
const invalid = (message, cause) => failure("InvalidOverride", `override ${name}: ${message}`, {
|
|
380
|
+
package: name,
|
|
381
|
+
...cause === void 0 ? {} : { cause }
|
|
382
|
+
});
|
|
383
|
+
if (packed.has(name)) return yield* invalid(`${name === carrier ? "the carrier" : "a closure member"} is always packed from the workspace; overrides replace only packages outside it`);
|
|
384
|
+
const spec = wanted.get(name) ?? "";
|
|
385
|
+
const absolute = path.isAbsolute(spec) ? spec : path.resolve(root, spec);
|
|
386
|
+
const source = yield* fs.realPath(absolute).pipe(Effect.catch((error) => error.reason._tag === "NotFound" ? Effect.fail(invalid(`${absolute} does not exist`)) : Effect.fail(failure("Io", `could not resolve ${absolute}`, {
|
|
387
|
+
package: name,
|
|
388
|
+
cause: error
|
|
389
|
+
}))));
|
|
390
|
+
const info = yield* fs.stat(source).pipe(Effect.mapError((cause) => failure("Io", `could not inspect ${source}`, {
|
|
391
|
+
package: name,
|
|
392
|
+
cause
|
|
393
|
+
})));
|
|
394
|
+
if (info.type === "Directory") {
|
|
395
|
+
const manifest = path.join(source, "package.json");
|
|
396
|
+
if (!(yield* fs.exists(manifest).pipe(Effect.mapError((cause) => failure("Io", `could not inspect ${manifest}`, {
|
|
397
|
+
package: name,
|
|
398
|
+
cause
|
|
399
|
+
}))))) return yield* invalid(`${source} has no package.json to pack`);
|
|
400
|
+
replacements.push({
|
|
401
|
+
name,
|
|
402
|
+
source,
|
|
403
|
+
kind: "directory"
|
|
404
|
+
});
|
|
405
|
+
} else if (info.type === "File" && source.endsWith(".tgz")) replacements.push({
|
|
406
|
+
name,
|
|
407
|
+
source,
|
|
408
|
+
kind: "tarball"
|
|
409
|
+
});
|
|
410
|
+
else return yield* invalid(`${source} is neither a package directory nor a .tgz`);
|
|
411
|
+
}
|
|
412
|
+
return {
|
|
413
|
+
closure: closure.success,
|
|
414
|
+
replacements
|
|
415
|
+
};
|
|
416
|
+
});
|
|
417
|
+
/**
|
|
418
|
+
* Prove a carrier's bins reach a consumer that is not part of the workspace,
|
|
419
|
+
* once per available package manager.
|
|
420
|
+
*
|
|
421
|
+
* @remarks
|
|
422
|
+
* Packs the carrier and its closure into a scoped, realpath'd scratch
|
|
423
|
+
* directory (so `file:` specs and the install cwd agree on macOS). Then, for
|
|
424
|
+
* each manager that answers `--version` from that directory (so a corepack
|
|
425
|
+
* pin in the repo cannot refuse it), it writes a consumer whose only direct
|
|
426
|
+
* dependency is the carrier tarball, steers the closure to its tarballs
|
|
427
|
+
* through the manager's own override field, pins the probed version (as
|
|
428
|
+
* `packageManager`, or for pnpm as `devEngines.packageManager` with
|
|
429
|
+
* `onFail: "ignore"`, since pnpm resolves a `packageManager` pin from the registry even
|
|
430
|
+
* when it names the running version), installs with lifecycle scripts skipped,
|
|
431
|
+
* and checks every
|
|
432
|
+
* expected bin is present and executable. A packed manifest that still
|
|
433
|
+
* carries `workspace:`, `catalog:`, `link:` or a relative `file:` specifier
|
|
434
|
+
* fails `UnresolvedProtocol` before any install.
|
|
435
|
+
*
|
|
436
|
+
* It asserts nothing about what the bins DO: run them from the test through
|
|
437
|
+
* `InstalledConsumer.runBin`, or for an MCP bin through `McpProbe` from
|
|
438
|
+
* `@effected/mcp/testing` at `InstalledConsumer.binPath`, inside the same
|
|
439
|
+
* scope, because the scratch directory (`PackedInstallResult.scratch`) is
|
|
440
|
+
* removed when the scope closes. Size the test's outer timeout with
|
|
441
|
+
* {@link PackedInstall.timeoutBudget}. POSIX only.
|
|
442
|
+
*
|
|
443
|
+
* @example
|
|
444
|
+
* ```ts
|
|
445
|
+
* import { NodeServices } from "@effect/platform-node";
|
|
446
|
+
* import { Workspaces } from "@effected/workspaces";
|
|
447
|
+
* import { PackedInstall } from "@effected/workspaces/testing";
|
|
448
|
+
* import { Effect, Layer } from "effect";
|
|
449
|
+
*
|
|
450
|
+
* const Live = Workspaces.layer({ cwd: "/repo" }).pipe(Layer.provideMerge(NodeServices.layer));
|
|
451
|
+
*
|
|
452
|
+
* // Use the consumers INSIDE the scope: closing it removes the scratch
|
|
453
|
+
* // directory, so a binPath returned out of Effect.scoped points at nothing.
|
|
454
|
+
* const program = Effect.gen(function* () {
|
|
455
|
+
* const result = yield* PackedInstall.run({
|
|
456
|
+
* carrier: "my-tool",
|
|
457
|
+
* closure: "auto",
|
|
458
|
+
* managers: ["npm", "pnpm"],
|
|
459
|
+
* bins: ["my-tool"],
|
|
460
|
+
* env: process.env,
|
|
461
|
+
* });
|
|
462
|
+
* // Keep the tool's data inside the scratch root, removed with it.
|
|
463
|
+
* const env = { XDG_DATA_HOME: `${result.scratch}/xdg` };
|
|
464
|
+
* for (const consumer of result.consumers) {
|
|
465
|
+
* const { stdout, exitCode } = yield* consumer.runBin("my-tool", ["--version"], { env });
|
|
466
|
+
* console.log(consumer.manager, exitCode, stdout.trim());
|
|
467
|
+
* }
|
|
468
|
+
* }).pipe(Effect.scoped, Effect.provide(Live));
|
|
469
|
+
* ```
|
|
470
|
+
*
|
|
471
|
+
* @public
|
|
472
|
+
*/
|
|
473
|
+
var PackedInstall = class PackedInstall {
|
|
474
|
+
constructor() {}
|
|
475
|
+
/** `env` without undefined values or the parent manager's context: the environment the installs run under. Reuse it to run the installed bins. */
|
|
476
|
+
static scrubEnv = scrubEnv;
|
|
477
|
+
/**
|
|
478
|
+
* The ceiling a test's outer timeout should cover so that every one of the
|
|
479
|
+
* run's own ceilings fires first, as a named `PackedInstallError`, rather
|
|
480
|
+
* than the outer guard's `TimeoutError` that names nothing.
|
|
481
|
+
*
|
|
482
|
+
* @remarks
|
|
483
|
+
* The worst case of the run's own ceilings, taken in sequence as the run
|
|
484
|
+
* takes them: each manager's `--version` probe (30 seconds), install
|
|
485
|
+
* (`installTimeout`) and `perConsumer` (one minute by default, one `runBin`
|
|
486
|
+
* at its default ceiling), plus each package's pack
|
|
487
|
+
* (`packTimeout`, two minutes by default) and manifest read (30 seconds),
|
|
488
|
+
* plus 30 seconds for the untimed steps and one minute for cleanup:
|
|
489
|
+
* removing the scratch root when the scope closes, and killing a child
|
|
490
|
+
* whose ceiling interrupted it. It reads the same defaults the run does.
|
|
491
|
+
* Pass `packages` as the names {@link PackedInstall.closure} returns to
|
|
492
|
+
* size it before the run. A vitest test timeout must sit above it, since
|
|
493
|
+
* vitest's own guard should not pre-empt the Effect's.
|
|
494
|
+
*
|
|
495
|
+
* @example
|
|
496
|
+
* ```ts
|
|
497
|
+
* import { PackedInstall } from "@effected/workspaces/testing";
|
|
498
|
+
* import { Duration } from "effect";
|
|
499
|
+
*
|
|
500
|
+
* const budget = PackedInstall.timeoutBudget({
|
|
501
|
+
* managers: ["npm", "pnpm", "yarn", "bun"],
|
|
502
|
+
* installTimeout: "3 minutes",
|
|
503
|
+
* packTimeout: "30 seconds",
|
|
504
|
+
* packages: ["my-tool", "my-tool-cli", "my-tool-core"],
|
|
505
|
+
* perConsumer: "2 minutes",
|
|
506
|
+
* });
|
|
507
|
+
* console.log(Duration.format(budget));
|
|
508
|
+
* // => 26m 30s
|
|
509
|
+
* ```
|
|
510
|
+
*/
|
|
511
|
+
static timeoutBudget = (budget) => {
|
|
512
|
+
const perManager = [
|
|
513
|
+
PROBE_TIMEOUT,
|
|
514
|
+
budget.installTimeout ?? DEFAULT_INSTALL_TIMEOUT,
|
|
515
|
+
budget.perConsumer ?? DEFAULT_BIN_TIMEOUT
|
|
516
|
+
].map(Duration.fromInputUnsafe).reduce((total, step) => Duration.sum(total, step), Duration.zero);
|
|
517
|
+
const perPackage = Duration.sum(Duration.fromInputUnsafe(budget.packTimeout ?? DEFAULT_PACK_TIMEOUT), Duration.fromInputUnsafe(MANIFEST_TIMEOUT));
|
|
518
|
+
const packages = typeof budget.packages === "number" ? budget.packages : budget.packages.length;
|
|
519
|
+
return Duration.sum(Duration.sum(Duration.times(perManager, new Set(budget.managers).size), Duration.times(perPackage, Math.max(1, packages))), Duration.sum(Duration.fromInputUnsafe(UNTIMED_SLACK), Duration.fromInputUnsafe(CLEANUP_ALLOWANCE)));
|
|
520
|
+
};
|
|
521
|
+
/**
|
|
522
|
+
* The packages a run packs, in the order it packs them, without packing
|
|
523
|
+
* anything: the carrier, the rest of the closure, then the `overrides`
|
|
524
|
+
* packages by name.
|
|
525
|
+
*
|
|
526
|
+
* @remarks
|
|
527
|
+
* The same implementation {@link PackedInstall.run} plans with, so for the
|
|
528
|
+
* same workspace and options the result equals the keys of
|
|
529
|
+
* `PackedInstallResult.tarballs`, in order. `options.closure` defaults to
|
|
530
|
+
* `"auto"`, and the run's own options object is accepted as it is. It
|
|
531
|
+
* fails as the run would before packing: `UnknownPackage`, `Discovery`,
|
|
532
|
+
* `InvalidOverride` or `Io`. Use it to size
|
|
533
|
+
* {@link PackedInstall.timeoutBudget} before the test is declared.
|
|
534
|
+
*
|
|
535
|
+
* @example
|
|
536
|
+
* ```ts
|
|
537
|
+
* import { NodeServices } from "@effect/platform-node";
|
|
538
|
+
* import { Workspaces } from "@effected/workspaces";
|
|
539
|
+
* import { PackedInstall } from "@effected/workspaces/testing";
|
|
540
|
+
* import { Effect, Layer } from "effect";
|
|
541
|
+
*
|
|
542
|
+
* const Live = Workspaces.layer({ cwd: "/repo" }).pipe(Layer.provideMerge(NodeServices.layer));
|
|
543
|
+
* const packages = await Effect.runPromise(PackedInstall.closure("my-tool").pipe(Effect.provide(Live)));
|
|
544
|
+
* const budget = PackedInstall.timeoutBudget({ managers: ["npm", "pnpm"], packages });
|
|
545
|
+
* ```
|
|
546
|
+
*
|
|
547
|
+
* @param carrier - The carrier, as the run's `carrier`.
|
|
548
|
+
* @param options - The closure and override options, as the run takes them.
|
|
549
|
+
*/
|
|
550
|
+
static closure = (carrier, options = {}) => planClosure(carrier, options).pipe(Effect.map(namesOf), Effect.withSpan("PackedInstall.closure"));
|
|
551
|
+
/**
|
|
552
|
+
* {@link PackedInstall.timeoutBudget} for exactly the run `options`
|
|
553
|
+
* describes, planned from the options themselves.
|
|
554
|
+
*
|
|
555
|
+
* @remarks
|
|
556
|
+
* Plans the closure with {@link PackedInstall.closure} (the run's own
|
|
557
|
+
* planner, `overrides` and `workspaceOverrides` included) and takes
|
|
558
|
+
* `managers`, `installTimeout` and `packTimeout` from the same object, so
|
|
559
|
+
* nothing is written twice. `perConsumer` is the one input a run's options
|
|
560
|
+
* do not carry. It is an Effect because planning reads the workspace; in a
|
|
561
|
+
* vitest file, await it at module evaluation, since a test's timeout is
|
|
562
|
+
* fixed when the test is declared. Fails as `closure` does.
|
|
563
|
+
*
|
|
564
|
+
* @example
|
|
565
|
+
* ```ts
|
|
566
|
+
* import { NodeServices } from "@effect/platform-node";
|
|
567
|
+
* import { Workspaces } from "@effected/workspaces";
|
|
568
|
+
* import type { PackedInstallOptions } from "@effected/workspaces/testing";
|
|
569
|
+
* import { PackedInstall } from "@effected/workspaces/testing";
|
|
570
|
+
* import { Duration, Effect, Layer } from "effect";
|
|
571
|
+
*
|
|
572
|
+
* const Live = Workspaces.layer({ cwd: "/repo" }).pipe(Layer.provideMerge(NodeServices.layer));
|
|
573
|
+
* const RUN: PackedInstallOptions = {
|
|
574
|
+
* carrier: "my-tool",
|
|
575
|
+
* closure: "auto",
|
|
576
|
+
* managers: ["npm", "pnpm"],
|
|
577
|
+
* bins: ["my-tool"],
|
|
578
|
+
* env: process.env,
|
|
579
|
+
* packTimeout: "30 seconds",
|
|
580
|
+
* };
|
|
581
|
+
* const budget = await Effect.runPromise(
|
|
582
|
+
* PackedInstall.timeoutBudgetFor(RUN, { perConsumer: "1 minute" }).pipe(Effect.provide(Live)),
|
|
583
|
+
* );
|
|
584
|
+
* const TEST_TIMEOUT_MS = Duration.toMillis(budget) + 60_000;
|
|
585
|
+
* ```
|
|
586
|
+
*
|
|
587
|
+
* @param options - The run's options, the same object passed to `run`.
|
|
588
|
+
* @param extra - What the test does with each consumer afterwards.
|
|
589
|
+
*/
|
|
590
|
+
static timeoutBudgetFor = (options, extra = {}) => PackedInstall.closure(options.carrier, options).pipe(Effect.map((packages) => PackedInstall.timeoutBudget({
|
|
591
|
+
managers: options.managers,
|
|
592
|
+
packages,
|
|
593
|
+
...options.installTimeout === void 0 ? {} : { installTimeout: options.installTimeout },
|
|
594
|
+
...options.packTimeout === void 0 ? {} : { packTimeout: options.packTimeout },
|
|
595
|
+
...extra.perConsumer === void 0 ? {} : { perConsumer: extra.perConsumer }
|
|
596
|
+
})));
|
|
597
|
+
/** Pack, then install under every available manager. */
|
|
598
|
+
static run = Effect.fn("PackedInstall.run")(function* (options) {
|
|
599
|
+
const fs = yield* FileSystem.FileSystem;
|
|
600
|
+
const path = yield* Path.Path;
|
|
601
|
+
if (path.sep !== "/") return yield* failure("UnsupportedPlatform", "PackedInstall drives POSIX .bin shims and tar; run it on macOS or Linux");
|
|
602
|
+
const managers = [...new Set(options.managers)];
|
|
603
|
+
if (managers.length === 0) return yield* failure("NoManagerAvailable", "managers is empty: name at least one package manager to install with");
|
|
604
|
+
const env = scrubEnv(options.env);
|
|
605
|
+
const io = (message) => (cause) => failure("Io", message, { cause });
|
|
606
|
+
const command = (executable, args, cwd) => ChildProcess.make(executable, args, {
|
|
607
|
+
cwd,
|
|
608
|
+
env,
|
|
609
|
+
extendEnv: false,
|
|
610
|
+
stdin: "ignore"
|
|
611
|
+
});
|
|
612
|
+
const scratch = yield* fs.makeTempDirectoryScoped({ prefix: "effected-packed-install-" }).pipe(Effect.flatMap((directory) => fs.realPath(directory)), Effect.mapError(io("could not create the scratch directory")));
|
|
613
|
+
const probes = yield* Effect.forEach(managers, (manager) => Run.collect(command(manager, ["--version"], scratch), { timeout: PROBE_TIMEOUT }).pipe(Effect.map((output) => output.succeeded ? versionOf(output.stdout) : void 0), Effect.catch(() => Effect.succeed(void 0)), Effect.map((version) => ({
|
|
614
|
+
manager,
|
|
615
|
+
version
|
|
616
|
+
}))));
|
|
617
|
+
const available = probes.flatMap(({ manager, version }) => version === void 0 ? [] : [{
|
|
618
|
+
manager,
|
|
619
|
+
version
|
|
620
|
+
}]);
|
|
621
|
+
const unavailable = probes.flatMap(({ manager, version }) => version === void 0 ? [manager] : []);
|
|
622
|
+
if (available.length === 0) return yield* failure("NoManagerAvailable", `none of ${managers.join(", ")} answered --version from ${scratch}`);
|
|
623
|
+
const missing = unavailable[0];
|
|
624
|
+
if (options.require === "all" && missing !== void 0) return yield* failure("ManagerUnavailable", `${missing} is required but did not answer --version`, { manager: missing });
|
|
625
|
+
const plan = yield* planClosure(options.carrier, options);
|
|
626
|
+
const source = options.packFrom ?? DEFAULT_PACK_FROM;
|
|
627
|
+
const packTimeout = options.packTimeout ?? DEFAULT_PACK_TIMEOUT;
|
|
628
|
+
/** `npm pack` or `pnpm pack` in `cwd` into an empty `destination`; the one tarball it wrote. */
|
|
629
|
+
const pack = (name, cwd, packer, destination) => Effect.gen(function* () {
|
|
630
|
+
yield* fs.makeDirectory(destination, { recursive: true }).pipe(Effect.mapError(io(`could not create ${destination}`)));
|
|
631
|
+
const args = packer === "pnpm" ? [
|
|
632
|
+
"pack",
|
|
633
|
+
"--pack-destination",
|
|
634
|
+
destination,
|
|
635
|
+
"--config.ignore-scripts=true"
|
|
636
|
+
] : [
|
|
637
|
+
"pack",
|
|
638
|
+
"--ignore-scripts",
|
|
639
|
+
"--pack-destination",
|
|
640
|
+
destination
|
|
641
|
+
];
|
|
642
|
+
const output = yield* Run.collect(command(packer, args, cwd), { timeout: packTimeout }).pipe(Effect.mapError((cause) => failure("PackFailed", cause._tag === "CommandFailedError" && cause.kind === "timeout" ? `${packer} pack timed out after ${describeDuration(packTimeout)} for ${name}` : `${packer} pack could not run for ${name}`, {
|
|
643
|
+
package: name,
|
|
644
|
+
cause
|
|
645
|
+
})));
|
|
646
|
+
if (!output.succeeded) {
|
|
647
|
+
const notInstalled = packer === "pnpm" && `${output.stdout}${output.stderr}`.includes(WORKSPACE_PROTOCOL_NOT_INSTALLED);
|
|
648
|
+
return yield* failure("PackFailed", notInstalled ? `pnpm pack could not rewrite the workspace: specifiers of ${name} because the workspace is not installed; run pnpm install in the workspace first` : `${packer} pack exited ${output.exitCode} for ${name}`, {
|
|
649
|
+
package: name,
|
|
650
|
+
output: tail(`${output.stdout}\n${output.stderr}`)
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
const found = (yield* fs.readDirectory(destination).pipe(Effect.mapError(io(`could not list ${destination}`)))).filter((file) => file.endsWith(".tgz"));
|
|
654
|
+
const [only] = found;
|
|
655
|
+
if (only === void 0 || found.length !== 1) return yield* failure("PackFailed", `expected exactly one tarball for ${name} in ${destination}, found ${found.length}`, { package: name });
|
|
656
|
+
return path.join(destination, only);
|
|
657
|
+
});
|
|
658
|
+
/** The packed manifest, read with `tar` from `cwd`, refused if only a workspace could resolve it. */
|
|
659
|
+
const inspect = (name, tarball, cwd) => Effect.gen(function* () {
|
|
660
|
+
const text = yield* Run.text(command("tar", [
|
|
661
|
+
"-xzOf",
|
|
662
|
+
tarball,
|
|
663
|
+
"package/package.json"
|
|
664
|
+
], cwd), { timeout: MANIFEST_TIMEOUT }).pipe(Effect.mapError((cause) => failure("PackFailed", `could not read package/package.json out of ${tarball}`, {
|
|
665
|
+
package: name,
|
|
666
|
+
cause
|
|
667
|
+
})));
|
|
668
|
+
const manifest = readPackedManifest(text);
|
|
669
|
+
if (Result.isFailure(manifest)) return yield* failure("PackFailed", `the packed package.json of ${name} is not a JSON object`, {
|
|
670
|
+
package: name,
|
|
671
|
+
cause: manifest.failure
|
|
672
|
+
});
|
|
673
|
+
if (manifest.success.unresolved.length > 0) return yield* failure("UnresolvedProtocol", `${name}'s packed manifest still carries ${manifest.success.unresolved.join(", ")}, which no consumer outside the workspace can resolve; pack from "source" so pnpm rewrites workspace: and catalog:, or fix the build`, { package: name });
|
|
674
|
+
return manifest.success;
|
|
675
|
+
});
|
|
676
|
+
const packed = [];
|
|
677
|
+
for (const [index, pkg] of plan.closure.entries()) {
|
|
678
|
+
const destination = path.join(scratch, "tarballs", String(index));
|
|
679
|
+
const cwd = source === "source" ? pkg.path : path.join(pkg.path, source.directory);
|
|
680
|
+
if (source !== "source") {
|
|
681
|
+
if (!(yield* fs.exists(path.join(cwd, "package.json")).pipe(Effect.mapError(io(`could not inspect ${cwd}`))))) return yield* failure("PackSourceMissing", `${cwd} has no package.json; build ${pkg.name} before packing it`, { package: pkg.name });
|
|
682
|
+
}
|
|
683
|
+
const tarball = yield* pack(pkg.name, cwd, source === "source" ? "pnpm" : "npm", destination);
|
|
684
|
+
packed.push({
|
|
685
|
+
name: pkg.name,
|
|
686
|
+
tarball,
|
|
687
|
+
manifest: yield* inspect(pkg.name, tarball, destination)
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
for (const [offset, replacement] of plan.replacements.entries()) {
|
|
691
|
+
const destination = path.join(scratch, "tarballs", String(plan.closure.length + offset));
|
|
692
|
+
const tarball = replacement.kind === "tarball" ? replacement.source : yield* pack(replacement.name, replacement.source, "npm", destination);
|
|
693
|
+
const manifest = yield* inspect(replacement.name, tarball, scratch);
|
|
694
|
+
if (manifest.name !== replacement.name) return yield* failure("InvalidOverride", `override ${replacement.name}: ${replacement.source} packs ${manifest.name ?? "a package with no name"}, not ${replacement.name}`, { package: replacement.name });
|
|
695
|
+
packed.push({
|
|
696
|
+
name: replacement.name,
|
|
697
|
+
tarball,
|
|
698
|
+
manifest
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
const [carrier, ...rest] = packed;
|
|
702
|
+
if (carrier === void 0) return yield* failure("UnknownPackage", `${options.carrier} was not packed`, { package: options.carrier });
|
|
703
|
+
const conflict = options.allowSharedBins === true ? void 0 : binConflict({
|
|
704
|
+
name: carrier.name,
|
|
705
|
+
bins: carrier.manifest.bins
|
|
706
|
+
}, rest.map(({ name, manifest }) => ({
|
|
707
|
+
name,
|
|
708
|
+
bins: manifest.bins
|
|
709
|
+
})));
|
|
710
|
+
if (conflict !== void 0) return yield* failure("BinConflict", `${conflict.package} declares the bin ${conflict.bin}, which the carrier ${carrier.name} declares; under a flat layout (npm, bun, Yarn's node-modules linker) either can take node_modules/.bin/${conflict.bin} and the carrier's provenance is lost there; drop the bin from ${conflict.package}, or pass allowSharedBins: true to share it deliberately`, { package: conflict.package });
|
|
711
|
+
const overrides = Object.fromEntries(rest.map(({ name, tarball }) => [name, tarball]));
|
|
712
|
+
const consumers = yield* Effect.forEach(available, ({ manager, version }) => Effect.gen(function* () {
|
|
713
|
+
const directory = path.join(scratch, `consumer-${manager}`);
|
|
714
|
+
yield* fs.makeDirectory(directory, { recursive: true }).pipe(Effect.mapError(io(`could not create ${directory}`)));
|
|
715
|
+
for (const { file, content } of consumerFiles({
|
|
716
|
+
manager,
|
|
717
|
+
version,
|
|
718
|
+
carrier,
|
|
719
|
+
overrides,
|
|
720
|
+
dependencies: options.consumerDependencies ?? {}
|
|
721
|
+
})) yield* fs.writeFileString(path.join(directory, file), content).pipe(Effect.mapError(io(`could not write ${file}`)));
|
|
722
|
+
const installTimeout = options.installTimeout ?? DEFAULT_INSTALL_TIMEOUT;
|
|
723
|
+
const output = yield* Run.collect(command(manager, installArgs(manager, version), directory), { timeout: installTimeout }).pipe(Effect.mapError((cause) => failure("InstallFailed", cause._tag === "CommandFailedError" && cause.kind === "timeout" ? `${manager} install timed out after ${describeDuration(installTimeout)}` : `${manager} install could not run`, {
|
|
724
|
+
manager,
|
|
725
|
+
cause
|
|
726
|
+
})));
|
|
727
|
+
if (!output.succeeded) return yield* failure("InstallFailed", `${manager} install exited ${output.exitCode}`, {
|
|
728
|
+
manager,
|
|
729
|
+
output: tail(`${output.stdout}\n${output.stderr}`)
|
|
730
|
+
});
|
|
731
|
+
const consumer = InstalledConsumer.make({
|
|
732
|
+
manager,
|
|
733
|
+
managerVersion: version,
|
|
734
|
+
directory,
|
|
735
|
+
env: Redacted.make(env),
|
|
736
|
+
carrier: carrier.name
|
|
737
|
+
});
|
|
738
|
+
for (const bin of options.bins) {
|
|
739
|
+
const info = yield* Effect.option(fs.stat(consumer.binPath(bin)));
|
|
740
|
+
if (Option.isNone(info) || (info.value.mode & 73) === 0) {
|
|
741
|
+
const listing = yield* fs.readDirectory(path.join(directory, "node_modules", ".bin")).pipe(Effect.map((names) => names.length === 0 ? "node_modules/.bin is empty" : `node_modules/.bin holds: ${[...names].sort().join(", ")}`), Effect.catch(() => Effect.succeed("node_modules/.bin does not exist or cannot be listed")));
|
|
742
|
+
return yield* failure("MissingBin", `${manager} installed ${options.carrier} but node_modules/.bin/${bin} is missing or not executable; ${listing}`, {
|
|
743
|
+
manager,
|
|
744
|
+
package: options.carrier,
|
|
745
|
+
output: listing
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
return consumer;
|
|
750
|
+
}));
|
|
751
|
+
return PackedInstallResult.make({
|
|
752
|
+
consumers,
|
|
753
|
+
unavailable,
|
|
754
|
+
tarballs: Object.fromEntries(packed.map(({ name, tarball }) => [name, tarball])),
|
|
755
|
+
scratch
|
|
756
|
+
});
|
|
757
|
+
});
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
//#endregion
|
|
761
|
+
export { InstalledConsumer, PackedInstall, PackedInstallError, PackedInstallResult };
|