diff --git a/docs/lib/content/commands/npm-patch.md b/docs/lib/content/commands/npm-patch.md index 030c414f4d6ac..9359ae3c8efb8 100644 --- a/docs/lib/content/commands/npm-patch.md +++ b/docs/lib/content/commands/npm-patch.md @@ -33,6 +33,11 @@ package literally named like a subcommand must use the explicit form, e.g. If more than one version of `` 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 ` Diffs the edited directory against a clean copy of the original tarball, diff --git a/lib/commands/patch.js b/lib/commands/patch.js index 67b8bc4555572..0bdac36d58c1c 100644 --- a/lib/commands/patch.js +++ b/lib/commands/patch.js @@ -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') @@ -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('/') @@ -105,7 +112,8 @@ 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) { @@ -113,6 +121,9 @@ class Patch extends BaseCommand { } 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() @@ -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) { @@ -202,6 +257,8 @@ 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 } @@ -209,10 +266,11 @@ class Patch extends BaseCommand { 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}`) @@ -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 }) } @@ -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') @@ -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), @@ -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 */ @@ -486,16 +550,20 @@ 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 @@ -503,21 +571,22 @@ class Patch extends BaseCommand { /* 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'] @@ -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 } ) diff --git a/test/lib/commands/patch.js b/test/lib/commands/patch.js index ac409bf2d175a..1896ab0681e04 100644 --- a/test/lib/commands/patch.js +++ b/test/lib/commands/patch.js @@ -1326,3 +1326,269 @@ t.test('install honors --allow-unused-patches only from the cli', async t => { await t.rejects(npm.exec('install', []), { code: 'EPATCHUNUSED' }) }) }) + +// Root project depending on the patchable dep through an npm: alias. +const ALIAS = 'aliased' +const aliasPrefix = () => ({ + 'dep-tarball': depTarball, + 'package.json': JSON.stringify({ + name: 'root-project', version: '1.0.0', dependencies: { [ALIAS]: `npm:${DEP_NAME}@^${DEP_VERSION}` }, + }), +}) + +t.test('add + commit an npm: alias keys the selector on the alias name', async t => { + const { npm, joinedOutput, outputs, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: aliasPrefix(), + }) + await setupDep(npm, registry) + await npm.exec('install', []) + + outputs.length = 0 + await npm.exec('patch', ['add', ALIAS]) + const editDir = joinedOutput().match(/directory: (.+)/)[1].trim() + t.equal(readJson(path.join(editDir, 'package.json')).name, DEP_NAME, 'extracted the real package') + fs.writeFileSync(path.join(editDir, 'index.js'), 'module.exports = "patched"\n') + await npm.exec('patch', ['commit', editDir]) + + const key = `${ALIAS}@${DEP_VERSION}` + const patchFile = path.join(npm.prefix, 'patches', `${key}.patch`) + t.same(readJson(path.join(npm.prefix, 'package.json')).patchedDependencies, + { [key]: `patches/${key}.patch` }, 'selector uses the alias name') + t.notMatch(fs.readFileSync(patchFile, 'utf8'), '.npm-patch-alias.json', 'marker is not part of the patch') + t.equal(fs.readFileSync(path.join(npm.prefix, 'node_modules', ALIAS, 'index.js'), 'utf8'), + 'module.exports = "patched"\n', 'installed alias is patched') + t.ok(readJson(path.join(npm.prefix, 'package-lock.json')).packages[`node_modules/${ALIAS}`].patched, + 'lockfile records the patch on the alias node') +}) + +t.test('add: an explicit npm: alias spec extracts the real package without install', async t => { + const { npm, joinedOutput, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: basePrefix(), + }) + await setupDep(npm, registry) + await npm.exec('patch', ['add', `${ALIAS}@npm:${DEP_NAME}@${DEP_VERSION}`]) + const editDir = joinedOutput().match(/directory: (.+)/)[1].trim() + t.equal(readJson(path.join(editDir, 'package.json')).name, DEP_NAME, 'extracted the real package') + t.same(readJson(path.join(editDir, '.npm-patch-alias.json')), { name: ALIAS, source: DEP_NAME }, + 'alias marker written') +}) + +t.test('add: a declared but uninstalled alias range resolves against the real package', async t => { + const { npm, joinedOutput, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: aliasPrefix(), + }) + await setupDep(npm, registry) + await npm.exec('patch', ['add', `${ALIAS}@^1.0.0`]) + const editDir = joinedOutput().match(/directory: (.+)/)[1].trim() + t.equal(readJson(path.join(editDir, 'package.json')).name, DEP_NAME, 'extracted the real package') +}) + +t.test('add: an explicit alias that disagrees with the installed package rejects', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: { + 'package.json': JSON.stringify({ name: 'root-project', version: '1.0.0' }), + node_modules: { [ALIAS]: { 'package.json': JSON.stringify({ name: DEP_NAME, version: DEP_VERSION }) } }, + }, + }) + await t.rejects(npm.exec('patch', ['add', `${ALIAS}@npm:other@${DEP_VERSION}`]), { code: 'EPATCHALIAS' }) +}) + +t.test('add: one name installed as two packages at the same version is ambiguous', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: { + 'package.json': JSON.stringify({ name: 'root-project', version: '1.0.0' }), + node_modules: { + [ALIAS]: { 'package.json': JSON.stringify({ name: ALIAS, version: DEP_VERSION }) }, + host: { + 'package.json': JSON.stringify({ name: 'host', version: '1.0.0' }), + node_modules: { [ALIAS]: { 'package.json': JSON.stringify({ name: DEP_NAME, version: DEP_VERSION }) } }, + }, + }, + }, + }) + await t.rejects(npm.exec('patch', ['add', ALIAS]), { code: 'EPATCHAMBIGUOUS', message: /more than one package/ }) +}) + +t.test('add: the real name of an installed alias points at the alias', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: { + 'package.json': JSON.stringify({ name: 'root-project', version: '1.0.0' }), + node_modules: { [ALIAS]: { 'package.json': JSON.stringify({ name: DEP_NAME, version: DEP_VERSION }) } }, + }, + }) + await t.rejects(npm.exec('patch', ['add', DEP_NAME]), + { code: 'EPATCHNOTINSTALLED', message: new RegExp(`installed as "${ALIAS}"`) }) +}) + +t.test('add: a reused edit dir drops a stale alias marker', async t => { + const { npm, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: { + ...basePrefix(), + edit: { '.npm-patch-alias.json': JSON.stringify({ name: ALIAS, source: DEP_NAME }) }, + }, + }) + await setupDep(npm, registry) + const editDir = path.join(npm.prefix, 'edit') + npm.config.set('edit-dir', editDir) + await npm.exec('patch', ['add', `${DEP_NAME}@${DEP_VERSION}`]) + t.notOk(fs.existsSync(path.join(editDir, '.npm-patch-alias.json')), 'stale marker removed') +}) + +t.test('commit: an alias marker for another package rejects with EPATCHBADMARKER', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: { + 'package.json': JSON.stringify({ name: 'root-project', version: '1.0.0' }), + edit: { + 'package.json': JSON.stringify({ name: DEP_NAME, version: DEP_VERSION }), + '.npm-patch-alias.json': JSON.stringify({ name: ALIAS, source: 'other' }), + }, + }, + }) + await t.rejects(npm.exec('patch', ['commit', path.join(npm.prefix, 'edit')]), { code: 'EPATCHBADMARKER' }) +}) + +t.test('commit: an alias marker naming a path rejects with EPATCHBADMARKER', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: { + 'package.json': JSON.stringify({ name: 'root-project', version: '1.0.0' }), + edit: { + 'package.json': JSON.stringify({ name: DEP_NAME, version: DEP_VERSION }), + '.npm-patch-alias.json': JSON.stringify({ name: '../escape', source: DEP_NAME }), + }, + }, + }) + await t.rejects(npm.exec('patch', ['commit', path.join(npm.prefix, 'edit')]), { code: 'EPATCHBADMARKER' }) +}) + +t.test('update rebases an npm: alias patch onto the real package', async t => { + const real = 'upd-alias-real' + const { npm, joinedOutput, outputs, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: rootWith({ [ALIAS]: `npm:${real}@^1.0.0` }), + }) + await setupVersions(npm, registry, real, { '1.0.0': 'a\nb\nc\n', '2.0.0': 'a\nb\nCC\n' }) + await npm.exec('install', []) + outputs.length = 0 + await npm.exec('patch', ['add', ALIAS]) + const editDir = joinedOutput().match(/directory: (.+)/)[1].trim() + fs.writeFileSync(path.join(editDir, 'index.js'), 'AA\nb\nc\n') + await npm.exec('patch', ['commit', editDir]) + + npm.config.set('to', '2.0.0') + await npm.exec('patch', ['update', ALIAS]) + t.same(readJson(path.join(npm.prefix, 'package.json')).patchedDependencies, + { [`${ALIAS}@2.0.0`]: `patches/${ALIAS}@2.0.0.patch` }, 'alias selector renamed to the new version') + t.match(fs.readFileSync(path.join(npm.prefix, 'patches', `${ALIAS}@2.0.0.patch`), 'utf8'), /\+AA/, + 'rebased patch keeps the edit') +}) + +t.test('update conflict on an npm: alias keeps the alias through the resolving commit', async t => { + const real = 'upd-alias-conflict' + const { npm, joinedOutput, outputs, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: rootWith({ [ALIAS]: `npm:${real}@^1.0.0` }), + }) + await setupVersions(npm, registry, real, { '1.0.0': 'a\nb\nc\n', '2.0.0': 'a\nBB\nc\n' }) + await npm.exec('install', []) + outputs.length = 0 + await npm.exec('patch', ['add', ALIAS]) + const addDir = joinedOutput().match(/directory: (.+)/)[1].trim() + fs.writeFileSync(path.join(addDir, 'index.js'), 'a\nMINE\nc\n') + await npm.exec('patch', ['commit', addDir]) + + npm.config.set('to', '2.0.0') + outputs.length = 0 + await npm.exec('patch', ['update', ALIAS]) + const editDir = joinedOutput().match(/Resolve the conflicts in: (.+)/)[1].trim() + t.same(readJson(path.join(editDir, '.npm-patch-alias.json')), { name: ALIAS, source: real }, + 'alias marker written for the conflict') + fs.writeFileSync(path.join(editDir, 'index.js'), 'a\nMINE\nc\n') + await npm.exec('patch', ['commit', editDir]) + t.same(readJson(path.join(npm.prefix, 'package.json')).patchedDependencies, + { [`${ALIAS}@2.0.0`]: `patches/${ALIAS}@2.0.0.patch` }, 'renamed on the alias after the resolving commit') +}) + +t.test('update: a name locked as two packages rejects with EPATCHAMBIGUOUS', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: { + 'package.json': JSON.stringify({ + name: 'root-project', + version: '1.0.0', + dependencies: { [ALIAS]: `npm:${DEP_NAME}@1.0.0`, host: '1.0.0' }, + patchedDependencies: { [`${ALIAS}@1.0.0`]: `patches/${ALIAS}@1.0.0.patch` }, + }), + patches: { [`${ALIAS}@1.0.0.patch`]: '' }, + 'package-lock.json': JSON.stringify({ + name: 'root-project', + version: '1.0.0', + lockfileVersion: 3, + requires: true, + packages: { + '': { name: 'root-project', version: '1.0.0', dependencies: { [ALIAS]: `npm:${DEP_NAME}@1.0.0`, host: '1.0.0' } }, + [`node_modules/${ALIAS}`]: { name: DEP_NAME, version: '1.0.0' }, + 'node_modules/host': { version: '1.0.0', dependencies: { [ALIAS]: '1.0.0' } }, + [`node_modules/host/node_modules/${ALIAS}`]: { version: '1.0.0' }, + }, + }), + }, + }) + npm.config.set('to', '2.0.0') + await t.rejects(npm.exec('patch', ['update', ALIAS]), { code: 'EPATCHAMBIGUOUS', message: /more than one package/ }) +}) + +t.test('add: an installed node without a package name falls back to its folder name', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: { + 'package.json': JSON.stringify({ name: 'root-project', version: '1.0.0' }), + node_modules: { [ALIAS]: { 'package.json': JSON.stringify({ version: DEP_VERSION }) } }, + }, + }) + await t.rejects(npm.exec('patch', ['add', `${ALIAS}@npm:other@${DEP_VERSION}`]), + { code: 'EPATCHALIAS', message: new RegExp(`installed as "${ALIAS}"`) }) +}) + +t.test('update: an alias missing from the lockfile rebases the declared package', async t => { + const real = 'upd-alias-unlocked' + const { npm, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: { + 'package.json': JSON.stringify({ + name: 'root-project', + version: '1.0.0', + dependencies: { [ALIAS]: `npm:${real}@^2.0.0` }, + patchedDependencies: { [`${ALIAS}@1.0.0`]: `patches/${ALIAS}@1.0.0.patch` }, + }), + patches: { [`${ALIAS}@1.0.0.patch`]: '--- /dev/null\t\n+++ b/EXTRA.txt\t\n@@ -0,0 +1 @@\n+extra\n' }, + 'package-lock.json': JSON.stringify({ + name: 'root-project', + version: '1.0.0', + lockfileVersion: 3, + requires: true, + packages: { '': { name: 'root-project', version: '1.0.0', dependencies: { [ALIAS]: `npm:${real}@^2.0.0` } } }, + }), + }, + }) + await setupVersions(npm, registry, real, { '1.0.0': 'x\n', '2.0.0': 'x\n' }) + npm.config.set('to', '2.0.0') + await npm.exec('patch', ['update', ALIAS]) + t.match(fs.readFileSync(path.join(npm.prefix, 'patches', `${ALIAS}@2.0.0.patch`), 'utf8'), /\+extra/, + 'rebased against the declared alias target') +})