diff --git a/lib/commands/outdated.js b/lib/commands/outdated.js index 714b3ab64da4d..34410d938f72a 100644 --- a/lib/commands/outdated.js +++ b/lib/commands/outdated.js @@ -156,7 +156,23 @@ class Outdated extends ArboristWorkspaceCmd { } async #getOutdatedInfo (edge) { - const alias = safeNpa(edge.spec)?.subSpec + // Global packages are iterated as Nodes (no parent declares them as deps), + // so `edge.spec` is undefined. Detect alias installs (e.g. + // `npm install -g cat@npm:dog@1.0.1`) by comparing the directory name + // (`node.name`) to the package's own name (`node.packageName`), and + // synthesize an alias spec so the registry lookup below targets the + // aliased package instead of the wrapper directory name. + // See https://github.com/npm/cli/issues/9706 + const isGlobalAliasNode = !edge.spec + && edge.packageName + && edge.name !== edge.packageName + && edge.version + const synthesizedSpec = isGlobalAliasNode + ? `npm:${edge.packageName}@${edge.version}` + : null + const effectiveEdgeSpec = synthesizedSpec ?? edge.spec + + const alias = safeNpa(effectiveEdgeSpec)?.subSpec const spec = npa(alias ? alias.name : edge.name) const node = edge.to || edge const { path, location, package: { version: current } = {} } = node @@ -178,7 +194,7 @@ class Outdated extends ArboristWorkspaceCmd { } // if it's not a range, version, or tag, skip it - if (!safeNpa(`${edge.name}@${edge.spec}`)?.registry) { + if (!safeNpa(`${edge.name}@${effectiveEdgeSpec}`)?.registry) { return null } @@ -195,7 +211,11 @@ class Outdated extends ArboristWorkspaceCmd { const latest = pickManifest(packument, '*', pickOpts) if (!current || current !== wanted.version || wanted.version !== latest.version) { this.#list.push({ - name: alias ? edge.spec.replace('npm', edge.name) : edge.name, + name: alias + ? (synthesizedSpec + ? `${edge.name}@${synthesizedSpec}` + : edge.spec.replace('npm', edge.name)) + : edge.name, path, type, current, diff --git a/test/lib/commands/outdated.js b/test/lib/commands/outdated.js index 34b8ea75190fc..35b5ad780dba1 100644 --- a/test/lib/commands/outdated.js +++ b/test/lib/commands/outdated.js @@ -803,3 +803,59 @@ t.test('min-release-age-exclude', async t => { t.match(joinedOutput(), '2.0.0', 'glob-excluded package shows newer version') }) }) + +t.test('global alias packages report alias spec, not aliased package version', async t => { + // Regression test for https://github.com/npm/cli/issues/9706 + // When a package is installed globally via an alias spec + // (e.g. `npm install -g cowsay@npm:dog@1.0.1`), `npm outdated -g` was + // resolving the wrapper directory name against the registry instead of + // the aliased package, so the `Current` column showed the aliased + // package version while `Wanted`/`Latest` came from a totally + // different packument. + const globalAliasFixture = { + node_modules: { + cat: { + 'package.json': JSON.stringify({ + name: 'dog', + version: '1.0.1', + _from: 'cat@npm:dog@1.0.1', + _requested: { + type: 'alias', + name: 'cat', + escapedName: 'cat', + rawSpec: 'npm:dog@1.0.1', + saveSpec: 'npm:dog@1.0.1', + fetchSpec: 'latest', + subSpec: { + type: 'version', + registry: true, + name: 'dog', + escapedName: 'dog', + rawSpec: '1.0.1', + saveSpec: null, + fetchSpec: '1.0.1', + }, + }, + }, null, 2), + }, + }, + } + + const { outdated, joinedOutput } = await mockNpm(t, { + globalPrefixDir: globalAliasFixture, + config: { global: true }, + }) + await outdated.exec([]) + + const out = joinedOutput() + // The row must describe the alias (`cat@npm:dog`) so users can tell + // which package on disk is being reported, and the Latest column must + // come from the aliased package's packument (`dog`@2.0.0), not from + // the wrapper name (`cat` would resolve to its own 1.0.1 latest). + t.match(out, /cat@npm:dog/, + 'output identifies the row by its alias spec') + t.match(out, /npm:dog@1\.0\.1/, + 'Current column reflects the aliased package version with alias prefix') + t.match(out, /\b2\.0\.0\b/, + 'Latest column comes from the aliased package\'s packument (dog@2.0.0)') +})