@effected/workspaces 0.26.0 → 0.28.0-next-20260927062352

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/PackedInstall.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { WorkspaceDiscovery } from "./WorkspaceDiscovery.js";
2
2
  import { PackageManagerName } from "./PackageManagerName.js";
3
- import { closureOf, consumerFiles, installArgs, scrubEnv, unresolvedSpecifiers, versionOf } from "./internal/packedInstallPlan.js";
4
- import { Duration, Effect, FileSystem, Option, Path, Result, Schema } from "effect";
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";
5
6
  import { Run } from "@effected/commands";
6
7
  import { ChildProcess } from "effect/unstable/process";
7
8
 
@@ -21,8 +22,12 @@ var PackedInstallError = class extends Schema.TaggedError()("PackedInstallError"
21
22
  "PackSourceMissing",
22
23
  "PackFailed",
23
24
  "UnresolvedProtocol",
25
+ "InvalidOverride",
26
+ "BinConflict",
24
27
  "InstallFailed",
25
28
  "MissingBin",
29
+ "BinFailed",
30
+ "UnownedBin",
26
31
  "Discovery",
27
32
  "Io"
28
33
  ]),
@@ -37,6 +42,14 @@ var PackedInstallError = class extends Schema.TaggedError()("PackedInstallError"
37
42
  /** The originating failure. */
38
43
  cause: Schema.optionalKey(Schema.Defect())
39
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";
40
53
  /**
41
54
  * One scratch project, outside the workspace, with the carrier installed.
42
55
  *
@@ -48,13 +61,249 @@ var InstalledConsumer = class extends Schema.Class("InstalledConsumer")({
48
61
  /** The version it reported and was pinned to. */
49
62
  managerVersion: Schema.String,
50
63
  /** The consumer project directory (realpath'd). */
51
- directory: Schema.String
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)
52
77
  }) {
53
78
  /** The installed bin `name`, in `node_modules/.bin`. POSIX: `PackedInstall` runs only there. */
54
79
  binPath(name) {
55
80
  return `${this.directory}/node_modules/.bin/${name}`;
56
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;
57
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
+ })));
58
307
  /**
59
308
  * What a packed install produced.
60
309
  *
@@ -66,13 +315,31 @@ var PackedInstallResult = class extends Schema.Class("PackedInstallResult")({
66
315
  /** The requested managers that did not answer `--version`. */
67
316
  unavailable: Schema.Array(PackageManagerName),
68
317
  /** Every packed package: name to absolute tarball path. */
69
- tarballs: Schema.Record(Schema.String, Schema.String)
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
70
326
  }) {};
71
327
  const DEFAULT_PACK_FROM = { directory: "dist/prod/npm/pkg" };
72
328
  const WORKSPACE_PROTOCOL_NOT_INSTALLED = "ERR_PNPM_CANNOT_RESOLVE_WORKSPACE_PROTOCOL";
73
329
  const TAIL = 2e3;
74
330
  const tail = (text) => text.length <= TAIL ? text : text.slice(-2e3);
75
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";
76
343
  /** A ceiling as a person reads it: `"4m"` for `"4 minutes"`, the raw input if it does not decode. */
77
344
  const describeDuration = (input) => Option.match(Duration.fromInput(input), {
78
345
  onNone: () => String(input),
@@ -83,6 +350,70 @@ const failure = (reason, message, extra = {}) => new PackedInstallError({
83
350
  message,
84
351
  ...extra
85
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
+ });
86
417
  /**
87
418
  * Prove a carrier's bins reach a consumer that is not part of the workspace,
88
419
  * once per available package manager.
@@ -94,8 +425,8 @@ const failure = (reason, message, extra = {}) => new PackedInstallError({
94
425
  * pin in the repo cannot refuse it), it writes a consumer whose only direct
95
426
  * dependency is the carrier tarball, steers the closure to its tarballs
96
427
  * through the manager's own override field, pins the probed version (as
97
- * `packageManager`, or for pnpm as `devEngines.packageManager` with `onFail:
98
- * "ignore"`, since pnpm resolves a `packageManager` pin from the registry even
428
+ * `packageManager`, or for pnpm as `devEngines.packageManager` with
429
+ * `onFail: "ignore"`, since pnpm resolves a `packageManager` pin from the registry even
99
430
  * when it names the running version), installs with lifecycle scripts skipped,
100
431
  * and checks every
101
432
  * expected bin is present and executable. A packed manifest that still
@@ -103,18 +434,18 @@ const failure = (reason, message, extra = {}) => new PackedInstallError({
103
434
  * fails `UnresolvedProtocol` before any install.
104
435
  *
105
436
  * It asserts nothing about what the bins DO: run them from the test through
106
- * `InstalledConsumer.binPath`, for an MCP bin with `McpProbe` from
107
- * `@effected/mcp/testing`, inside the same scope, because the scratch
108
- * directory is removed when the scope closes. POSIX only.
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.
109
442
  *
110
443
  * @example
111
444
  * ```ts
112
445
  * import { NodeServices } from "@effect/platform-node";
113
- * import { Run } from "@effected/commands";
114
446
  * import { Workspaces } from "@effected/workspaces";
115
447
  * import { PackedInstall } from "@effected/workspaces/testing";
116
448
  * import { Effect, Layer } from "effect";
117
- * import { ChildProcess } from "effect/unstable/process";
118
449
  *
119
450
  * const Live = Workspaces.layer({ cwd: "/repo" }).pipe(Layer.provideMerge(NodeServices.layer));
120
451
  *
@@ -128,22 +459,141 @@ const failure = (reason, message, extra = {}) => new PackedInstallError({
128
459
  * bins: ["my-tool"],
129
460
  * env: process.env,
130
461
  * });
131
- * const env = PackedInstall.scrubEnv(process.env);
462
+ * // Keep the tool's data inside the scratch root, removed with it.
463
+ * const env = { XDG_DATA_HOME: `${result.scratch}/xdg` };
132
464
  * for (const consumer of result.consumers) {
133
- * const version = yield* Run.text(
134
- * ChildProcess.make(consumer.binPath("my-tool"), ["--version"], { env, extendEnv: false }),
135
- * );
136
- * console.log(consumer.manager, version.trim());
465
+ * const { stdout, exitCode } = yield* consumer.runBin("my-tool", ["--version"], { env });
466
+ * console.log(consumer.manager, exitCode, stdout.trim());
137
467
  * }
138
468
  * }).pipe(Effect.scoped, Effect.provide(Live));
139
469
  * ```
140
470
  *
141
471
  * @public
142
472
  */
143
- var PackedInstall = class {
473
+ var PackedInstall = class PackedInstall {
144
474
  constructor() {}
145
475
  /** `env` without undefined values or the parent manager's context: the environment the installs run under. Reuse it to run the installed bins. */
146
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
+ })));
147
597
  /** Pack, then install under every available manager. */
148
598
  static run = Effect.fn("PackedInstall.run")(function* (options) {
149
599
  const fs = yield* FileSystem.FileSystem;
@@ -160,7 +610,7 @@ var PackedInstall = class {
160
610
  stdin: "ignore"
161
611
  });
162
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")));
163
- const probes = yield* Effect.forEach(managers, (manager) => Run.collect(command(manager, ["--version"], scratch), { timeout: "30 seconds" }).pipe(Effect.map((output) => output.succeeded ? versionOf(output.stdout) : void 0), Effect.catch(() => Effect.succeed(void 0)), Effect.map((version) => ({
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) => ({
164
614
  manager,
165
615
  version
166
616
  }))));
@@ -172,18 +622,13 @@ var PackedInstall = class {
172
622
  if (available.length === 0) return yield* failure("NoManagerAvailable", `none of ${managers.join(", ")} answered --version from ${scratch}`);
173
623
  const missing = unavailable[0];
174
624
  if (options.require === "all" && missing !== void 0) return yield* failure("ManagerUnavailable", `${missing} is required but did not answer --version`, { manager: missing });
175
- const packages = yield* (yield* WorkspaceDiscovery).listPackages().pipe(Effect.mapError((cause) => failure("Discovery", "workspace discovery failed", { cause })));
176
- const closure = closureOf(packages, options.carrier, options.closure);
177
- if (Result.isFailure(closure)) return yield* failure("UnknownPackage", `${closure.failure} is not a package of this workspace`, { package: closure.failure });
625
+ const plan = yield* planClosure(options.carrier, options);
178
626
  const source = options.packFrom ?? DEFAULT_PACK_FROM;
179
- const pack = (pkg, destination) => Effect.gen(function* () {
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* () {
180
630
  yield* fs.makeDirectory(destination, { recursive: true }).pipe(Effect.mapError(io(`could not create ${destination}`)));
181
- const cwd = source === "source" ? pkg.path : path.join(pkg.path, source.directory);
182
- if (source !== "source") {
183
- 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 });
184
- }
185
- const packer = source === "source" ? "pnpm" : "npm";
186
- const args = source === "source" ? [
631
+ const args = packer === "pnpm" ? [
187
632
  "pack",
188
633
  "--pack-destination",
189
634
  destination,
@@ -194,44 +639,75 @@ var PackedInstall = class {
194
639
  "--pack-destination",
195
640
  destination
196
641
  ];
197
- const output = yield* Run.collect(command(packer, args, cwd), { timeout: "2 minutes" }).pipe(Effect.mapError((cause) => failure("PackFailed", `${packer} pack could not run for ${pkg.name}`, {
198
- package: pkg.name,
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,
199
644
  cause
200
645
  })));
201
646
  if (!output.succeeded) {
202
- const notInstalled = source === "source" && `${output.stdout}${output.stderr}`.includes(WORKSPACE_PROTOCOL_NOT_INSTALLED);
203
- return yield* failure("PackFailed", notInstalled ? `pnpm pack could not rewrite the workspace: specifiers of ${pkg.name} because the workspace is not installed; run pnpm install in the workspace first` : `${packer} pack exited ${output.exitCode} for ${pkg.name}`, {
204
- package: pkg.name,
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,
205
650
  output: tail(`${output.stdout}\n${output.stderr}`)
206
651
  });
207
652
  }
208
- const found = (yield* fs.readDirectory(destination).pipe(Effect.mapError(io(`could not list ${destination}`)))).filter((name) => name.endsWith(".tgz"));
653
+ const found = (yield* fs.readDirectory(destination).pipe(Effect.mapError(io(`could not list ${destination}`)))).filter((file) => file.endsWith(".tgz"));
209
654
  const [only] = found;
210
- if (only === void 0 || found.length !== 1) return yield* failure("PackFailed", `expected exactly one tarball for ${pkg.name} in ${destination}, found ${found.length}`, { package: pkg.name });
211
- const tarball = path.join(destination, only);
212
- const manifest = yield* Run.text(command("tar", [
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", [
213
661
  "-xzOf",
214
662
  tarball,
215
663
  "package/package.json"
216
- ], destination), { timeout: "30 seconds" }).pipe(Effect.mapError((cause) => failure("PackFailed", `could not read package/package.json out of ${tarball}`, {
217
- package: pkg.name,
664
+ ], cwd), { timeout: MANIFEST_TIMEOUT }).pipe(Effect.mapError((cause) => failure("PackFailed", `could not read package/package.json out of ${tarball}`, {
665
+ package: name,
218
666
  cause
219
667
  })));
220
- const unresolved = unresolvedSpecifiers(manifest);
221
- if (Result.isFailure(unresolved)) return yield* failure("PackFailed", `the packed package.json of ${pkg.name} is not a JSON object`, {
222
- package: pkg.name,
223
- cause: unresolved.failure
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
224
672
  });
225
- if (unresolved.success.length > 0) return yield* failure("UnresolvedProtocol", `${pkg.name}'s packed manifest still carries ${unresolved.success.join(", ")}, which no consumer outside the workspace can resolve; pack from "source" so pnpm rewrites workspace: and catalog:, or fix the build`, { package: pkg.name });
226
- return tarball;
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;
227
675
  });
228
676
  const packed = [];
229
- for (const [index, pkg] of closure.success.entries()) packed.push({
230
- name: pkg.name,
231
- tarball: yield* pack(pkg, path.join(scratch, "tarballs", String(index)))
232
- });
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
+ }
233
701
  const [carrier, ...rest] = packed;
234
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 });
235
711
  const overrides = Object.fromEntries(rest.map(({ name, tarball }) => [name, tarball]));
236
712
  const consumers = yield* Effect.forEach(available, ({ manager, version }) => Effect.gen(function* () {
237
713
  const directory = path.join(scratch, `consumer-${manager}`);
@@ -255,7 +731,9 @@ var PackedInstall = class {
255
731
  const consumer = InstalledConsumer.make({
256
732
  manager,
257
733
  managerVersion: version,
258
- directory
734
+ directory,
735
+ env: Redacted.make(env),
736
+ carrier: carrier.name
259
737
  });
260
738
  for (const bin of options.bins) {
261
739
  const info = yield* Effect.option(fs.stat(consumer.binPath(bin)));
@@ -273,7 +751,8 @@ var PackedInstall = class {
273
751
  return PackedInstallResult.make({
274
752
  consumers,
275
753
  unavailable,
276
- tarballs: Object.fromEntries(packed.map(({ name, tarball }) => [name, tarball]))
754
+ tarballs: Object.fromEntries(packed.map(({ name, tarball }) => [name, tarball])),
755
+ scratch
277
756
  });
278
757
  });
279
758
  };