import * as Effect from "effect/Effect"; import type * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import type { Command } from "effect/unstable/cli"; import type { LockedTree, PackageSpec } from "@packall/core"; import { InputFileKind, parseSpecs, readInputFile } from "@packall/core"; import type { Config } from "../config.js"; import { pluralize } from "../format.js"; import { quoteArgument, relativeToCwd } from "../summary.js"; import { Tty } from "../tty.js"; import { pinFromLockfile } from "./lockfile.js"; /** What the run will bundle, once `--file` and `--lockfile` have been settled. */ export type ResolvedInput = { readonly specs: ReadonlyArray; /** Set when the run is pinned; `specs` is then only for display. */ readonly lockfile?: LockedTree | undefined; /** Echoed path of the lockfile in force, for the reproducible command. */ readonly lockfilePath?: string | undefined; /** Names the accompanying package.json declares, cross-checked against it. */ readonly declared?: ReadonlyArray | undefined; /** * Whether detection had something to decide. * * Only true for a `--file package.json`: that is the one input where a * lockfile could have applied and did not, which is the only case worth * pinning down with `--lockfile off` in the echoed command. */ readonly detectionRan?: boolean | undefined; }; /** Merges positional specs with anything from `--file`, and settles pinning. */ export const collectInput = ( config: Config, interactive: boolean, ): Effect.Effect => Effect.gen(function* () { const tty = yield* Tty; const collected: Array = []; if (config.specs.length > 0) { const { specs, errors } = parseSpecs(config.specs); if (errors.length > 0) { return yield* Effect.fail( new Error( `${errors.length} invalid spec(s):\n` + errors.map((error) => ` ${error.message}`).join("\n"), ), ); } collected.push(...specs); } const filePath = Option.getOrUndefined(config.file); let lockfile: LockedTree | undefined; let lockfilePath: string | undefined; let declared: ReadonlyArray | undefined; let detectionRan = false; if (filePath !== undefined) { const parsed = yield* readInputFile(filePath, { includeDev: !config.prod }); for (const warning of parsed.warnings) { tty.write(` warning: ${warning}\n`); } collected.push(...parsed.specs); if (parsed.kind === InputFileKind.Lockfile) { lockfile = parsed.lockfile; lockfilePath = filePath; } else if (parsed.kind === InputFileKind.PackageJson) { detectionRan = true; const pinned = yield* pinFromLockfile(config, filePath, interactive); if (pinned !== null) { lockfile = pinned.lockfile; lockfilePath = pinned.path; declared = parsed.required; // The lockfile supersedes the ranges: its roots are the same // dependencies, already pinned. collected.length = 0; collected.push(...pinned.specs); } } } // The two resolution modes cannot be merged into one plan — one reproduces // a graph, the other satisfies ranges — so this is refused rather than // half-honoured. if (lockfile !== undefined && config.specs.length > 0) { return yield* Effect.fail( specsWithLockfileError( config, lockfilePath, Option.getOrUndefined(config.file), tty, ), ); } if (collected.length === 0) { return yield* Effect.fail( new Error( "Nothing to bundle.\n" + " Pass one or more specs: npmb react@18 lodash\n" + " or point at a file: npmb --file package.json\n" + " See `packall --help` for everything else. The `npmb` alias is the same tool.", ), ); } return { specs: dedupe(collected), lockfile, lockfilePath, declared, detectionRan }; }); /** * Explains why positional specs and a lockfile cannot be one run. * * Both ways out are given as commands rather than described, because the useful * thing at this point is the line to run next — the same reason `--dry-run` * prints the command that reproduces it. * * Which way out comes first depends on how the lockfile got here. `--lockfile * off` governs *detection*, so it un-pins a file that was found and does * nothing at all to one that was named with `--file`; offering it there would * send you round the same error a second time. */ export const specsWithLockfileError = ( config: Pick, lockfilePath: string | undefined, filePath: string | undefined, tty: Tty.Service, ): Error => { const specs = config.specs.map(quoteArgument).join(" "); const out = relativeToCwd(config.out, tty); const separately = `packall ${out === "." ? "" : `--out ${quoteArgument(out)} `}${specs}`; const named = lockfilePath !== undefined && lockfilePath === filePath; const fresh = (target: string): string => `packall --file ${target} --lockfile off ${specs}`; const ways: ReadonlyArray = named ? [ ["Bundle the specs on their own", separately], ["Or resolve everything from ranges", fresh("")], ] : [ [ "Resolve everything from ranges", fresh(quoteArgument(relativeToCwd(filePath ?? "", tty))), ], ["Or bundle the specs on their own", separately], ]; // The commands line up under each other, which is the whole reason to pad: // two long `packall …` lines starting at different columns are hard to // compare at a glance. The colon travels with its label rather than being // pushed out to the padding column. const width = Math.max(...ways.map(([label]) => label.length)) + 1; // Named individually when there is one, counted when there are several — the // full list is already in both commands below, and repeating a dozen specs // here would push the sentence off the terminal. const subject = config.specs.length === 1 ? specs : `those ${pluralize(config.specs.length, "spec")}`; return new Error( "Cannot combine specs with a lockfile.\n" + ` ${relativeToCwd(lockfilePath ?? "The lockfile", tty)} pins a complete dependency graph, ` + `so resolving ${subject}\n` + " alongside it would mix pinned and freshly-resolved versions in one bundle.\n\n" + ways.map(([label, command]) => ` ${`${label}:`.padEnd(width)} ${command}`).join("\n"), ); }; const dedupe = (specs: ReadonlyArray): ReadonlyArray => { const seen = new Set(); const out: Array = []; for (const spec of specs) { const key = `${spec.name}|${JSON.stringify(spec.selector)}`; if (seen.has(key)) continue; seen.add(key); out.push(spec); } return out; };