@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.
@@ -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 };