import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import type { Command } from "effect/unstable/cli"; import { Prompt } from "effect/unstable/cli"; import type { LockedTree, LockfileFormat, PackageSpec } from "@packall/core"; import { formatByLockfileName, InputFileKind, LockfileFormat as Format, lockfileNamesFor, readInputFile, } from "@packall/core"; import type { Config } from "../config.js"; import { relativeToCwd } from "../summary.js"; import { Tty } from "../tty.js"; /** What `--lockfile` was asked for. */ export type LockfilePolicy = | { readonly _tag: "Off" } | { readonly _tag: "Detect" } | { readonly _tag: "Manager"; readonly format: LockfileFormat } | { readonly _tag: "Path"; readonly path: string }; /** A lockfile found for a package.json. */ export type LockfileCandidate = { readonly path: string; readonly format: LockfileFormat; /** * Where the package.json sits relative to the lockfile, POSIX-style. * * `"."` when they are siblings. Anything else means the lockfile was found by * walking up and covers more than the project that went looking for it. */ readonly importer: string; }; /** A settled pin: the graph, where it came from, and the roots it names. */ export type PinnedLockfile = { readonly lockfile: LockedTree; readonly path: string; readonly specs: ReadonlyArray; }; /** * How far up to look for a lockfile. * * Generous, because a deeply nested workspace member is normal and the search * stops at the first hit anyway; the bound only exists so a package.json * outside any repository cannot walk to the filesystem root one `..` at a time. */ const MAX_WALK_UP = 12; const formatByManager: Readonly> = { npm: Format.Npm, pnpm: Format.Pnpm, bun: Format.Bun, }; /** * Reads `--lockfile`. * * Overloaded the same way `--force` is, and for the same reason: the values are * a small closed set of policies plus one open-ended case, and splitting them * across two flags would make the common form longer without making anything * clearer. Anything unrecognised is a path, so a lockfile called * `ci/locked.json` needs no extra ceremony. */ export const parseLockfilePolicy = (value: string): LockfilePolicy => { const normalized = value.trim().toLowerCase(); if (normalized === "off" || normalized === "false" || normalized === "none") { return { _tag: "Off" }; } if (normalized === "detect" || normalized === "auto" || normalized === "true") { return { _tag: "Detect" }; } const format = formatByManager[normalized]; if (format !== undefined) return { _tag: "Manager", format }; return { _tag: "Path", path: value }; }; /** * Where the package.json sits relative to the lockfile's directory. * * Separators are normalised because the lockfile formats all spell their * workspace keys POSIX-style regardless of the platform that wrote them. */ export const importerPath = ( lockfileDir: string, packageJsonDir: string, path: Path.Path, ): string => { const rel = path.relative(lockfileDir, packageJsonDir).split(path.sep).join("/"); return rel === "" ? "." : rel; }; /** * The paths a policy would consider for a package.json, nearest first. * * The walk up the directory tree is what makes this useful in a monorepo, where * `packages/core/package.json` has no lockfile of its own and the one that pins * it sits at the repository root. Each level records how to get back down, so * the parse can scope to that member rather than bundling every sibling's * dependencies too. * * Pure, and exported so the ordering is testable without a filesystem. */ export const lockfileCandidates = ( policy: LockfilePolicy, directory: string, path: Path.Path, ): ReadonlyArray => { if (policy._tag === "Off") return []; if (policy._tag === "Path") { // A placeholder format: the real one comes from the file's content, which // `readInputFile` detects when it reads it. return [ { path: policy.path, format: Format.Npm, importer: importerPath(path.dirname(policy.path), directory, path), }, ]; } const out: Array = []; let level = directory; for (let depth = 0; depth < MAX_WALK_UP; depth++) { for (const [name, format] of Object.entries(formatByLockfileName)) { if (policy._tag === "Manager" && policy.format !== format) continue; out.push({ path: path.join(level, name), format, importer: importerPath(level, directory, path), }); } const parent = path.dirname(level); if (parent === level) break; level = parent; } return out; }; /** * Settles which lockfile — if any — pins this run. * * Returns `null` for "resolve the ranges fresh", which is what `--lockfile off` * asks for and what happens when a project simply has no lockfile. */ export const pinFromLockfile = ( config: Config, packageJsonPath: string, interactive: boolean, ): Effect.Effect< PinnedLockfile | null, Error, FileSystem.FileSystem | Command.Environment | Path.Path | Tty > => Effect.gen(function* () { const tty = yield* Tty; const path = yield* Path.Path; const policy = parseLockfilePolicy(config.lockfile); if (policy._tag === "Off") return null; const present = yield* presentCandidates(policy, path.dirname(packageJsonPath), path); if (present.length === 0) { const failure = notFoundError(policy, packageJsonPath, tty); if (failure !== null) return yield* Effect.fail(failure); // Detect, and there is simply nothing to detect. Said out loud, because // "which versions did this bundle actually get" is the question this // whole flag exists to answer. tty.write( ` note: no lockfile beside ${relativeToCwd(packageJsonPath, tty)} or above it — ` + "resolving version ranges fresh.\n" + " Pass --lockfile to pin, or --lockfile off to silence this.\n", ); return null; } const chosen = yield* chooseLockfile(present, config, packageJsonPath, interactive, tty); if (chosen === null) return null; const parsed = yield* readInputFile(chosen.path, { includeDev: !config.prod, // Only scope when the file was found by walking up. A sibling lockfile // belongs to this project whole, and pinning it to importer "." would // reject the single-project files that have no workspace entries at all. importer: chosen.importer === "." ? undefined : chosen.importer, }).pipe( // A lockfile further up the tree that turns out not to cover this // package is not an error under detection — it belongs to a different // project, and resolving ranges is the honest fallback. Asked for // explicitly, it stays an error. Effect.catch((error) => policy._tag === "Detect" && chosen.importer !== "." ? Effect.succeed(null) : Effect.fail(error), ), ); if (parsed === null) { tty.write( ` note: ${relativeToCwd(chosen.path, tty)} does not cover ` + `${relativeToCwd(packageJsonPath, tty)} — resolving version ranges fresh.\n`, ); return null; } if (parsed.kind !== InputFileKind.Lockfile || parsed.lockfile === undefined) { return yield* Effect.fail( new Error( `${relativeToCwd(chosen.path, tty)} is not a lockfile — it parsed as ${parsed.kind}.\n` + " Pass --lockfile off, or point --lockfile at a package-lock.json, pnpm-lock.yaml or bun.lock.", ), ); } for (const warning of parsed.lockfile.warnings) { tty.write(` warning: ${warning}\n`); } return { lockfile: parsed.lockfile, path: chosen.path, specs: parsed.specs }; }); /** * The candidates that actually exist, at the nearest level that has any. * * Nearest wins: a package with its own lockfile is not asking about the one at * the repository root. Only files at that same level compete with each other, * which is the case worth being unsure about. */ const presentCandidates = ( policy: LockfilePolicy, directory: string, path: Path.Path, ): Effect.Effect, never, FileSystem.FileSystem> => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const present: Array = []; let level: string | undefined; for (const candidate of lockfileCandidates(policy, directory, path)) { const candidateLevel = path.dirname(candidate.path); if (level !== undefined && candidateLevel !== level) break; const found = yield* fs.exists(candidate.path).pipe(Effect.orElseSucceed(() => false)); if (found) { level = candidateLevel; present.push(candidate); } } return present; }); /** * Why the search turning up nothing is fatal — or `null` when it is not. * * A policy that named something specific is an error; detection falls through * to resolving ranges fresh, which is a legitimate outcome. */ const notFoundError = ( policy: LockfilePolicy, packageJsonPath: string, tty: Tty.Service, ): Error | null => { if (policy._tag === "Manager") { return new Error( `--lockfile ${policy.format} was given, but no ${policy.format} lockfile sits beside ` + `${relativeToCwd(packageJsonPath, tty)} or above it.\n` + ` Looked for: ${lockfileNamesFor(policy.format).join(", ")}\n` + " Run the install to create one, or pass --lockfile off to resolve ranges fresh.", ); } if (policy._tag === "Path") { return new Error(`Lockfile not found: ${relativeToCwd(policy.path, tty)}`); } return null; }; /** * Asks which lockfile to pin to, when there is anything worth asking. * * Under `--interactive` this always asks, even with a single candidate, so the * echoed command comes out saying exactly which file was used rather than * `detect` — the point of the echo is that it reproduces the run somewhere with * a different directory next to it. * * Outside `--interactive`, one candidate is used and several are refused: * picking one for you is precisely the kind of guess this feature exists to * eliminate. */ const chooseLockfile = ( candidates: ReadonlyArray, config: Config, packageJsonPath: string, interactive: boolean, tty: Tty.Service, ): Effect.Effect => Effect.gen(function* () { const first = candidates[0]; if (first === undefined) return null; const ask = config.interactive && interactive && !config.yes && !config.json; if (!ask) { if (candidates.length === 1) return first; return yield* Effect.fail( new Error( `${candidates.length} lockfiles sit beside ${relativeToCwd(packageJsonPath, tty)}:\n` + candidates .map((candidate) => ` ${relativeToCwd(candidate.path, tty)}`) .join("\n") + "\n Choosing between them is a guess. Name one with --lockfile " + `${candidates.map((candidate) => candidate.format).join("|")}, or pass --lockfile off.`, ), ); } return yield* Prompt.run( Prompt.select({ message: candidates.length === 1 ? `${relativeToCwd(first.path, tty)} sits beside this package.json. Bundle the versions it pins?` : `${candidates.length} lockfiles sit beside this package.json. Which pins this bundle?`, choices: [ ...candidates.map((candidate) => ({ title: `${relativeToCwd(candidate.path, tty)} — the exact versions ${candidate.format} installed`, value: candidate, description: "Reproduces the install this lockfile records.", })), { title: "Resolve the version ranges fresh", value: null, description: "Takes the newest version matching each range, as if there were no lockfile.", }, ], }), ).pipe( // Ctrl-C at the question, or no real TTY after all. Pinning is the // default this flag ships with, so it is also the safe fall-through. Effect.orElseSucceed(() => first), ); });