fix: display npm: protocol for aliased packages in list and why (#10084)

* fix: support alias resolution in pnpm why with npm:
protocol

* refactor: make alias required instead of optional

* refactor: reorder field to put alias first
This commit is contained in:
Ryo Matsukawa
2025-11-20 09:08:53 +09:00
committed by Zoltan Kochan
parent 2194432539
commit c206765715
10 changed files with 272 additions and 4 deletions

View File

@@ -0,0 +1,9 @@
---
"@pnpm/plugin-commands-listing": patch
"@pnpm/reviewing.dependencies-hierarchy": patch
"@pnpm/types": patch
"@pnpm/list": patch
"pnpm": patch
---
`pnpm list` and `pnpm why` now display npm: protocol for aliased packages (e.g., `foo npm:is-odd@3.0.1`) [#8660](https://github.com/pnpm/pnpm/issues/8660).

View File

@@ -16,6 +16,7 @@ export type IncludedDependencies = {
export type ReadPackageHook = <Pkg extends BaseManifest> (pkg: Pkg, dir?: string) => Pkg | Promise<Pkg>
export interface FinderContext {
alias: string
name: string
version: string
readManifest: () => DependencyManifest

View File

@@ -171,6 +171,7 @@ async function dependenciesHierarchyForPackage (
})
let newEntry: PackageNode | null = null
const matchedSearched = opts.search?.({
alias,
name: packageInfo.name,
version: packageInfo.version,
readManifest,
@@ -231,6 +232,7 @@ async function dependenciesHierarchyForPackage (
version,
}
const matchedSearched = opts.search?.({
alias: pkg.alias,
name: pkg.name,
version: pkg.version,
readManifest: () => readPackageJsonFromDirSync(pkgPath),

View File

@@ -35,9 +35,10 @@ function search (
matchName: MatchFunction
matchVersion?: MatchFunction
},
{ name, version }: FinderContext
{ alias, name, version }: FinderContext
): boolean {
if (!packageSelector.matchName(name)) {
const nameMatches = packageSelector.matchName(name) || packageSelector.matchName(alias)
if (!nameMatches) {
return false
}
if (packageSelector.matchVersion == null) {

View File

@@ -142,6 +142,7 @@ function getTreeHelper (
})
let circular: boolean
const matchedSearched = opts.search?.({
alias,
name: packageInfo.name,
version: packageInfo.version,
readManifest,

View File

@@ -37,6 +37,7 @@ test('package searcher with 2 finders', () => {
function mockContext (manifest: DependencyManifest) {
return {
alias: manifest.name,
name: manifest.name,
version: manifest.version,
readManifest: () => manifest,

View File

@@ -56,7 +56,21 @@ function renderParseableForPackage (
}
return [
firstLine,
...pkgs.map((pkg) => `${pkg.path}:${pkg.name}@${pkg.version}`),
...pkgs.map((pkgNode) => {
const node = pkgNode as PackageNode
if (node.alias !== node.name) {
// Only add npm: prefix if version doesn't already contain @ (to avoid file:, link:, etc.)
if (!node.version.includes('@')) {
return `${node.path}:${node.alias} npm:${node.name}@${node.version}`
}
return `${node.path}:${node.alias} ${node.version}`
}
// If version already contains @, it's in full format (e.g., name@file:path)
if (node.version.includes('@')) {
return `${node.path}:${node.version}`
}
return `${node.path}:${node.name}@${node.version}`
}),
].join('\n')
}
return [
@@ -66,6 +80,7 @@ function renderParseableForPackage (
}
interface PackageInfo {
alias: string
name: string
version: string
path: string

View File

@@ -145,7 +145,18 @@ export async function toArchyTree (
function printLabel (getPkgColor: GetPkgColor, node: PackageNode): string {
const color = getPkgColor(node)
let txt = `${color(node.name)} ${chalk.gray(node.version)}`
let txt: string
if (node.alias !== node.name) {
// When using npm: protocol alias, display as "alias npm:name@version"
// Only add npm: prefix if version doesn't already contain @ (to avoid file:, link:, etc.)
if (!node.version.includes('@')) {
txt = `${color(node.alias)} ${chalk.gray(`npm:${node.name}@${node.version}`)}`
} else {
txt = `${color(node.alias)} ${chalk.gray(node.version)}`
}
} else {
txt = `${color(node.name)} ${chalk.gray(node.version)}`
}
if (node.isPeer) {
txt += ' peer'
}

View File

@@ -5,6 +5,7 @@ import { fixtures } from '@pnpm/test-fixtures'
import chalk from 'chalk'
import cliColumns from 'cli-columns'
import { renderTree } from '../lib/renderTree.js'
import { renderParseable } from '../lib/renderParseable.js'
const DEV_DEP_ONLY_CLR = chalk.yellow
const PROD_DEP_CLR = (s: string) => s // just use the default color
@@ -838,3 +839,138 @@ ${DEPENDENCIES}
└─┬ @scope/b ${VERSION_CLR('link:packages/b')}
└── @scope/c ${VERSION_CLR('link:packages/c')}`)
})
test('renderTree displays npm: protocol for aliased packages', async () => {
const testPath = '/test/path'
const output = await renderTree(
[
{
name: 'test-project',
path: testPath,
version: '1.0.0',
dependencies: [
{
alias: 'foo',
name: '@pnpm.e2e/pkg-with-1-dep',
version: '100.0.0',
path: '/test/path/node_modules/.pnpm/@pnpm.e2e+pkg-with-1-dep@100.0.0/node_modules/@pnpm.e2e/pkg-with-1-dep',
isMissing: false,
isPeer: false,
isSkipped: false,
},
],
},
],
{
alwaysPrintRootPackage: false,
depth: 0,
long: false,
search: false,
showExtraneous: false,
}
)
// renderTree uses chalk for coloring, so we check parts separately
expect(output).toContain('foo')
expect(output).toContain('npm:@pnpm.e2e/pkg-with-1-dep@100.0.0')
})
test('renderTree displays file: protocol correctly for aliased packages', async () => {
const testPath = '/test/path'
const output = await renderTree(
[
{
name: 'test-project',
path: testPath,
version: '1.0.0',
dependencies: [
{
alias: 'my-alias',
name: 'my-local-pkg',
version: 'my-local-pkg@file:local-pkg',
path: '/test/path/local-pkg',
isMissing: false,
isPeer: false,
isSkipped: false,
},
],
},
],
{
alwaysPrintRootPackage: false,
depth: 0,
long: false,
search: false,
showExtraneous: false,
}
)
// renderTree uses chalk for coloring, so we check parts separately
// instead of matching the complete string with color codes
expect(output).toContain('my-alias')
expect(output).toContain('my-local-pkg@file:local-pkg')
})
test('renderParseable displays npm: protocol for aliased packages', async () => {
const testPath = '/test/path'
const output = await renderParseable(
[
{
name: 'test-project',
path: testPath,
version: '1.0.0',
dependencies: [
{
alias: 'foo',
name: '@pnpm.e2e/pkg-with-1-dep',
version: '100.0.0',
path: '/test/path/node_modules/.pnpm/@pnpm.e2e+pkg-with-1-dep@100.0.0/node_modules/@pnpm.e2e/pkg-with-1-dep',
isMissing: false,
isPeer: false,
isSkipped: false,
},
],
},
],
{
alwaysPrintRootPackage: false,
depth: 0,
long: true,
search: false,
}
)
expect(output).toContain('foo npm:@pnpm.e2e/pkg-with-1-dep@100.0.0')
})
test('renderParseable displays file: protocol correctly for aliased packages', async () => {
const testPath = '/test/path'
const output = await renderParseable(
[
{
name: 'test-project',
path: testPath,
version: '1.0.0',
dependencies: [
{
alias: 'my-alias',
name: 'my-local-pkg',
version: 'my-local-pkg@file:local-pkg',
path: '/test/path/local-pkg',
isMissing: false,
isPeer: false,
isSkipped: false,
},
],
},
],
{
alwaysPrintRootPackage: false,
depth: 0,
long: true,
search: false,
}
)
expect(output).toContain('my-alias my-local-pkg@file:local-pkg')
})

View File

@@ -1,4 +1,5 @@
import path from 'path'
import fs from 'fs'
import { type PnpmError } from '@pnpm/error'
import { why } from '@pnpm/plugin-commands-listing'
import { prepare } from '@pnpm/prepare'
@@ -51,3 +52,93 @@ dependencies:
@pnpm.e2e/pkg-with-1-dep 100.0.0
└── @pnpm.e2e/dep-of-pkg-with-1-dep 100.0.0`)
})
test('"why" should find packages by alias name when using npm: protocol', async () => {
prepare({
dependencies: {
foo: 'npm:@pnpm.e2e/pkg-with-1-dep@100.0.0',
},
})
await execa('node', [pnpmBin, 'install', '--registry', `http://localhost:${REGISTRY_MOCK_PORT}`])
const output = await why.handler({
dev: false,
dir: process.cwd(),
optional: false,
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
}, ['foo'])
const lines = stripAnsi(output).split('\n')
expect(lines).toContain('foo npm:@pnpm.e2e/pkg-with-1-dep@100.0.0')
})
test('"why" should find packages by actual package name when using npm: protocol', async () => {
prepare({
dependencies: {
foo: 'npm:@pnpm.e2e/pkg-with-1-dep@100.0.0',
},
})
await execa('node', [pnpmBin, 'install', '--registry', `http://localhost:${REGISTRY_MOCK_PORT}`])
const output = await why.handler({
dev: false,
dir: process.cwd(),
optional: false,
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
}, ['@pnpm.e2e/pkg-with-1-dep'])
const lines = stripAnsi(output).split('\n')
expect(lines).toContain('foo npm:@pnpm.e2e/pkg-with-1-dep@100.0.0')
})
test('"why" should display npm: protocol in parseable format', async () => {
prepare({
dependencies: {
foo: 'npm:@pnpm.e2e/pkg-with-1-dep@100.0.0',
},
})
await execa('node', [pnpmBin, 'install', '--registry', `http://localhost:${REGISTRY_MOCK_PORT}`])
const output = await why.handler({
dev: false,
dir: process.cwd(),
optional: false,
long: true,
parseable: true,
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
}, ['foo'])
const lines = output.split('\n')
expect(lines.some(line => line.includes('foo npm:@pnpm.e2e/pkg-with-1-dep@100.0.0'))).toBe(true)
})
test('"why" should display file: protocol correctly for aliased packages', async () => {
prepare({
dependencies: {
'my-alias': 'file:./local-pkg',
},
})
// Create a local package after prepare() changes directory
const localPkgDir = path.join(process.cwd(), 'local-pkg')
fs.mkdirSync(localPkgDir, { recursive: true })
fs.writeFileSync(
path.join(localPkgDir, 'package.json'),
JSON.stringify({ name: 'my-local-pkg', version: '1.0.0' })
)
await execa('node', [pnpmBin, 'install'])
const output = await why.handler({
dev: false,
dir: process.cwd(),
optional: false,
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
}, ['my-local-pkg'])
const lines = stripAnsi(output).split('\n')
expect(lines).toContain('my-alias my-local-pkg@file:local-pkg')
})