Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/lib/content/commands/npm-patch.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ package literally named like a subcommand must use the explicit form, e.g.
If more than one version of `<pkg>` is installed, re-run with an exact
selector such as `npm patch add lodash@4.17.21`.

For an `npm:` alias, pass the alias name, e.g. `npm patch add my-lodash`
for `"my-lodash": "npm:lodash@^4"`, or the full alias spec
`npm patch add my-lodash@npm:lodash@4.17.21`. npm extracts the real
package, and `commit` keys the selector and patch file on the alias name.

* `npm patch commit <edit-dir>`

Diffs the edited directory against a clean copy of the original tarball,
Expand Down
157 changes: 113 additions & 44 deletions lib/commands/patch.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ 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'
// Marker left in an npm: alias edit dir so commit keys the selector on the alias, not the real package name.
const ALIAS_MARKER = '.npm-patch-alias.json'
const MARKERS = new Set([UPDATE_MARKER, ALIAS_MARKER])

// fs.cp filter that skips a git working directory.
const notGitDir = src => !src.split(sep).includes('.git')
Expand All @@ -39,6 +42,10 @@ const patchFileVersion = patchPath => {
const patchErr = (message, code, extra = {}) =>
Object.assign(new Error(message), { code, ...extra })

// Registry package names a dependency called `name` is fetched as: an npm: alias spec names its target.
const declaredSources = (specs, name) =>
new Set(specs.length ? specs.map(spec => spec.startsWith('npm:') ? npa(spec.slice(4)).name : name) : [name])

// 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('/')
Expand Down Expand Up @@ -105,14 +112,18 @@ class Patch extends BaseCommand {
return this.#newArborist().loadActual()
}

// Resolve a user spec to a concrete registry name@version to patch.
// Resolve a user spec to the selector name, version, and registry package (source) to patch.
// source differs from name only for an npm: alias, whose tarball is fetched under its real name.
async #resolveTarget (spec) {
const parsed = npa(spec)
if (parsed.type && !parsed.registry) {
throw this.#nonRegistryError(spec)
}

const { name } = parsed
// name@npm:pkg@range resolves the range against pkg
const explicitSource = parsed.type === 'alias' ? parsed.subSpec.name : null
const wanted = parsed.type === 'alias' ? parsed.subSpec : parsed
const tree = await this.#loadActual()
// group every installed node by version so mixed-source duplicates are seen
const installed = new Map()
Expand All @@ -133,39 +144,83 @@ class Patch extends BaseCommand {
}
}

// pick the one registry package behind name@version; installed nodes win over declared specs
const sourceOf = version => {
const nodes = version ? installed.get(version) || [] : [...installed.values()].flat()
let sources = new Set(nodes.map(n => n.packageName || name))
if (!sources.size) {
const specs = [...tree.inventory.values()]
.map(n => n.edgesOut.get(name)?.spec).filter(Boolean)
sources = explicitSource ? new Set([explicitSource]) : declaredSources(specs, name)
}
if (explicitSource && [...sources].some(s => s !== explicitSource)) {
throw patchErr(`"${name}" is installed as "${[...sources].join('", "')}", not "${explicitSource}".`,
'EPATCHALIAS')
}
if (sources.size > 1) {
throw patchErr(`"${name}" resolves to more than one package ` +
`("${[...sources].join('", "')}"), so a "${name}" selector would patch all of them.`, 'EPATCHAMBIGUOUS')
}
return [...sources][0]
}
const target = version => {
ensureRegistry(version)
return { name, version, source: sourceOf(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 (wanted.rawSpec && wanted.rawSpec !== '*' && wanted.rawSpec !== 'latest') {
const exact = semver.valid(wanted.fetchSpec)
if (exact) {
ensureRegistry(exact)
return { name, version: exact }
return target(exact)
}
const matches = [...installed.keys()].filter(v => semver.satisfies(v, parsed.fetchSpec))
const matches = [...installed.keys()].filter(v => semver.satisfies(v, wanted.fetchSpec))
if (matches.length > 1) {
throw this.#ambiguousError(name, matches, installed)
}
if (matches.length === 1) {
ensureRegistry(matches[0])
return { name, version: matches[0] }
return target(matches[0])
}
// resolve the range against the registry
const mani = await pacote.manifest(spec, this.npm.flatOptions)
return { name: mani.name, version: mani.version }
const source = sourceOf(null)
const mani = await pacote.manifest(`${source}@${wanted.rawSpec}`, this.npm.flatOptions)
return { name, version: mani.version, source }
}

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' }
)
// point at the alias when the real package name was given
const aliases = [...new Set([...tree.inventory.values()]
.filter(n => n.packageName === name && n.name !== name && !n.isProjectRoot)
.map(n => n.name))]
const hint = aliases.length
? `It is installed as "${aliases.join('", "')}"; patch that name instead.`
: 'Run "npm install" first, or pass an explicit version.'
throw patchErr(`No installed version of "${name}" found. ${hint}`, 'EPATCHNOTINSTALLED')
}
if (installed.size > 1) {
throw this.#ambiguousError(name, [...installed.keys()], installed)
}
const [version] = [...installed.keys()]
ensureRegistry(version)
return { name, version }
return target([...installed.keys()][0])
}

// Record that an edit dir holds an npm: alias of source.
async #writeAliasMarker (editDir, name, source) {
if (name !== source) {
await writeFile(join(editDir, ALIAS_MARKER), JSON.stringify({ name, source }) + '\n')
}
}

// Parse a marker file left in an edit dir, or null when there is none.
async #readMarker (editDir, file) {
const raw = await readFile(join(editDir, file), 'utf8').catch(() => null)
if (raw === null) {
return null
}
try {
return JSON.parse(raw)
} catch {
throw patchErr(`invalid ${file} marker in ${editDir}`, 'EPATCHBADMARKER')
}
}

#nonRegistryError (label) {
Expand Down Expand Up @@ -202,17 +257,20 @@ class Patch extends BaseCommand {
await rm(editDir, { recursive: true, force: true })
}
await mkdir(editDir, { recursive: true })
// clear a stale alias marker before extracting, so a file the package ships is never touched
await rm(join(editDir, ALIAS_MARKER), { force: true })
return editDir
}

async add (args) {
if (args.length !== 1) {
throw this.usageError()
}
const { name, version } = await this.#resolveTarget(args[0])
const { name, version, source } = await this.#resolveTarget(args[0])
const editDir = await this.#makeEditDir(name, version)

await pacote.extract(selectorKey(name, version), editDir, this.npm.flatOptions)
await pacote.extract(selectorKey(source, version), editDir, this.npm.flatOptions)
await this.#writeAliasMarker(editDir, name, source)

output.standard(`You can now edit the following directory: ${editDir}`)
output.standard(`When done, run: npm patch commit ${editDir}`)
Expand All @@ -229,30 +287,28 @@ class Patch extends BaseCommand {
{ code: 'EPATCHNOEDITDIR' }
)
})
const { name, version } = edited
if (!name || !version) {
const { name: source, version } = edited
if (!source || !version) {
throw new Error(`Edit directory package.json is missing name or version: ${editDir}`)
}

// an alias marker must describe this very package and a registry name, so a stale one cannot rename it
const alias = await this.#readMarker(editDir, ALIAS_MARKER)
if (alias && (alias.source !== source || typeof alias.name !== 'string' || npa(alias.name).name !== alias.name)) {
throw patchErr(`invalid ${ALIAS_MARKER} marker in ${editDir}`, 'EPATCHBADMARKER')
}
const name = alias ? alias.name : source

// 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')
}
}
const marker = await this.#readMarker(editDir, UPDATE_MARKER)

// 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])))
await pacote.extract(selectorKey(source, version), base, this.npm.flatOptions)
;({ diff, packageJsonChanged } = await diffDirs(base, editDir, MARKERS))
} finally {
await rm(base, { recursive: true, force: true })
}
Expand Down Expand Up @@ -354,7 +410,8 @@ class Patch extends BaseCommand {
)
}

const installedVersions = await this.#installedVersions(entry.name)
const { versions: installedVersions, sources } =
await this.#installedVersions(entry.name, pkgJson.content)
const newVersion = this.#resolveNewVersion(entry, installedVersions)
if (newVersion === baseVersion) {
throw patchErr(`nothing to update: the patch already targets ${selectorKey(entry.name, newVersion)}`, 'EPATCHNOOP')
Expand All @@ -367,8 +424,14 @@ class Patch extends BaseCommand {
)
}

// an npm: alias rebases the real package's tarballs but keeps the alias selector
if (sources.size > 1) {
throw patchErr(`"${entry.name}" resolves to more than one package ("${[...sources].join('", "')}"); rebase by hand`,
'EPATCHAMBIGUOUS')
}
const [source] = sources
const rebase = await this.#rebasePatch({
name: entry.name,
source,
baseVersion,
newVersion,
patchAbs: resolve(this.#root, entry.patchPath),
Expand All @@ -382,6 +445,7 @@ class Patch extends BaseCommand {
// 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')
await this.#writeAliasMarker(editDir, entry.name, source)
} 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 */
Expand Down Expand Up @@ -486,38 +550,43 @@ class Patch extends BaseCommand {
return matching.sort(semver.rcompare)[0]
}

// Distinct registry-installed versions of a package from the lockfile, or null when it cannot be loaded.
// Distinct registry-installed versions of a package from the lockfile (null when it cannot be loaded), plus the registry packages it is fetched as.
// Uses loadVirtual so the lockfile's own validity gates staleness and links/workspaces are excluded.
async #installedVersions (name) {
async #installedVersions (name, rootPkg) {
// an uninstalled package can still be told apart as an npm: alias from the root manifest
const declared = () => declaredSources(['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']
.map(type => rootPkg[type]?.[name]).filter(Boolean), name)
let tree
try {
tree = await this.#newArborist().loadVirtual()
} catch {
return null
return { versions: null, sources: declared() }
}
const versions = new Set()
const sources = 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)
sources.add(node.packageName)
}
}
return [...versions]
return { versions: [...versions], sources: sources.size ? sources : declared() }
}

// 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 }) {
async #rebasePatch ({ source, 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 pacote.extract(selectorKey(source, baseVersion), baseDir, this.npm.flatOptions)
await pacote.extract(selectorKey(source, 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']
Expand All @@ -532,7 +601,7 @@ class Patch extends BaseCommand {
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`,
`the existing patch no longer applies to its baseline ${selectorKey(source, baseVersion)}; rebase by hand`,
'EPATCHBASE',
{ cause: er }
)
Expand Down
Loading
Loading