UNPKG

npm

Version:

a package manager for JavaScript

741 lines (671 loc) 28.4 kB
const { resolve, relative, join, dirname, basename, isAbsolute, sep } = require('node:path') const { tmpdir } = require('node:os') const { cp, mkdir, mkdtemp, readFile, rm, writeFile } = require('node:fs/promises') const pacote = require('pacote') const npa = require('npm-package-arg') const semver = require('semver') const git = require('@npmcli/git') const PackageJson = require('@npmcli/package-json') const { log, output } = require('proc-log') const { matchSelector, parseSelector } = require('@npmcli/arborist/lib/patched-dependencies.js') const { applyPatchToDir } = require('@npmcli/arborist/lib/patch.js') const { patchRelaxOpts } = require('../utils/cli-only-flag.js') const BaseCommand = require('../base-cmd.js') const { diffDirs } = require('../utils/patch-diff.js') const reifyFinish = require('../utils/reify-finish.js') const SUBCOMMANDS = ['add', 'commit', 'update', 'ls', 'rm'] // Marker left in a conflicted update edit dir so the finalizing commit drops the old exact selector. const UPDATE_MARKER = '.npm-patch-update.json' // fs.cp filter that skips a git working directory. const notGitDir = src => !src.split(sep).includes('.git') // Build the selector key stored in patchedDependencies, e.g. lodash@4.17.21. const selectorKey = (name, version) => `${name}@${version}` // Posix-relative path to a patch file inside patches-dir for name@version. const patchFilePath = (patchesDir, name, version) => `${patchesDir}/${name}@${version}.patch`.split('\\').join('/') // Recover the exact version a patch file was authored against from its name. // commit always writes <name>@<version>.patch, so the baseline is encoded there. const patchFileVersion = patchPath => { const { spec } = parseSelector(basename(patchPath).replace(/\.patch$/, '')) return semver.valid(spec) } const patchErr = (message, code, extra = {}) => Object.assign(new Error(message), { code, ...extra }) // The project-root-relative posix path for abs, or null if abs escapes the root. const containedRelative = (root, abs) => { const rel = relative(root, abs).split('\\').join('/') return (!rel || rel.startsWith('..') || isAbsolute(rel)) ? null : rel } class Patch extends BaseCommand { static description = 'Apply local patches to installed dependencies' static name = 'patch' static params = [ 'patches-dir', 'allow-unused-patches', 'ignore-patch-failures', 'edit-dir', 'ignore-existing', 'keep-edit-dir', 'to', 'registry', ] static usage = [ '<pkg>[@<version>]', 'add <pkg>[@<version>] [--edit-dir <path>] [--ignore-existing]', 'commit <edit-dir> [--patches-dir <dir>] [--keep-edit-dir]', 'update <pkg>[@<old-version>] [--to <new-version>] [--patches-dir <dir>]', 'ls', 'rm <pkg>[@<version>]', ] static async completion (opts) { if (opts.conf.argv.remain.length === 2) { return SUBCOMMANDS } return [] } async exec (args) { const [sub, ...rest] = args if (!sub) { throw this.usageError() } // explicit subcommand, else treat the bare arg as `patch add <pkg>` if (SUBCOMMANDS.includes(sub)) { return this[sub](rest) } return this.add(args) } get #root () { return this.npm.localPrefix } #newArborist (opts = {}) { const Arborist = require('@npmcli/arborist') return new Arborist({ ...this.npm.flatOptions, ...patchRelaxOpts(this.npm.config), path: this.#root, ...opts, }) } async #loadActual () { return this.#newArborist().loadActual() } // Resolve a user spec to a concrete registry name@version to patch. async #resolveTarget (spec) { const parsed = npa(spec) if (parsed.type && !parsed.registry) { throw this.#nonRegistryError(spec) } const { name } = parsed const tree = await this.#loadActual() // group every installed node by version so mixed-source duplicates are seen const installed = new Map() for (const node of tree.inventory.values()) { if (node.name === name && !node.isProjectRoot && node.version) { const nodes = installed.get(node.version) || [] nodes.push(node) installed.set(node.version, nodes) } } // a version cannot be patched if a consumer depends on it through a non-registry spec (file:, git:, http(s)); npm: aliases stay registry. // checking the edges (not isRegistryDependency) avoids rejecting edgeless store nodes and linked symlinks, which are registry deps. const ensureRegistry = version => { const nodes = installed.get(version) || [] if (nodes.some(n => [...n.edgesIn].some(e => e.spec && !npa(e.spec).registry))) { throw this.#nonRegistryError(`${name}@${version}`) } } // an explicit version/range is honored even when not present in the tree if (parsed.rawSpec && parsed.rawSpec !== '*' && parsed.rawSpec !== 'latest') { const exact = semver.valid(parsed.fetchSpec) if (exact) { ensureRegistry(exact) return { name, version: exact } } const matches = [...installed.keys()].filter(v => semver.satisfies(v, parsed.fetchSpec)) if (matches.length > 1) { throw this.#ambiguousError(name, matches, installed) } if (matches.length === 1) { ensureRegistry(matches[0]) return { name, version: matches[0] } } // resolve the range against the registry const mani = await pacote.manifest(spec, this.npm.flatOptions) return { name: mani.name, version: mani.version } } if (installed.size === 0) { throw Object.assign( new Error(`No installed version of "${name}" found. ` + `Run "npm install" first, or pass an explicit version.`), { code: 'EPATCHNOTINSTALLED' } ) } if (installed.size > 1) { throw this.#ambiguousError(name, [...installed.keys()], installed) } const [version] = [...installed.keys()] ensureRegistry(version) return { name, version } } #nonRegistryError (label) { return Object.assign( new Error(`Cannot patch non-registry dependency "${label}". ` + `Only registry dependencies can be patched; edit the source directly.`), { code: 'EPATCHNONREGISTRY' } ) } #ambiguousError (name, versions, installed) { const lines = versions.map(version => { const node = installed.get(version)[0] const dependant = [...node.edgesIn][0]?.from?.location || '(root)' return ` ${selectorKey(name, version)} (via ${dependant})` }) return Object.assign( new Error(`Multiple versions of "${name}" are installed:\n${lines.join('\n')}\n` + `Re-run with an exact selector, e.g. "npm patch add ${selectorKey(name, versions[0])}".`), { code: 'EPATCHAMBIGUOUS' } ) } // Create (or reuse, with --edit-dir) the directory a package is extracted into for editing. async #makeEditDir (name, version) { let editDir = this.npm.config.get('edit-dir') if (!editDir) { const base = join(tmpdir(), 'npm-patch') await mkdir(base, { recursive: true }) return mkdtemp(join(base, `${name.replace(/\//g, '+')}@${version}-`)) } editDir = resolve(editDir) if (this.npm.config.get('ignore-existing')) { await rm(editDir, { recursive: true, force: true }) } await mkdir(editDir, { recursive: true }) return editDir } async add (args) { if (args.length !== 1) { throw this.usageError() } const { name, version } = await this.#resolveTarget(args[0]) const editDir = await this.#makeEditDir(name, version) await pacote.extract(selectorKey(name, version), editDir, this.npm.flatOptions) output.standard(`You can now edit the following directory: ${editDir}`) output.standard(`When done, run: npm patch commit ${editDir}`) } async commit (args) { if (args.length !== 1) { throw this.usageError() } const editDir = resolve(args[0]) const { content: edited } = await PackageJson.normalize(editDir).catch(() => { throw Object.assign( new Error(`No package.json found in edit directory: ${editDir}`), { code: 'EPATCHNOEDITDIR' } ) }) const { name, version } = edited if (!name || !version) { throw new Error(`Edit directory package.json is missing name or version: ${editDir}`) } // a conflicted `patch update` leaves a marker so this commit drops the renamed-from selector // it is kept on disk (and excluded from the diff) so a re-run after a no-op resolution still finalizes the update const markerPath = join(editDir, UPDATE_MARKER) const markerRaw = await readFile(markerPath, 'utf8').catch(() => null) let marker = null if (markerRaw !== null) { try { marker = JSON.parse(markerRaw) } catch { throw patchErr(`invalid update marker in ${editDir}`, 'EPATCHBADMARKER') } } // extract a clean baseline to diff against const base = await mkdtemp(join(tmpdir(), 'npm-patch-base-')) let diff, packageJsonChanged try { await pacote.extract(selectorKey(name, version), base, this.npm.flatOptions) ;({ diff, packageJsonChanged } = await diffDirs(base, editDir, new Set([UPDATE_MARKER]))) } finally { await rm(base, { recursive: true, force: true }) } if (!diff) { // package.json is excluded from patches, so an edit limited to it captures nothing const reason = packageJsonChanged ? `only package.json changed in ${editDir}, which is not patchable; nothing to commit` : `no changes detected in ${editDir}; nothing to commit` log.warn('patch', reason) return } if (packageJsonChanged) { log.warn('patch', 'changes to package.json are not included in patches and were ignored') } const patchesDir = this.npm.config.get('patches-dir') const absPatch = resolve(this.#root, patchFilePath(patchesDir, name, version)) // refuse to write outside the project so the patch set stays in the repo const relPatch = containedRelative(this.#root, absPatch) if (!relPatch) { throw Object.assign( new Error(`patches-dir "${patchesDir}" resolves outside the project root.`), { code: 'EPATCHUNSAFE' } ) } await mkdir(dirname(absPatch), { recursive: true }) await writeFile(absPatch, diff) const pkgJson = await PackageJson.load(this.#root) const patchedDependencies = { ...pkgJson.content.patchedDependencies } const newKey = selectorKey(name, version) patchedDependencies[newKey] = relPatch // a marker left by a conflicted `patch update` for this package means a metadata-only finalize. // its name is checked so a stray or forged marker can never hijack a normal commit. const updateFinalize = !!marker && marker.name === name let orphan = null // an exact rename drops the renamed-from selector; a fork carries removeKey null and keeps the old entry. // removeKey must be a real, same-package selector string, so a malformed marker can neither crash nor drop the wrong entry. if (updateFinalize && typeof marker.removeKey === 'string' && marker.removeKey !== newKey && patchedDependencies[marker.removeKey] !== undefined && parseSelector(marker.removeKey).name === name) { orphan = this.#dropSelector(patchedDependencies, marker.removeKey) } pkgJson.update({ patchedDependencies }) await pkgJson.save() // finishing a conflicted update is metadata-only (like update itself): the new version may not be installed yet const arb = updateFinalize ? this.#newArborist({ packageLockOnly: true, allowUnusedPatches: true, audit: false }) : this.#newArborist() await arb.reify(arb.options) await reifyFinish(this.npm, arb) // remove the renamed-from patch file only after the lockfile is durable await this.#removePatchFile(orphan) if (!this.npm.config.get('keep-edit-dir')) { await rm(editDir, { recursive: true, force: true }) } output.standard(`Patched ${newKey} -> ${relPatch}`) } // Remove a selector from the map; return its patch file path if nothing else references it. #dropSelector (patched, key) { const patchPath = patched[key] delete patched[key] /* istanbul ignore next - the shared-file branch only fires when two selectors point at one patch */ return (patchPath && !Object.values(patched).includes(patchPath)) ? patchPath : null } // Delete an orphaned patch file, but never one that escapes the project root. async #removePatchFile (patchPath) { if (!patchPath) { return } const abs = resolve(this.#root, patchPath) /* istanbul ignore else - defensive: orphaned paths come from the manifest and stay in-project */ if (containedRelative(this.#root, abs)) { await rm(abs, { force: true }) } } async update (args) { if (args.length !== 1) { throw this.usageError() } const pkgJson = await PackageJson.load(this.#root) const patched = { ...pkgJson.content.patchedDependencies } const entry = this.#resolveUpdateEntry(args[0], patched) const baseVersion = patchFileVersion(entry.patchPath) if (!baseVersion) { throw patchErr( `cannot determine the version "${entry.patchPath}" was authored against; rebase by hand`, 'EPATCHBASE' ) } const installedVersions = await this.#installedVersions(entry.name) const newVersion = this.#resolveNewVersion(entry, installedVersions) if (newVersion === baseVersion) { throw patchErr(`nothing to update: the patch already targets ${selectorKey(entry.name, newVersion)}`, 'EPATCHNOOP') } const newKey = selectorKey(entry.name, newVersion) if (patched[newKey] && newKey !== entry.key) { throw patchErr( `an entry already exists for ${newKey}; use "npm patch rm" first or rebase manually`, 'EPATCHEXISTS' ) } const rebase = await this.#rebasePatch({ name: entry.name, baseVersion, newVersion, patchAbs: resolve(this.#root, entry.patchPath), }) try { if (rebase.conflicted) { const editDir = await this.#makeEditDir(entry.name, newVersion) try { await cp(rebase.repo, editDir, { recursive: true, filter: notGitDir }) // mark the conflict so the finalizing commit knows this is an update finalize. // an exact selector is a rename (drop the old key); a range/name-only is a fork (keep it). const removeKey = entry.spec && semver.valid(entry.spec) ? entry.key : null await writeFile(join(editDir, UPDATE_MARKER), JSON.stringify({ name: entry.name, removeKey }) + '\n') } catch (er) { // discard a temp edit dir we created if the copy or marker write fails; never a user-supplied --edit-dir /* istanbul ignore next - a failing copy/marker write is not deterministically reproducible */ if (!this.npm.config.get('edit-dir')) { await rm(editDir, { recursive: true, force: true }) } /* istanbul ignore next */ throw er } output.standard(`Patch did not apply cleanly to ${newKey}.`) output.standard(`Resolve the conflicts in: ${editDir}`) output.standard(`When done, run: npm patch commit ${editDir}`) return } const { diff } = await diffDirs(rebase.newDir, rebase.repo) if (!diff) { throw patchErr( `the patch no longer changes ${newKey}; remove it with "npm patch rm" instead`, 'EPATCHEMPTY' ) } await this.#finalizeUpdate({ pkgJson, patched, entry, name: entry.name, newVersion, newKey, diff, installedVersions, }) } finally { await rm(rebase.work, { recursive: true, force: true }) } } // Identify the single patchedDependencies entry the update targets. #resolveUpdateEntry (spec, patched) { const target = npa(spec) const name = target.name const oldSpec = target.rawSpec && target.rawSpec !== '*' ? target.fetchSpec : null const keys = Object.keys(patched) if (oldSpec) { const key = selectorKey(name, oldSpec) if (!patched[key]) { throw patchErr(`no patch registered for ${key}`, 'EPATCHNOTFOUND') } return { key, name, spec: oldSpec, patchPath: patched[key] } } const matches = keys.filter(k => parseSelector(k).name === name) if (!matches.length) { throw patchErr(`no patch to update for "${name}"`, 'EPATCHNOTFOUND') } if (matches.length > 1) { throw patchErr( `multiple patches match "${name}":\n${matches.map(k => ` ${k}`).join('\n')}\n` + `Re-run with an explicit selector, e.g. "npm patch update ${matches[0]}".`, 'EPATCHAMBIGUOUS' ) } const key = matches[0] return { key, name, spec: parseSelector(key).spec, patchPath: patched[key] } } // Determine the version to rebase onto: --to, else the installed version matching the selector. #resolveNewVersion (entry, installedVersions) { const to = this.npm.config.get('to') if (to) { const valid = semver.valid(to) if (!valid) { throw patchErr(`--to "${to}" is not a valid version`, 'EPATCHBADTO') } // update finalizes with allowUnusedPatches, so a --to absent from the tree records an unused patch silently. // The next plain install then rejects with EPATCHUNUSED, so warn now to surface the mismatch early. if (installedVersions !== null && !installedVersions.includes(valid)) { log.warn('patch', `${selectorKey(entry.name, valid)} is not installed; ` + `bump the dependency and reinstall, or the next "npm install" will fail with EPATCHUNUSED.`) } return valid } if (installedVersions === null) { throw patchErr( `could not read the lockfile for "${entry.name}"; ` + `run "npm install" first or pass --to <new-version>`, 'EPATCHSTALE' ) } const { spec } = entry let matching if (spec && semver.valid(spec)) { matching = installedVersions.filter(v => semver.eq(v, spec)) } else if (spec) { matching = installedVersions.filter(v => semver.satisfies(v, spec)) } else { matching = installedVersions } if (!matching.length) { throw patchErr( `no installed version matches the patch selector "${entry.key}"; ` + `pass --to <new-version> to rebase onto a specific version`, 'EPATCHSTALE' ) } return matching.sort(semver.rcompare)[0] } // Distinct registry-installed versions of a package from the lockfile, or null when it cannot be loaded. // Uses loadVirtual so the lockfile's own validity gates staleness and links/workspaces are excluded. async #installedVersions (name) { let tree try { tree = await this.#newArborist().loadVirtual() } catch { return null } const versions = new Set() for (const node of tree.inventory.values()) { if (node.isProjectRoot || node.isWorkspace || node.isLink || !node.version) { continue } /* istanbul ignore else - other-named nodes are skipped; only exercised with multi-package trees */ if (node.name === name) { versions.add(node.version) } } return [...versions] } // 3-way merge the existing patch onto the new version via a throwaway git repo. // Returns { work, repo, newDir, conflicted }; the caller removes work on success. async #rebasePatch ({ name, baseVersion, newVersion, patchAbs }) { const work = await mkdtemp(join(tmpdir(), 'npm-patch-rebase-')) try { const baseDir = join(work, 'base') const newDir = join(work, 'new') const repo = join(work, 'repo') await pacote.extract(selectorKey(name, baseVersion), baseDir, this.npm.flatOptions) await pacote.extract(selectorKey(name, newVersion), newDir, this.npm.flatOptions) await mkdir(repo, { recursive: true }) const id = ['-c', 'user.name=npm', '-c', 'user.email=npm@npmjs.com', '-c', 'commit.gpgsign=false'] await this.#git(['init', '-q', '-b', 'base'], repo) // base = the version the patch was authored against await cp(baseDir, repo, { recursive: true }) await this.#git(['add', '-Af'], repo) await this.#git([...id, 'commit', '-qm', 'base', '--allow-empty'], repo) // theirs = base + the existing patch, applied with the same jsdiff helper installs use await this.#git(['checkout', '-q', '-b', 'theirs'], repo) try { await applyPatchToDir({ patch: await readFile(patchAbs), cwd: repo }) } catch (er) { throw patchErr( `the existing patch no longer applies to its baseline ${selectorKey(name, baseVersion)}; rebase by hand`, 'EPATCHBASE', { cause: er } ) } await this.#git(['add', '-Af'], repo) await this.#git([...id, 'commit', '-qm', 'theirs', '--allow-empty'], repo) // ours = the new version, branched from base await this.#git(['checkout', '-q', '-b', 'ours', 'base'], repo) await this.#git(['rm', '-rqf', '.'], repo) await cp(newDir, repo, { recursive: true }) await this.#git(['add', '-Af'], repo) await this.#git([...id, 'commit', '-qm', 'ours', '--allow-empty'], repo) // replay the patch's intent onto the new version let conflicted = false try { await this.#git([...id, 'merge', '--no-edit', 'theirs'], repo) } catch (er) { // a genuine conflict leaves unmerged paths; any other non-zero exit is a real git failure const unmerged = await this.#git(['ls-files', '--unmerged'], repo) /* istanbul ignore else - a non-conflict merge failure is a git/environment fault */ if (String(unmerged.stdout).trim()) { conflicted = true } else { throw er } } return { work, repo, newDir, conflicted } } catch (er) { await rm(work, { recursive: true, force: true }) throw er } } #git (args, cwd) { return git.spawn(args, { cwd, env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' }, }) } // Apply the selector rules, write the new patch, and sync the lockfile without touching node_modules. async #finalizeUpdate (opts) { const { pkgJson, patched, entry, name, newVersion, newKey, diff, installedVersions } = opts const patchesDir = this.npm.config.get('patches-dir') const absPatch = resolve(this.#root, patchFilePath(patchesDir, name, newVersion)) const relPatch = containedRelative(this.#root, absPatch) if (!relPatch) { throw patchErr(`patches-dir "${patchesDir}" resolves outside the project root.`, 'EPATCHUNSAFE') } // write the new patch first so no selector points at a missing file await mkdir(dirname(absPatch), { recursive: true }) await writeFile(absPatch, diff) patched[newKey] = relPatch // drop the old selector only when it no longer wins any installed node, so a still-needed range survives let orphan = null if (entry.key !== newKey && this.#selectorUnused(patched, entry.key, name, installedVersions)) { orphan = this.#dropSelector(patched, entry.key) } pkgJson.update({ patchedDependencies: patched }) await pkgJson.save() // record the new patch integrity in the lockfile only; update never touches node_modules const arb = this.#newArborist({ packageLockOnly: true, allowUnusedPatches: true, audit: false }) await arb.reify(arb.options) await reifyFinish(this.npm, arb) // remove the now-orphaned old patch file only after the lockfile is durable await this.#removePatchFile(orphan) output.standard(`Updated ${entry.key} -> ${newKey} (${relPatch})`) } // Whether `key` should be dropped once the new selector is added. // An exact selector is a straight rename. A range/name-only selector is dropped only when it no longer // wins any installed node under the installer's exact > range > name-only precedence, so a kept one is still used. #selectorUnused (patched, key, name, installedVersions) { const { spec } = parseSelector(key) if (spec && semver.valid(spec)) { return true } if (installedVersions === null) { return false } const selectors = Object.keys(patched) .filter(k => parseSelector(k).name === name) .map(k => ({ ...parseSelector(k), key: k })) for (const version of installedVersions) { let winner try { winner = matchSelector(selectors, { name, version }) } catch { /* istanbul ignore next - ambiguous overlapping ranges: keep the selector to be safe */ return false } /* istanbul ignore if - a still-winning selector is kept; only exercised with multiple installed versions */ if (winner?.key === key) { return false } } return true } async ls () { const pkgJson = await PackageJson.normalize(this.#root).catch(() => ({ content: {} })) const patched = pkgJson.content.patchedDependencies || {} const keys = Object.keys(patched) if (!keys.length) { return } // count nodes per patch using the same precedence Arborist applies at install const tree = await this.#loadActual() const selectors = keys.map(key => ({ ...parseSelector(key), key, patchPath: patched[key] })) const counts = new Map(keys.map(key => [key, 0])) // only the overlapping range selectors that actually conflict on a node const ambiguous = new Set() for (const node of tree.inventory.values()) { if (node.isProjectRoot || node.isLink || !node.version) { continue } let winner = null try { winner = matchSelector(selectors, node) } catch { for (const s of selectors) { if (s.name === node.name && s.spec && !semver.valid(s.spec) && semver.satisfies(node.version, s.spec)) { ambiguous.add(s.key) } } continue } if (winner) { counts.set(winner.key, counts.get(winner.key) + 1) } } for (const key of keys) { if (ambiguous.has(key)) { output.standard(`${patched[key]}\t${key}\t(error: ambiguous selectors)`) continue } const n = counts.get(key) output.standard(`${patched[key]}\t${key}\t(${n} node${n === 1 ? '' : 's'})`) } } async rm (args) { if (args.length !== 1) { throw this.usageError() } const target = npa(args[0]) const targetName = target.name const targetVersion = target.rawSpec && target.rawSpec !== '*' ? target.fetchSpec : null const pkgJson = await PackageJson.load(this.#root) const patched = { ...pkgJson.content.patchedDependencies } const removed = [] for (const key of Object.keys(patched)) { const { name, spec } = parseSelector(key) if (name === targetName && (!targetVersion || spec === targetVersion)) { removed.push(key) } } if (!removed.length) { throw Object.assign( new Error(`No registered patch found for "${args[0]}".`), { code: 'EPATCHNOTFOUND' } ) } for (const key of removed) { const patchPath = patched[key] delete patched[key] // only delete the file when no remaining selector references it if (!Object.values(patched).includes(patchPath)) { const abs = resolve(this.#root, patchPath) // never delete a path that escapes the project root if (!containedRelative(this.#root, abs)) { throw Object.assign( new Error(`Refusing to delete patch outside the project root: ${patchPath}`), { code: 'EPATCHUNSAFE' } ) } await rm(abs, { force: true }) } } if (Object.keys(patched).length) { pkgJson.update({ patchedDependencies: patched }) } else { delete pkgJson.content.patchedDependencies } await pkgJson.save() const arb = this.#newArborist() await arb.reify(arb.options) await reifyFinish(this.npm, arb) output.standard(`Removed patch${removed.length === 1 ? '' : 'es'}: ${removed.join(', ')}`) } } module.exports = Patch