import type { BundleContext, BundleResult } from "@packall/core"; import { Layout } from "@packall/core"; import { ForceMode } from "./enums/force-mode.js"; import { bytes as formatBytes, duration, pluralize } from "./format.js"; import type { Tty } from "./tty.js"; /** How many warnings to print before collapsing the rest into a count. */ const MAX_WARNINGS_SHOWN = 8; export type TextInput = { readonly result: BundleResult; readonly options: BundleContext; /** The host, for rendering paths the way they were typed. */ readonly tty: Tty.Service; readonly elapsedMs: number; readonly warnings: ReadonlyArray; readonly quiet: boolean; /** Print resolved absolute paths instead of ones relative to the cwd. */ readonly absolutePaths?: boolean | undefined; /** When present, the run prints the command that reproduces it. */ readonly echo?: CommandEcho | undefined; /** Set when a lockfile pinned this run, and reported because it changes the answer. */ readonly pinnedBy?: PinnedBy | undefined; }; /** The lockfile a run was pinned to. */ export type PinnedBy = { readonly path: string; readonly format: string; readonly lockfileVersion: string; /** The workspace member the roots came from, when the lockfile covers several. */ readonly importer?: string | undefined; }; /** Everything needed to reconstruct the command that produced this run. */ export type CommandEcho = { readonly specs: ReadonlyArray; /** Set when the run came from `--file`; echoed instead of the spec list. */ readonly file?: string | undefined; readonly prod?: boolean | undefined; /** Path of the lockfile that pinned the run, when one did. */ readonly lockfile?: string | undefined; /** True when detection ran against a package.json and pinned nothing. */ readonly skipLockfile?: boolean | undefined; readonly layout: Layout; readonly outDir: string; /** As `formatPlatformFilter` renders it — `"all"` when unrestricted. */ readonly platforms: string; readonly optional: boolean; readonly peer: boolean; readonly allVersions: boolean; readonly maxVersions?: number | undefined; readonly includePrerelease: boolean; readonly verify: boolean; /** Overwrite stance actually in force. */ readonly force?: ForceMode | undefined; readonly forceSpecs?: ReadonlyArray | undefined; readonly registry?: string | undefined; readonly archiveName?: string | undefined; }; /** * Renders an absolute path the way you would have typed it. * * `Flag.directory` and `Flag.file` resolve their values before the handler sees * them, which is right for doing the work and wrong for showing it back: an * echoed command reading `--out ./temp` is what you meant, and it stays correct * when pasted somewhere else in the same tree. * * Paths outside the working directory keep their absolute form — a `../../..` * chain is longer than what it replaces and silently depends on where you run * it. Separators are normalised to `/`, which every shell on Windows accepts. * * The host arrives as a value rather than being read from `process`, so this is * testable without chdir'ing the test process — and so the identical function * renders virtual paths in a browser, where there is no `process` to read. */ export const relativeToCwd = (target: string, tty: Tty.Service): string => { const { path } = tty; if (!path.isAbsolute(target)) return target; const rel = path.relative(tty.cwd, target); if (rel === "") return "."; // `..` means outside the tree; an absolute `rel` means a different drive. if (rel.startsWith("..") || path.isAbsolute(rel)) return target; return `./${rel.split(path.sep).join("/")}`; }; /** * Shell-quotes an argument. * * Specs beginning with `@` are quoted unconditionally: PowerShell reads a * leading `@` as the array/splat operator, so `npmb @babel/core` is a parse * error there. A quoted spec is correct in every shell involved, and a * copy-pasteable command that only works in bash is not much use to somebody * on Windows — which, for this tool, is most people. */ export const quoteArgument = (argument: string): string => /^[\w.@/^~=<>*-]+$/.test(argument) && !argument.startsWith("@") ? argument : `"${argument.replace(/"/g, '\\"')}"`; /** * The command that reproduces this run non-interactively. * * Printed after anything was decided by a prompt, and on every `--dry-run`. * Exploring is how you find the right flags; this is what lets you keep them — * paste it into a script, a ticket, or a CI job and get the identical bundle * without answering a single question. * * Only non-default values are emitted, so the line stays short enough to read. */ export const equivalentCommand = (echo: CommandEcho, tty: Tty.Service): string => { // A `--file` run can carry fifty specs; echoing the file keeps the line // readable and keeps it tracking the file if that changes. const parts = echo.file === undefined ? ["packall", ...echo.specs.map(quoteArgument)] : [ "packall", "--file", quoteArgument(relativeToCwd(echo.file, tty)), ...(echo.prod === true ? ["--prod"] : []), ]; // Pinned as a path, never as `detect`: the point of this line is that it // reproduces the run somewhere else, where detection could find a different // file or none at all. `off` is echoed for the same reason — a run that // declined the lockfile has to keep declining it. if (echo.lockfile !== undefined) parts.push("--lockfile", quoteArgument(echo.lockfile)); else if (echo.skipLockfile === true) parts.push("--lockfile", "off"); if (echo.layout !== Layout.PerSpec) parts.push("--layout", echo.layout); const out = relativeToCwd(echo.outDir, tty); if (out !== ".") parts.push("--out", quoteArgument(out)); if (echo.platforms !== "all") parts.push("--platform", quoteArgument(echo.platforms)); if (!echo.optional) parts.push("--no-optional"); if (!echo.peer) parts.push("--no-peer"); if (echo.allVersions) parts.push("--all-versions"); if (echo.maxVersions !== undefined) parts.push("--max-versions", String(echo.maxVersions)); if (echo.includePrerelease) parts.push("--include-prerelease"); if (!echo.verify) parts.push("--no-verify"); if (echo.force === ForceMode.All) parts.push("--force"); if (echo.force === ForceMode.None) parts.push("--no-force"); if (echo.forceSpecs !== undefined && echo.forceSpecs.length > 0) { parts.push("--force-spec", quoteArgument(echo.forceSpecs.join(","))); } if (echo.registry !== undefined) parts.push("--registry", quoteArgument(echo.registry)); if (echo.archiveName !== undefined) parts.push("--archive-name", quoteArgument(echo.archiveName)); return parts.join(" "); }; /** The human-readable summary. */ export const toText = (input: TextInput): string => { const lines: Array = []; const { result } = input; // Paths are shown relative to where the command was run: that is how they // were typed, it is shorter, and it stays true if the line is pasted into a // script alongside the command. opts back out for // logs that will be read somewhere else entirely. const show = (target: string): string => input.absolutePaths === true ? input.tty.path.resolve(target) : relativeToCwd(target, input.tty); // Stated before anything else, because "which versions did I actually get" // is the question a pinned run exists to answer, and the answer is different // from an unpinned one. if (input.pinnedBy !== undefined) { // The importer is named whenever there is one, because a lockfile found by // walking up covers the whole repository and "which part of it" is then a // real question about what is in the bundle. const importer = input.pinnedBy.importer === undefined ? "" : `, workspace ${input.pinnedBy.importer}`; lines.push(""); lines.push( ` pinned by ${show(input.pinnedBy.path)} ` + `(${input.pinnedBy.format}, lockfileVersion ${input.pinnedBy.lockfileVersion}${importer})`, ); } const uniqueWarnings = [...new Set(input.warnings)]; if (uniqueWarnings.length > 0) { lines.push(""); for (const warning of uniqueWarnings.slice(0, MAX_WARNINGS_SHOWN)) { lines.push(` warning: ${warning}`); } if (uniqueWarnings.length > MAX_WARNINGS_SHOWN) { lines.push(` … and ${uniqueWarnings.length - MAX_WARNINGS_SHOWN} more warning(s)`); } } if (result.dryRun) { lines.push(""); lines.push( `Dry run — nothing downloaded. ${pluralize(result.resolution.packages.length, "package")} would be bundled:`, ); for (const root of result.resolution.roots) { const versions = root.versions.length === 1 ? root.versions[0] : `${root.versions.length} versions`; const count = root.closure.length; lines.push( ` ${root.spec.name} → ${versions} (${pluralize(count, "package")} in closure)`, ); } lines.push(""); if (input.echo !== undefined) { lines.push(` Re-run without --dry-run to fetch them:`); lines.push(""); lines.push(` ${equivalentCommand(input.echo, input.tty)}`); } else { lines.push(` Re-run without --dry-run to fetch them.`); } lines.push(""); return `${lines.join("\n")}\n`; } const totalOut = result.artifacts.reduce((sum, artifact) => sum + artifact.bytes, 0); lines.push(""); lines.push( `Done in ${duration(input.elapsedMs)} — ` + `${pluralize(result.resolution.packages.length, "package")}, ` + `${formatBytes(result.downloadedBytes)} downloaded.`, ); if (result.unverified.length > 0) { lines.push( ` ${pluralize(result.unverified.length, "package")} had no checksum to verify against.`, ); } lines.push(""); if (!input.quiet) { for (const artifact of result.artifacts.slice(0, 20)) { const label = show(artifact.path); // The manifest and the import guide hold no packages, and "(0 // packages)" beside them reads as a fault rather than as a fact. const count = artifact.packageCount === 0 ? "" : ` (${pluralize(artifact.packageCount, "package")})`; lines.push(` ${label} ${formatBytes(artifact.bytes)}${count}`); } if (result.artifacts.length > 20) { lines.push(` … and ${result.artifacts.length - 20} more`); } lines.push(""); } lines.push( ` ${pluralize(result.artifacts.length, "artifact")} in ${show(input.options.outDir)} ` + `(${formatBytes(totalOut)} total)`, ); lines.push(""); return `${lines.join("\n")}\n`; }; /** The machine-readable summary. */ export const toJson = (result: BundleResult, options: BundleContext, elapsedMs: number): string => JSON.stringify( { ok: true, dryRun: result.dryRun, elapsedMs, outDir: options.outDir, layout: options.layout, totals: { packages: result.resolution.packages.length, downloadedBytes: result.downloadedBytes, artifacts: result.artifacts.length, artifactBytes: result.artifacts.reduce((sum, artifact) => sum + artifact.bytes, 0), }, roots: result.resolution.roots.map((root) => ({ name: root.spec.name, raw: root.spec.raw, versions: root.versions, closureSize: root.closure.length, })), artifacts: result.artifacts, packages: result.resolution.packages.map((pkg) => ({ name: pkg.name, version: pkg.version, integrity: pkg.manifest.dist.integrity ?? null, tarball: pkg.manifest.dist.tarball, })), unverified: result.unverified, warnings: result.resolution.warnings.map((warning) => warning.message), }, null, 2, ); /** * Renders a failure. * * Our own errors already carry a full, actionable message — including the * suggested `.npmrc` line for an auth failure and the host name for an * unreachable registry — so the job here is mostly to not get in the way. */ export const formatError = (error: unknown): string => { if (error instanceof Error) { const prefix = taggedPrefix(error); // Some messages already open by naming themselves — `OutputError` reads // "Output error at /path: …" — and prefixing those produced a stutter: // "Output: Output error at …". Where the message already leads with the // prefix, it is doing the prefix's job. return startsWithPrefix(error.message, prefix) ? error.message : `${prefix}: ${error.message}`; } return `Error: ${String(error)}`; }; /** * The error's own `_tag`, humanised — or a plain `Error` when it carries none. * * Read off the value rather than cast onto it, so an ordinary `Error` and one * of ours are told apart by what is actually there. */ const taggedPrefix = (error: Error): string => { if (!("_tag" in error)) return "Error"; const tag = error._tag; return typeof tag === "string" ? humanizeTag(tag) : "Error"; }; const startsWithPrefix = (message: string, prefix: string): boolean => message.toLowerCase().startsWith(prefix.toLowerCase()); /** `RegistryUnreachableError` -> `Registry unreachable`. */ const humanizeTag = (tag: string): string => { const withoutSuffix = tag.replace(/Error$/, ""); const spaced = withoutSuffix.replace(/([a-z0-9])([A-Z])/g, "$1 $2"); return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase(); };