@effected/github-actions 0.13.0 → 0.13.2

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.
@@ -1,9 +1,13 @@
1
1
  import { ActionEnvironment } from "./ActionEnvironment.js";
2
+ import { unstubbed } from "./internal/unstubbed.js";
3
+ import { digestFileHex } from "./internal/digest.js";
4
+ import { isWindowsRunner, toolCacheRoot } from "./internal/runner.js";
5
+ import { typeAt } from "./internal/fsProbe.js";
6
+ import { PNPM_EXE_PREFIX, detectMusl, isNodeScript, pnpmExeTarget, strongestSri } from "./internal/pnpmExe.js";
2
7
  import { ToolInstaller } from "./ToolInstaller.js";
3
- import { Context, Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect";
4
- import { createHash } from "node:crypto";
8
+ import { Context, Effect, FileSystem, Layer, Option, Path, Schema } from "effect";
5
9
  import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
6
- import { PackageManagerPinName } from "@effected/npm";
10
+ import { DEFAULT_REGISTRY, PackageManagerPinName } from "@effected/npm";
7
11
 
8
12
  //#region src/PackageManagerInstaller.ts
9
13
  /**
@@ -19,9 +23,11 @@ var PackageManagerInstallerError = class extends Schema.TaggedError()("PackageMa
19
23
  * entry). `integrityMismatch` — the downloaded artifact does not hash to
20
24
  * what the pin declares; nothing was cached. `integrityMissing` — the pin
21
25
  * carries no integrity and the caller asked for `requireIntegrity`.
22
- * `unsupportedPlatform` — bun publishes no build for this runner's
23
- * OS/architecture pair. `layoutUnexpected` — the artifact extracted, but
24
- * its contents are not shaped like the package manager it claims to be.
26
+ * `unsupportedPlatform` — no build exists for this runner's
27
+ * OS/architecture pair: bun publishes none, or pnpm 12 ships no
28
+ * `@pnpm/exe.*` native binary for it. `layoutUnexpected` — the artifact
29
+ * extracted, but its contents are not shaped like the package manager it
30
+ * claims to be.
25
31
  */
26
32
  reason: Schema.Literals([
27
33
  "downloadFailed",
@@ -90,10 +96,14 @@ var AmbientPackageManager = class extends Schema.Class("AmbientPackageManager")(
90
96
  * on Windows); for bun it is the entry directory itself, because the cached
91
97
  * `bun` binary is directly executable and a shim would add nothing but a
92
98
  * fork. `bins` still maps each published bin name to the underlying entry —
93
- * a Node script to run with `node` for npm/pnpm/yarn, the native binary for
94
- * bun. Calling `addPath` remains deliberately the consumer's move, and note
95
- * that `addPath` targets *subsequent* steps: a same-process probe must use
96
- * the absolute paths in `bins` or `binDir`.
99
+ * a Node script to run with `node` for npm, yarn and pnpm 11 and earlier; an
100
+ * executable to run directly for bun and for pnpm 12+, whose `pnpm` is the
101
+ * host's native binary (overlaid onto the cached wrapper the way pnpm's own
102
+ * install script does) and whose `pn`/`pnpx`/`pnx` are shell aliases of it.
103
+ * Each shim already applies that distinction. Calling `addPath` remains
104
+ * deliberately the consumer's move, and note that `addPath` targets
105
+ * *subsequent* steps: a same-process probe must use the absolute paths in
106
+ * `bins` or `binDir`.
97
107
  *
98
108
  * @public
99
109
  */
@@ -130,16 +140,26 @@ var CachedPackageManager = class extends Schema.Class("CachedPackageManager")({
130
140
  * @public
131
141
  */
132
142
  const InstalledPackageManager = Schema.Union([AmbientPackageManager, CachedPackageManager]);
133
- const DEFAULT_REGISTRY = "https://registry.npmjs.org";
134
143
  /** The shim directory name inside a cached npm-registry-manager entry. */
135
144
  const SHIM_DIR = ".bin";
136
145
  /**
137
- * The POSIX shim body: an `exec` wrapper so the shim's process *becomes*
138
- * node, and `"$@"` so arguments survive quoting intact.
146
+ * The POSIX shim body: an `exec` wrapper so the shim's process *becomes* the
147
+ * target, and `"$@"` so arguments survive quoting intact. A Node script
148
+ * (`.js`/`.mjs`/`.cjs`) runs under `node`; anything else — pnpm 12's native
149
+ * binary, its `#!/bin/sh` alias scripts — is exec'd directly, because handing
150
+ * a shell script or a Mach-O to node is exactly the "Invalid or unexpected
151
+ * token" failure this rule exists to prevent.
139
152
  */
140
- const posixShim = (target) => `#!/bin/sh\nexec node "${target}" "$@"\n`;
141
- /** The Windows shim body, CRLF-terminated as cmd expects. */
142
- const cmdShim = (target) => `@echo off\r\nnode "${target}" %*\r\n`;
153
+ const posixShim = (target) => isNodeScript(target) ? `#!/bin/sh\nexec node "${target}" "$@"\n` : `#!/bin/sh\nexec "${target}" "$@"\n`;
154
+ /** The Windows shim body, CRLF-terminated as cmd expects; same node-vs-direct rule. */
155
+ const cmdShim = (target) => isNodeScript(target) ? `@echo off\r\nnode "${target}" %*\r\n` : `@echo off\r\n"${target}" %*\r\n`;
156
+ /** The bin names pnpm's wrapper publishes, every one of which becomes the native binary on Windows. */
157
+ const PNPM_NATIVE_BIN_NAMES = [
158
+ "pnpm",
159
+ "pn",
160
+ "pnpx",
161
+ "pnx"
162
+ ];
143
163
  /**
144
164
  * The bun release asset name for a runner platform, or `None` when bun
145
165
  * publishes no build for it.
@@ -149,9 +169,18 @@ const cmdShim = (target) => `@echo off\r\nnode "${target}" %*\r\n`;
149
169
  * bun's spelling (`macOS` → `darwin`) and the Node arch spelling to bun's
150
170
  * (`arm64` → `aarch64`).
151
171
  */
172
+ const BUN_OS = {
173
+ linux: "linux",
174
+ macos: "darwin",
175
+ windows: "windows"
176
+ };
177
+ const BUN_CPU = {
178
+ x64: "x64",
179
+ arm64: "aarch64"
180
+ };
152
181
  const bunTarget = (runnerOs, arch) => {
153
- const os = runnerOs.toLowerCase() === "linux" ? "linux" : runnerOs.toLowerCase() === "macos" ? "darwin" : runnerOs.toLowerCase() === "windows" ? "windows" : void 0;
154
- const cpu = arch === "x64" ? "x64" : arch === "arm64" ? "aarch64" : void 0;
182
+ const os = BUN_OS[runnerOs.toLowerCase()];
183
+ const cpu = BUN_CPU[arch];
155
184
  return os === void 0 || cpu === void 0 ? Option.none() : Option.some(`bun-${os}-${cpu}`);
156
185
  };
157
186
  /**
@@ -174,7 +203,10 @@ const normalizeBins = (bin, fallbackName) => {
174
203
  return Object.keys(entries).length === 0 ? Option.none() : Option.some(entries);
175
204
  };
176
205
  /** Map a `RUNNER_ARCH` value (`X64`, `ARM64`) onto the Node arch spelling. */
177
- const archFromRunner = (runnerArch) => runnerArch.toLowerCase() === "x64" ? "x64" : runnerArch.toLowerCase() === "arm64" ? "arm64" : runnerArch;
206
+ const archFromRunner = (runnerArch) => {
207
+ const lowered = runnerArch.toLowerCase();
208
+ return lowered === "x64" || lowered === "arm64" ? lowered : runnerArch;
209
+ };
178
210
  const make = Effect.gen(function* () {
179
211
  const env = yield* ActionEnvironment;
180
212
  const fs = yield* FileSystem.FileSystem;
@@ -186,11 +218,12 @@ const make = Effect.gen(function* () {
186
218
  onNone: () => process.arch,
187
219
  onSome: archFromRunner
188
220
  }));
189
- const windows = runnerOs.toLowerCase() === "windows";
221
+ const windows = yield* isWindowsRunner(env);
222
+ const musl = detectMusl();
190
223
  const bunBinaryName = windows ? "bun.exe" : "bun";
191
224
  const shimFileName = (name) => windows ? `${name}.cmd` : name;
192
225
  const shimBody = windows ? cmdShim : posixShim;
193
- const cacheRoot = yield* Effect.map(env.getOptional("RUNNER_TOOL_CACHE"), (found) => Option.getOrElse(found, () => path.join("/tmp", "runner-tool-cache")));
226
+ const cacheRoot = yield* toolCacheRoot(env, path);
194
227
  const finalCachePath = (tool, version) => ToolInstaller.cachePath({
195
228
  root: cacheRoot,
196
229
  tool,
@@ -209,16 +242,11 @@ const make = Effect.gen(function* () {
209
242
  cause
210
243
  });
211
244
  /** The hex digest of a file's bytes, streamed rather than buffered. */
212
- const hashFile = (pin, file, algorithm) => Effect.suspend(() => {
213
- const digest = createHash(algorithm);
214
- return fs.stream(file).pipe(Stream.runForEach((chunk) => Effect.sync(() => {
215
- digest.update(chunk);
216
- })), Effect.map(() => digest.digest("hex")), Effect.mapError((cause) => errorFor(pin)({
217
- reason: "integrityMismatch",
218
- subject: file,
219
- cause
220
- })));
221
- });
245
+ const hashFile = (pin, file, algorithm) => digestFileHex(fs, file, algorithm).pipe(Effect.mapError((cause) => errorFor(pin)({
246
+ reason: "integrityMismatch",
247
+ subject: file,
248
+ cause
249
+ })));
222
250
  /**
223
251
  * Verify a downloaded artifact against the pin's integrity, fail-closed.
224
252
  *
@@ -250,20 +278,24 @@ const make = Effect.gen(function* () {
250
278
  */
251
279
  const ambientNpmVersion = Effect.map(Effect.result(spawner.string(ChildProcess.make("npm", ["--version"]))), (result) => result._tag === "Success" ? Option.some(result.success.trim()) : Option.none());
252
280
  const assertFile = (pin, file, subject) => Effect.gen(function* () {
253
- const stat = yield* Effect.result(fs.stat(file));
254
- if (stat._tag !== "Success" || stat.success.type !== "File") return yield* Effect.fail(errorFor(pin)({
281
+ if ((yield* typeAt(fs, file)) !== "File") return yield* Effect.fail(errorFor(pin)({
255
282
  reason: "layoutUnexpected",
256
283
  subject
257
284
  }));
258
285
  });
259
286
  /**
260
- * The bin entries a package directory publishes, from its own manifest.
287
+ * What a package directory's own manifest says about its entry points: the
288
+ * `bin` map, plus the `@pnpm/exe.*` optional dependencies that mark the
289
+ * pnpm 12 native-binary layout (empty for every other manager and version).
261
290
  *
262
291
  * Read from the artifact rather than hardcoded per manager, because the
263
292
  * layouts genuinely move — corepack's own table has pnpm's entry at
264
- * `bin/pnpm.cjs` through v10 and `bin/pnpm.mjs` from v11.
293
+ * `bin/pnpm.cjs` through v10 and `bin/pnpm.mjs` from v11, and from v12 the
294
+ * `pnpm` bin is a placeholder for a native binary shipped as a separate
295
+ * package. Both maps are attacker-supplied bytes and are normalized to
296
+ * string → string before anything downstream reads them.
265
297
  */
266
- const readPackageBins = (pin, packageDir) => Effect.gen(function* () {
298
+ const readPackageManifest = (pin, packageDir) => Effect.gen(function* () {
267
299
  const manifestPath = path.join(packageDir, "package.json");
268
300
  const raw = yield* fs.readFileString(manifestPath).pipe(Effect.mapError((cause) => errorFor(pin)({
269
301
  reason: "layoutUnexpected",
@@ -292,8 +324,41 @@ const make = Effect.gen(function* () {
292
324
  }));
293
325
  yield* assertFile(pin, target, `bin ${name} (${relative}) is missing`);
294
326
  }
295
- return bins;
327
+ return {
328
+ bins,
329
+ nativePackages: nativePackagesOf(manifest.optionalDependencies)
330
+ };
296
331
  });
332
+ /**
333
+ * The `@pnpm/exe.<target>` → version entries of an `optionalDependencies`
334
+ * value, and nothing else: only well-formed string pairs under that prefix
335
+ * survive, so a hostile or malformed map cannot smuggle a package name
336
+ * into a registry url.
337
+ */
338
+ const nativePackagesOf = (optionalDependencies) => {
339
+ if (typeof optionalDependencies !== "object" || optionalDependencies === null) return {};
340
+ const entries = {};
341
+ for (const [name, version] of Object.entries(optionalDependencies)) if (name.startsWith("@pnpm/exe.") && typeof version === "string") entries[name] = version;
342
+ return entries;
343
+ };
344
+ /**
345
+ * pnpm's Windows overlay places the native binary as `<name>.exe` for every
346
+ * wrapper bin and repoints `bin` at those; this is that repoint, applied to
347
+ * the in-memory map (the shims and the record) rather than the manifest.
348
+ */
349
+ const nativeWindowsBins = (bins) => Object.fromEntries(Object.entries(bins).map(([name, relative]) => PNPM_NATIVE_BIN_NAMES.includes(name) ? [name, `${relative}.exe`] : [name, relative]));
350
+ /**
351
+ * Whether a file is still pnpm's shebang-less placeholder (a `#`-led text
352
+ * file that is not a `#!` script) rather than the native binary meant to
353
+ * overlay it — the mark of an entry written by a version of this module
354
+ * that predates the overlay, or by a foreign writer that ran no lifecycle
355
+ * scripts. Anything else (a shebang, an executable's magic, an unreadable
356
+ * or empty file) is left to the ordinary layout checks.
357
+ */
358
+ const isPlaceholder = (file) => Effect.scoped(Effect.flatMap(fs.open(file), (handle) => handle.readAlloc(2))).pipe(Effect.map((head) => Option.match(head, {
359
+ onNone: () => false,
360
+ onSome: (bytes) => bytes.length >= 1 && bytes[0] === 35 && bytes[1] !== 33
361
+ })), Effect.orElseSucceed(() => false));
297
362
  const rootBins = (bins, directory) => Object.fromEntries(Object.entries(bins).map(([name, relative]) => [name, path.join(directory, relative)]));
298
363
  /**
299
364
  * Write one executable shim per bin into `<into>/.bin`, each invoking
@@ -319,35 +384,124 @@ const make = Effect.gen(function* () {
319
384
  for (const [name, relative] of Object.entries(bins)) {
320
385
  const shim = path.join(shimDir, shimFileName(name));
321
386
  if (options.skipExisting) {
322
- const present = yield* Effect.result(fs.stat(shim));
323
- if (present._tag === "Success" && present.success.type === "File") continue;
387
+ if ((yield* typeAt(fs, shim)) === "File") continue;
324
388
  }
325
389
  yield* fs.writeFileString(shim, shimBody(path.join(finalDirectory, relative))).pipe(Effect.mapError(cacheError(shim)));
326
390
  if (!windows) yield* fs.chmod(shim, 493).pipe(Effect.mapError(cacheError(shim)));
327
391
  }
328
392
  });
329
- /** The record for a directory already in the cache, regenerating absent shims. */
393
+ /**
394
+ * The record for a directory already in the cache, regenerating absent
395
+ * shims — or `None` when the entry is a pnpm 12 wrapper whose `pnpm` bin is
396
+ * still the placeholder, which no shim can make runnable: the caller then
397
+ * reinstalls over it through the ordinary install path (ToolInstaller's
398
+ * swap removes the old entry and renames the complete new one into place).
399
+ */
330
400
  const cachedRecord = (pin, directory) => Effect.gen(function* () {
331
401
  if (pin.name === "bun") {
332
402
  const binary = path.join(directory, bunBinaryName);
333
403
  yield* assertFile(pin, binary, `cached bun binary (${bunBinaryName}) is missing`);
334
- return CachedPackageManager.make({
404
+ return Option.some(CachedPackageManager.make({
335
405
  name: pin.name,
336
406
  version: pin.version.toString(),
337
407
  directory,
338
408
  binDir: directory,
339
409
  bins: { bun: binary }
340
- });
410
+ }));
411
+ }
412
+ const manifest = yield* readPackageManifest(pin, directory);
413
+ let bins = manifest.bins;
414
+ if (Object.keys(manifest.nativePackages).length > 0) {
415
+ const entry = bins[pin.name];
416
+ if (entry !== void 0 && (yield* isPlaceholder(path.join(directory, entry)))) {
417
+ yield* Effect.logInfo(`The cached ${pin.name}@${pin.version.toString()} still holds pnpm's placeholder bin; reinstalling it over.`);
418
+ return Option.none();
419
+ }
420
+ if (windows) bins = nativeWindowsBins(bins);
341
421
  }
342
- const bins = yield* readPackageBins(pin, directory);
343
422
  yield* writeShims(pin, directory, directory, bins, { skipExisting: true });
344
- return CachedPackageManager.make({
423
+ return Option.some(CachedPackageManager.make({
345
424
  name: pin.name,
346
425
  version: pin.version.toString(),
347
426
  directory,
348
427
  binDir: path.join(directory, SHIM_DIR),
349
428
  bins: rootBins(bins, directory)
429
+ }));
430
+ });
431
+ /**
432
+ * pnpm 12's native overlay, done here because the installer never runs the
433
+ * lifecycle script that does it upstream: pick the host's `@pnpm/exe.*`
434
+ * package from the wrapper's optional dependencies, fetch its tarball from
435
+ * the SAME registry the wrapper came from, verify it against the registry's
436
+ * own `dist.integrity` (fail-closed — this is a second artifact the pin
437
+ * never named, so there is no integrity-less posture to honor), and copy
438
+ * the executable over the placeholder in the STAGED wrapper, where `dist/`
439
+ * sits beside it as the binary expects. Answers the bins as they should be
440
+ * shimmed and recorded — retargeted to `.exe` on Windows, as upstream does.
441
+ */
442
+ const overlayNativeBinary = (pin, packageDir, bins, nativePackages, registry) => Effect.gen(function* () {
443
+ const target = Option.getOrUndefined(pnpmExeTarget(runnerOs, arch, musl));
444
+ const packageName = target === void 0 ? void 0 : `${PNPM_EXE_PREFIX}${target}`;
445
+ const nativeVersion = packageName === void 0 ? void 0 : nativePackages[packageName];
446
+ if (target === void 0 || packageName === void 0 || nativeVersion === void 0) return yield* Effect.fail(errorFor(pin)({
447
+ reason: "unsupportedPlatform",
448
+ subject: `${runnerOs || "unknown"}/${arch}`
449
+ }));
450
+ if (nativeVersion !== pin.version.toString()) return yield* Effect.fail(errorFor(pin)({
451
+ reason: "layoutUnexpected",
452
+ subject: `${packageName} is pinned at ${nativeVersion}, not the wrapper's ${pin.version.toString()}`
453
+ }));
454
+ const placeholder = bins[pin.name];
455
+ if (placeholder === void 0) return yield* Effect.fail(errorFor(pin)({
456
+ reason: "layoutUnexpected",
457
+ subject: `package.json names no ${pin.name} bin to overlay`
458
+ }));
459
+ const packumentUrl = `${registry}/${packageName}/${nativeVersion}`;
460
+ const packumentFile = yield* installer.download(packumentUrl).pipe(Effect.mapError(fromInstaller(pin)));
461
+ const unverifiable = (cause) => errorFor(pin)({
462
+ reason: "integrityMismatch",
463
+ subject: packumentUrl,
464
+ ...cause === void 0 ? {} : { cause }
350
465
  });
466
+ const declared = (yield* fs.readFileString(packumentFile).pipe(Effect.mapError((cause) => unverifiable(cause)), Effect.flatMap((raw) => Effect.try({
467
+ try: () => JSON.parse(raw),
468
+ catch: (cause) => unverifiable(cause)
469
+ })))).dist?.integrity;
470
+ const expected = typeof declared === "string" ? Option.getOrUndefined(strongestSri(declared)) : void 0;
471
+ if (expected === void 0) return yield* Effect.fail(unverifiable());
472
+ const tarballUrl = `${registry}/${packageName}/-/exe.${target}-${nativeVersion}.tgz`;
473
+ const archive = yield* installer.download(tarballUrl).pipe(Effect.mapError(fromInstaller(pin)));
474
+ const actualHex = yield* hashFile(pin, archive, expected.algorithm);
475
+ if (actualHex !== expected.hex) return yield* Effect.fail(errorFor(pin)({
476
+ reason: "integrityMismatch",
477
+ subject: tarballUrl,
478
+ expected: `${expected.algorithm}.${expected.hex}`,
479
+ actual: `${expected.algorithm}.${actualHex}`
480
+ }));
481
+ const extracted = yield* installer.extractTar(archive).pipe(Effect.mapError(fromInstaller(pin)));
482
+ const nativeName = windows ? "pnpm.exe" : "pnpm";
483
+ const native = path.join(extracted, "package", nativeName);
484
+ yield* assertFile(pin, native, `package/${nativeName} is missing from the ${packageName} tarball`);
485
+ const cacheError = (subject) => (cause) => errorFor(pin)({
486
+ reason: "cacheFailed",
487
+ subject,
488
+ cause
489
+ });
490
+ if (windows) {
491
+ for (const name of PNPM_NATIVE_BIN_NAMES) {
492
+ const relative = bins[name];
493
+ if (relative === void 0) continue;
494
+ for (const destination of [path.join(packageDir, relative), path.join(packageDir, `${relative}.exe`)]) yield* fs.copyFile(native, destination).pipe(Effect.mapError(cacheError(destination)));
495
+ }
496
+ return nativeWindowsBins(bins);
497
+ }
498
+ const destination = path.join(packageDir, placeholder);
499
+ yield* fs.copyFile(native, destination).pipe(Effect.mapError(cacheError(destination)));
500
+ for (const relative of Object.values(bins)) if (!isNodeScript(relative)) {
501
+ const file = path.join(packageDir, relative);
502
+ yield* fs.chmod(file, 493).pipe(Effect.mapError(cacheError(file)));
503
+ }
504
+ return bins;
351
505
  });
352
506
  /** Download bun's per-platform zip from GitHub releases and cache the binary. */
353
507
  const installBun = (pin) => Effect.gen(function* () {
@@ -386,12 +540,12 @@ const make = Effect.gen(function* () {
386
540
  if (!berry) yield* verifyIntegrity(pin, archive);
387
541
  const extracted = yield* installer.extractTar(archive).pipe(Effect.mapError(fromInstaller(pin)));
388
542
  const packageDir = path.join(extracted, "package");
389
- const stat = yield* Effect.result(fs.stat(packageDir));
390
- if (stat._tag !== "Success" || stat.success.type !== "Directory") return yield* Effect.fail(errorFor(pin)({
543
+ if ((yield* typeAt(fs, packageDir)) !== "Directory") return yield* Effect.fail(errorFor(pin)({
391
544
  reason: "layoutUnexpected",
392
545
  subject: "tarball has no package/ root"
393
546
  }));
394
- const bins = yield* readPackageBins(pin, packageDir);
547
+ const manifest = yield* readPackageManifest(pin, packageDir);
548
+ const bins = Object.keys(manifest.nativePackages).length > 0 ? yield* overlayNativeBinary(pin, packageDir, manifest.bins, manifest.nativePackages, registry) : manifest.bins;
395
549
  if (berry) {
396
550
  const cli = path.join(packageDir, "bin/yarn.js");
397
551
  yield* assertFile(pin, cli, "bin/yarn.js is missing from the cli-dist tarball");
@@ -420,7 +574,10 @@ const make = Effect.gen(function* () {
420
574
  });
421
575
  if (options?.requireIntegrity === true && pin.integrity === void 0) return yield* Effect.fail(errorFor(pin)({ reason: "integrityMissing" }));
422
576
  const cached = yield* installer.find(pin.name, version);
423
- if (Option.isSome(cached)) return yield* cachedRecord(pin, cached.value);
577
+ if (Option.isSome(cached)) {
578
+ const record = yield* cachedRecord(pin, cached.value);
579
+ if (Option.isSome(record)) return record.value;
580
+ }
424
581
  if (pin.name === "npm" && options?.allowAmbient !== false) {
425
582
  const probed = yield* ambientNpmVersion;
426
583
  if (Option.isSome(probed) && probed.value === version) return AmbientPackageManager.make({
@@ -437,9 +594,7 @@ const make = Effect.gen(function* () {
437
594
  return yield* installFromRegistry(pin, pin.name, registry);
438
595
  }) };
439
596
  });
440
- const unimplemented = (member) => {
441
- throw new Error(`PackageManagerInstaller.makeTest: ${member}() was called but not stubbed — pass a \`${member}\` override.`);
442
- };
597
+ const dies = unstubbed("PackageManagerInstaller.makeTest");
443
598
  /**
444
599
  * First-class exact-version package-manager provisioning on a GitHub runner.
445
600
  *
@@ -483,7 +638,7 @@ var PackageManagerInstaller = class PackageManagerInstaller extends Context.Serv
483
638
  static layer = Layer.effect(this, make);
484
639
  /** A test double. The unstubbed member dies rather than reporting an install that did not happen. */
485
640
  static makeTest = (overrides = {}) => ({
486
- install: () => Effect.sync(() => unimplemented("install")),
641
+ install: () => dies("install"),
487
642
  ...overrides
488
643
  });
489
644
  /** {@link PackageManagerInstaller.makeTest} behind `Layer.succeed`. */
package/ToolInstaller.js CHANGED
@@ -1,4 +1,8 @@
1
1
  import { ActionEnvironment } from "./ActionEnvironment.js";
2
+ import { unstubbed } from "./internal/unstubbed.js";
3
+ import { isWindowsRunner, toolCacheRoot } from "./internal/runner.js";
4
+ import { spawnOnce } from "./internal/spawn.js";
5
+ import { typeAt } from "./internal/fsProbe.js";
2
6
  import { Context, Effect, FileSystem, Layer, Option, Path, Schedule, Schema, Stream } from "effect";
3
7
  import { HttpClient } from "effect/unstable/http";
4
8
  import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
@@ -61,11 +65,8 @@ const make = Effect.gen(function* () {
61
65
  const path = yield* Path.Path;
62
66
  const http = yield* HttpClient.HttpClient;
63
67
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
64
- const root = yield* Effect.map(env.getOptional("RUNNER_TOOL_CACHE"), (found) => Option.getOrElse(found, () => path.join("/tmp", "runner-tool-cache")));
65
- const windows = yield* Effect.map(env.getOptional("RUNNER_OS"), (found) => Option.match(found, {
66
- onNone: () => false,
67
- onSome: (os) => os.toLowerCase() === "windows"
68
- }));
68
+ const root = yield* toolCacheRoot(env, path);
69
+ const windows = yield* isWindowsRunner(env);
69
70
  const cachePath = (tool, version) => ToolInstaller.cachePath({
70
71
  root,
71
72
  tool,
@@ -93,30 +94,22 @@ const make = Effect.gen(function* () {
93
94
  * the difference between "this is not a gzip archive" and "tar is not
94
95
  * installed". Discarding it is what makes an extraction failure unactionable.
95
96
  *
96
- * **One spawn, not two — load-bearing.** The spawner's convenience members
97
- * each spawn independently: `string` collects output without inspecting the
98
- * exit code, and `exitCode` runs the command AGAIN (core's
99
- * `ChildProcessSpawner.make` derives both from `spawn`). Calling them
100
- * back-to-back double-executed every extraction — harmless for idempotent
101
- * `tar`/`unzip -o`, but .NET's `ZipFile.ExtractToDirectory` refuses to
102
- * overwrite, so the second run failed 5/5 on real Windows runners while the
103
- * captured "complaint" was the FIRST run's silent success. Output and exit
104
- * code must come from the same `spawn` handle.
97
+ * **One spawn, not two — load-bearing**, and why is spelled once in
98
+ * `internal/spawn.ts`: the double run failed 5/5 on real Windows runners
99
+ * because .NET's `ZipFile.ExtractToDirectory` refuses to overwrite.
105
100
  */
106
- const extractWith = (command, archive) => Effect.scoped(Effect.gen(function* () {
107
- const extractError = (cause) => new ToolInstallerError({
101
+ const extractWith = (command, archive) => Effect.gen(function* () {
102
+ const { output, code } = yield* spawnOnce(spawner, command).pipe(Effect.mapError((cause) => new ToolInstallerError({
108
103
  reason: "extractFailed",
109
104
  subject: archive,
110
105
  cause
111
- });
112
- const handle = yield* spawner.spawn(command).pipe(Effect.mapError(extractError));
113
- const output = yield* Stream.mkString(Stream.decodeText(handle.all)).pipe(Effect.mapError(extractError));
114
- if ((yield* handle.exitCode.pipe(Effect.mapError(extractError))) !== 0) return yield* Effect.fail(new ToolInstallerError({
106
+ })));
107
+ if (code !== 0) return yield* Effect.fail(new ToolInstallerError({
115
108
  reason: "extractFailed",
116
109
  subject: archive,
117
110
  stderr: output.trim()
118
111
  }));
119
- }));
112
+ });
120
113
  /**
121
114
  * Install a staged directory into the tool cache by renaming it into place.
122
115
  *
@@ -191,7 +184,7 @@ const make = Effect.gen(function* () {
191
184
  while: (error) => error.retryable
192
185
  }));
193
186
  });
194
- const find = (tool, version) => Effect.map(Effect.result(fs.stat(cachePath(tool, version))), (result) => result._tag === "Success" && result.success.type === "Directory" ? Option.some(cachePath(tool, version)) : Option.none());
187
+ const find = (tool, version) => Effect.map(typeAt(fs, cachePath(tool, version)), (type) => type === "Directory" ? Option.some(cachePath(tool, version)) : Option.none());
195
188
  const cacheFile = Effect.fn("ToolInstaller.cacheFile")(function* (source, name, tool, version) {
196
189
  yield* Effect.annotateCurrentSpan({
197
190
  tool,
@@ -252,8 +245,7 @@ const make = Effect.gen(function* () {
252
245
  });
253
246
  const cached = yield* find(options.tool, options.version);
254
247
  if (Option.isSome(cached)) {
255
- const present = yield* Effect.result(fs.stat(path.join(cached.value, options.binary)));
256
- if (present._tag === "Success" && present.success.type === "File") return {
248
+ if ((yield* typeAt(fs, path.join(cached.value, options.binary))) === "File") return {
257
249
  directory: cached.value,
258
250
  binDir: cached.value
259
251
  };
@@ -272,9 +264,7 @@ const make = Effect.gen(function* () {
272
264
  })
273
265
  };
274
266
  });
275
- const unimplemented = (member) => {
276
- throw new Error(`ToolInstaller.makeTest: ${member}() was called but not stubbed — pass a \`${member}\` override.`);
277
- };
267
+ const dies = unstubbed("ToolInstaller.makeTest");
278
268
  /**
279
269
  * Download, extract and cache a toolchain in the runner's tool cache.
280
270
  *
@@ -323,13 +313,13 @@ var ToolInstaller = class ToolInstaller extends Context.Service()("@effected/git
323
313
  static cachePath = (options) => `${options.root}/${options.tool}/${options.version}/${options.arch}`;
324
314
  /** A test double. Unstubbed members die rather than reporting a tool that is not there. */
325
315
  static makeTest = (overrides = {}) => ({
326
- find: () => Effect.sync(() => unimplemented("find")),
327
- download: () => Effect.sync(() => unimplemented("download")),
328
- extractTar: () => Effect.sync(() => unimplemented("extractTar")),
329
- extractZip: () => Effect.sync(() => unimplemented("extractZip")),
330
- cacheDir: () => Effect.sync(() => unimplemented("cacheDir")),
331
- cacheFile: () => Effect.sync(() => unimplemented("cacheFile")),
332
- provisionFile: () => Effect.sync(() => unimplemented("provisionFile")),
316
+ find: () => dies("find"),
317
+ download: () => dies("download"),
318
+ extractTar: () => dies("extractTar"),
319
+ extractZip: () => dies("extractZip"),
320
+ cacheDir: () => dies("cacheDir"),
321
+ cacheFile: () => dies("cacheFile"),
322
+ provisionFile: () => dies("provisionFile"),
333
323
  ...overrides
334
324
  });
335
325
  /** {@link ToolInstaller.makeTest} behind `Layer.succeed`. */