import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import type { BundleContext } from "@packall/core"; import { bundleResolved, formatPlatformFilter, formatSpec, Layout, layerCallbackProgress, plan, planLocked, } from "@packall/core"; import { layer as npmRegistryLayer, preflightTimeoutFor } from "@packall/registry-npm"; import type { Config } from "./config.js"; import { VERSION } from "./constants.js"; import { ForceMode } from "./enums/force-mode.js"; import { parsePlatforms } from "./input/platforms.js"; import { collectInput } from "./input/specs.js"; import { Npmrc } from "./npmrc.js"; import { chooseLayout } from "./prompts/layout.js"; import { resolveOverwrites, parseForce } from "./prompts/overwrite.js"; import { choosePlatforms } from "./prompts/platforms.js"; import { ProgressRenderer } from "./renderer.js"; import { formatError, relativeToCwd, toJson, toText } from "./summary.js"; import { Transport } from "./transport.js"; import { Tty } from "./tty.js"; export const run = (config: Config) => { // Held outside the workflow so the failure path can reach it. The live // progress line has to be cleared and its timer stopped before an error is // printed, or the animation repaints over the first line of the message. let activeRenderer: ProgressRenderer | undefined; return Effect.gen(function* () { const tty = yield* Tty; const transport = yield* Transport; const npmrc = yield* Npmrc; // Two questions, not one. `interactive` is about the output stream — may // the renderer repaint a line — and `canPrompt` is about the input one. // A browser terminal answers yes and no; `< /dev/null` answers no and no. const interactive = tty.isInteractive && !config.json; const canPrompt = interactive && tty.canPrompt; const renderer = new ProgressRenderer({ interactive, quiet: config.quiet || config.json, write: tty.write, columns: tty.columns, // So the preflight line can count towards its deadline rather than just // counting: the point of the number is that the wait has an end. preflightTimeoutMs: preflightTimeoutFor({}, config.timeout), }); activeRenderer = renderer; const input = yield* collectInput(config, canPrompt); const specs = input.specs; const platforms = yield* parsePlatforms(config.platform); const loaded = yield* npmrc.load(Option.getOrUndefined(config.npmrc)); // Printed here rather than collected for the summary, and the difference // matters: these are configuration problems known *before* any work starts, // and the run they affect most is the one that dies at the preflight two // seconds from now — which never reaches a summary. Nothing is drawing yet, // so this cannot interleave with the progress line. for (const warning of loaded.warnings) { tty.write(` warning: ${warning}\n`); } const options: BundleContext = { scope: { optional: config.optional, peer: config.peer, platforms }, allVersions: config.allVersions, maxVersions: Option.getOrUndefined(config.maxVersions), includePrerelease: config.includePrerelease, concurrency: Math.max(1, config.concurrency), layout: Option.getOrElse(config.layout, (): Layout => Layout.PerSpec), outDir: config.out, archiveName: Option.getOrUndefined(config.archiveName), dryRun: config.dryRun, verifyIntegrity: config.verify, // Settled properly by `resolveOverwrites` once the resolution is known; // this is only the starting point. force: parseForce(config.force, config.forceSpec).global === ForceMode.All, toolVersion: VERSION, }; const infrastructure = Layer.mergeAll( npmRegistryLayer({ registry: Option.getOrUndefined(config.registry), npmrc: loaded.config, requestTimeoutMs: config.timeout, retry: { maxRetries: Math.max(0, config.retries), baseDelayMs: 400, maxDelayMs: 5_000, }, rewriteTarballHost: config.rewriteTarballHost, skipPreflight: !transport.canProbeRoot, transportFailureVisibility: transport.failureVisibility, }).pipe( Layer.provide( transport.layerFor({ requestTimeoutMs: config.timeout, tls: loaded.tls }), ), ), layerCallbackProgress((event) => renderer.handle(event)), ); const startedAt = Date.now(); // What the run actually did, once the prompts have had their say. The // summary must report these rather than what was asked for — otherwise // answering the layout prompt makes `--json` report the layout you // declined, and the echoed command reproduces the wrong run. let settled: BundleContext = options; // One entry point for both resolution modes, so the platform re-plan below // stays pinned when the run is pinned. const runPlan = (current: BundleContext) => input.lockfile === undefined ? plan(specs, current) : planLocked(input.lockfile, { ...current, includeDev: !config.prod, declared: input.declared, }); const result = yield* Effect.gen(function* () { const first = yield* runPlan(options); // Narrowing changes which packages are in the closure, so the resolution // has to be rebuilt rather than filtered. Packuments are already cached // by this point, so the second pass costs no extra requests. const chosen = yield* choosePlatforms( first, config, options.scope.platforms, canPrompt, ); const narrowed = chosen === options.scope.platforms ? options : { ...options, scope: { ...options.scope, platforms: chosen } }; const resolution = narrowed === options ? first : yield* runPlan(narrowed); const layout = yield* chooseLayout(resolution, config, narrowed.layout, canPrompt); settled = { ...narrowed, layout }; const overwrite = yield* resolveOverwrites(resolution, settled, config, canPrompt); settled = { ...settled, ...overwrite }; return yield* bundleResolved(resolution, settled); }).pipe(Effect.provide(infrastructure)); renderer.finish(); yield* Effect.sync(() => { if (config.json) { tty.writeOut(`${toJson(result, settled, Date.now() - startedAt)}\n`); return; } tty.write( toText({ result, options: settled, tty, elapsedMs: Date.now() - startedAt, warnings: [ ...renderer.collectedWarnings, ...result.resolution.warnings.map((warning) => warning.message), ], quiet: config.quiet, absolutePaths: config.printAbsolutePath, pinnedBy: input.lockfile === undefined || input.lockfilePath === undefined ? undefined : { path: input.lockfilePath, format: input.lockfile.format, lockfileVersion: input.lockfile.lockfileVersion, importer: input.lockfile.importer, }, echo: { specs: specs.map(formatSpec), file: Option.getOrUndefined(config.file), prod: config.prod, // Echoed as a path rather than as `detect`, so the command // reproduces this run from anywhere rather than re-detecting // whatever happens to sit beside the package.json there. lockfile: lockfileEcho(config, input.lockfilePath, tty), skipLockfile: input.lockfile === undefined && input.detectionRan === true, layout: settled.layout, outDir: settled.outDir, platforms: formatPlatformFilter(settled.scope.platforms), optional: settled.scope.optional, peer: settled.scope.peer, allVersions: settled.allVersions, maxVersions: Option.getOrUndefined(config.maxVersions), includePrerelease: settled.includePrerelease, verify: settled.verifyIntegrity, force: parseForce(config.force, config.forceSpec).global, forceSpecs: parseForce(config.force, config.forceSpec).specs, registry: Option.getOrUndefined(config.registry), archiveName: Option.getOrUndefined(config.archiveName), }, }), ); }); }).pipe( Effect.ensuring(Effect.sync(() => activeRenderer?.finish())), // Failures are reported in our own vocabulary rather than as a fiber dump. // Somebody debugging a proxy at 6pm needs the host name, not a stack trace. Effect.catch((error) => Effect.gen(function* () { const tty = yield* Tty; tty.write(`\n${formatError(error)}\n`); tty.exit(1); }), ), ); }; /** * What `--lockfile` should say in the reproducible command. * * `undefined` leaves it off entirely — the flag is only worth echoing when it * would change the outcome, and a lockfile passed straight to `--file` needs no * flag because `--file` already says it. */ const lockfileEcho = ( config: Config, lockfilePath: string | undefined, tty: Tty.Service, ): string | undefined => lockfilePath !== undefined && lockfilePath !== Option.getOrUndefined(config.file) ? relativeToCwd(lockfilePath, tty) : undefined;