feat: add native view/info/show/v command (#11064)

* feat: add native view/info command

* test: add unit tests for native view command

* fix(view): support ranges, aliases, and tags

* chore: update lockfile and tsconfig

* refactor(view): reuse pickPackageFromMeta from npm-resolver

- Share version resolution logic with the npm-resolver instead of
  reimplementing tag/range/version matching in the view command.
- Export pickPackageFromMeta and pickVersionByVersionRange from
  @pnpm/resolving.npm-resolver.
- Remove redundant double HTTP fetch (metadata already contains all
  version data).
- Remove duplicate author/repository fields from PackageInRegistry
  (already inherited from BaseManifest).
- Consolidate four changesets into one.
- Revert unrelated .gitignore change.
- Drop direct semver dependency from deps.inspection.commands.

* refactor(view): reuse fetchMetadataFromFromRegistry from npm-resolver

Use the npm-resolver's fetchMetadataFromFromRegistry instead of
hand-rolled fetch logic. This fixes:
- Broken URL encoding for scoped packages (@scope/pkg)
- Missing auth header, proxy, SSL, and retry config
- Duplicated fetch + error handling code

Also pass proper Config options (rawConfig, userAgent, SSL, proxy,
retry, timeout) through to createFetchFromRegistry and
createGetAuthHeaderByURI so the view command works with private
registries and corporate proxies.

* test(view): improve test coverage for view command

Add tests for:
- non-registry spec rejection (git URLs)
- no matching version error
- version range resolution (^1.0.0)
- dist-tag resolution (latest)
- nested field selection (dist.shasum)
- field selection with --json
- text output format (header, dist section, dist-tags)
- scoped package lookup (@pnpm.e2e/pkg-with-1-dep)
- deps count / deps: none in header
- object field rendering as JSON

* revert: undo rename of @pnpm/resolving.registry.types

The rename from @pnpm/resolving.registry.types to
@pnpm/registry.types (and the move from resolving/registry/types/
to registry/types/) is a separate refactoring concern unrelated to
the view command. Revert all rename-related changes.

Keep the legitimate type additions to PackageInRegistry:
maintainers, contributors, and dist.unpackedSize.

* revert: restore pnpm-workspace.yaml (remove registry/* glob)

* fix(view): handle edge cases in formatBytes and unpackedSize

- Use explicit null check for unpackedSize so 0 B is still rendered
- Add TB/PB units and clamp index to prevent undefined output

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
Alessio Attilio
2026-03-27 19:01:10 +01:00
committed by GitHub
parent e2b350164c
commit d3d6938414
11 changed files with 489 additions and 6 deletions

View File

@@ -0,0 +1,7 @@
---
"@pnpm/resolving.registry.types": patch
"@pnpm/deps.inspection.commands": minor
"pnpm": minor
---
Added native `pnpm view` command with `info`, `show`, and `v` aliases for viewing package information from the registry. Supports version ranges, dist-tags, aliases, field selection, and JSON output.

View File

@@ -36,6 +36,7 @@
"@pnpm/cli.utils": "workspace:*",
"@pnpm/colorize-semver-diff": "catalog:",
"@pnpm/config.matcher": "workspace:*",
"@pnpm/config.pick-registry-for-package": "workspace:*",
"@pnpm/config.reader": "workspace:*",
"@pnpm/deps.inspection.list": "workspace:*",
"@pnpm/deps.inspection.outdated": "workspace:*",
@@ -45,7 +46,11 @@
"@pnpm/global.packages": "workspace:*",
"@pnpm/installing.modules-yaml": "workspace:*",
"@pnpm/lockfile.fs": "workspace:*",
"@pnpm/network.auth-header": "workspace:*",
"@pnpm/network.fetch": "workspace:*",
"@pnpm/npm-package-arg": "catalog:",
"@pnpm/resolving.default-resolver": "workspace:*",
"@pnpm/resolving.npm-resolver": "workspace:*",
"@pnpm/semver-diff": "catalog:",
"@pnpm/store.path": "workspace:*",
"@pnpm/types": "workspace:*",

View File

@@ -1,3 +1,4 @@
export { list, ll, why } from './listing/index.js'
export { outdated } from './outdated/index.js'
export * as peers from './peers.js'
export * as view from './view/index.js'

View File

@@ -0,0 +1,269 @@
import { pickRegistryForPackage } from '@pnpm/config.pick-registry-for-package'
import { type Config, types as allTypes } from '@pnpm/config.reader'
import { PnpmError } from '@pnpm/error'
import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header'
import { createFetchFromRegistry } from '@pnpm/network.fetch'
import npa from '@pnpm/npm-package-arg'
import {
fetchMetadataFromFromRegistry,
pickPackageFromMeta,
pickVersionByVersionRange,
type RegistryPackageSpec,
} from '@pnpm/resolving.npm-resolver'
import { pick } from 'ramda'
import { renderHelp } from 'render-help'
export function rcOptionsTypes (): Record<string, unknown> {
return pick([], allTypes)
}
export function cliOptionsTypes (): Record<string, unknown> {
return {
...rcOptionsTypes(),
json: Boolean,
}
}
export const commandNames = ['view', 'info', 'show', 'v']
export function help (): string {
return renderHelp({
description: 'View package information from the registry without using npm CLI.',
usages: [
'pnpm view <package-name>',
'pnpm view <package-name>@<version>',
'pnpm view <package-name> [<field>[.subfield]...]',
],
descriptionLists: [
{
title: 'Options',
list: [
{
description: 'Show information in JSON format',
name: '--json',
},
],
},
],
})
}
export async function handler (
opts: Config & {
json?: boolean
},
params: string[]
): Promise<string | void> {
const packageSpec = params[0]
if (!packageSpec) {
throw new PnpmError('MISSING_PACKAGE_NAME', 'Package name is required. Usage: pnpm view <package-name>')
}
const fields = params.slice(1)
let parsed: ReturnType<typeof npa>
try {
parsed = npa(packageSpec)
} catch {
throw new PnpmError('INVALID_PACKAGE_NAME', `Invalid package name: "${packageSpec}"`)
}
if (!parsed.registry) {
throw new PnpmError('INVALID_PACKAGE_NAME', `Invalid package name: "${packageSpec}". pnpm view only supports registry packages.`)
}
const subSpec = parsed.type === 'alias' ? parsed.subSpec : parsed
const packageName = subSpec?.name
if (!packageName) {
throw new PnpmError('INVALID_PACKAGE_NAME', `Invalid package name: "${packageSpec}"`)
}
const specType = (subSpec?.type ?? 'tag') as 'tag' | 'version' | 'range'
const spec: RegistryPackageSpec = {
name: packageName,
fetchSpec: subSpec?.fetchSpec ?? 'latest',
type: specType,
}
const registry = pickRegistryForPackage(opts.registries, packageName)
const fetchFromRegistry = createFetchFromRegistry(opts)
const getAuthHeader = createGetAuthHeaderByURI({ allSettings: opts.rawConfig ?? {}, userSettings: opts.userConfig ?? {} })
const { meta: metadata } = await fetchMetadataFromFromRegistry(
{
fetch: fetchFromRegistry,
retry: {
factor: opts.fetchRetryFactor,
maxTimeout: opts.fetchRetryMaxtimeout,
minTimeout: opts.fetchRetryMintimeout,
retries: opts.fetchRetries,
},
timeout: opts.fetchTimeout ?? 60000,
fetchWarnTimeoutMs: 10000,
},
packageName,
{
registry,
authHeaderValue: getAuthHeader(registry),
fullMetadata: true,
}
)
const data = pickPackageFromMeta(
pickVersionByVersionRange,
{ preferredVersionSelectors: undefined },
spec,
metadata
)
if (!data) {
throw new PnpmError('PACKAGE_NOT_FOUND', `No matching version found for ${packageName}@${spec.fetchSpec}`)
}
const versionsCount = metadata.versions ? Object.keys(metadata.versions).length : 0
const depsCount = data.dependencies ? Object.keys(data.dependencies).length : 0
const distTags = metadata['dist-tags']
const info = {
...data,
author: typeof data.author === 'object' ? (data.author as { name: string }).name : data.author,
repository: typeof data.repository === 'object' ? data.repository.url : data.repository,
versionsCount: versionsCount > 0 ? versionsCount : undefined,
depsCount: depsCount > 0 ? depsCount : undefined,
distTags,
}
// If fields are specified, filter and return only those
if (fields.length > 0) {
const selectedFields: Record<string, unknown> = {}
for (const field of fields) {
selectedFields[field] = getNestedProperty(info as unknown as Record<string, unknown>, field)
}
if (opts.json) {
return JSON.stringify(selectedFields, null, 2)
}
if (fields.length === 1) {
const value = selectedFields[fields[0]]
return formatFieldValue(value)
}
const lines = fields.map(field => {
const value = selectedFields[field]
if (typeof value === 'object' && value !== null) {
return `${field} = ${JSON.stringify(value)}`
}
if (typeof value === 'string') {
return `${field} = '${value}'`
}
return `${field} = ${formatFieldValue(value)}`
})
return lines.join('\n')
}
if (opts.json) {
return JSON.stringify(info, null, 2)
}
const headerParts: string[] = []
if (info.name && info.version) {
headerParts.push(`${info.name}@${info.version}`)
}
if (info.license) {
headerParts.push(info.license)
}
if (info.depsCount !== undefined) {
headerParts.push(`deps: ${info.depsCount}`)
} else {
headerParts.push('deps: none')
}
if (info.versionsCount !== undefined) {
headerParts.push(`versions: ${info.versionsCount}`)
}
const lines = [headerParts.join(' | ')]
if (info.description) {
lines.push(info.description)
}
if (info.homepage) {
lines.push(info.homepage)
}
if (info.keywords && info.keywords.length > 0) {
lines.push('')
lines.push(`keywords: ${info.keywords.join(', ')}`)
}
if (info.dependencies && Object.keys(info.dependencies).length > 0) {
lines.push('')
lines.push('dependencies:')
const depEntries = Object.entries(info.dependencies).map(([name, version]) => `${name}: ${version}`)
lines.push(depEntries.join(', '))
}
if (info.dist) {
lines.push('')
lines.push('dist')
if (info.dist.tarball) {
lines.push(`.tarball: ${info.dist.tarball}`)
}
if (info.dist.shasum) {
lines.push(`.shasum: ${info.dist.shasum}`)
}
if (info.dist.integrity) {
lines.push(`.integrity: ${info.dist.integrity}`)
}
if (info.dist.unpackedSize != null) {
lines.push(`.unpackedSize: ${formatBytes(info.dist.unpackedSize)}`)
}
}
if (info.maintainers && info.maintainers.length > 0) {
lines.push('')
lines.push('maintainers:')
for (const maintainer of info.maintainers) {
lines.push(`- ${maintainer.name}`)
}
}
if (info.distTags && Object.keys(info.distTags).length > 0) {
lines.push('')
lines.push('dist-tags:')
for (const [tag, tagVersion] of Object.entries(info.distTags)) {
lines.push(`${tag}: ${tagVersion}`)
}
}
return lines.join('\n')
}
function formatBytes (bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'kB', 'MB', 'GB', 'TB', 'PB']
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1)
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
}
function getNestedProperty (obj: Record<string, unknown>, path: string): unknown {
return path.split('.').reduce((acc: unknown, part) => {
if (typeof acc === 'object' && acc !== null) {
return (acc as Record<string, unknown>)[part]
}
return undefined
}, obj)
}
function formatFieldValue (value: unknown): string {
if (value === null || value === undefined) {
return ''
}
if (typeof value === 'object') {
return JSON.stringify(value, null, 2)
}
return String(value)
}

162
deps/inspection/commands/test/view.ts vendored Normal file
View File

@@ -0,0 +1,162 @@
import type { Config } from '@pnpm/config.reader'
import { view } from '@pnpm/deps.inspection.commands'
import { REGISTRY_MOCK_PORT } from '@pnpm/registry-mock'
const REGISTRY_URL = `http://localhost:${REGISTRY_MOCK_PORT}`
const VIEW_OPTIONS = {
registries: { default: REGISTRY_URL },
}
test('view: command should be available', () => {
expect(view.handler).toBeDefined()
expect(view.help).toBeDefined()
expect(view.commandNames).toBeDefined()
expect(view.cliOptionsTypes).toBeDefined()
})
test('view: command should have correct names', () => {
expect(view.commandNames).toEqual(['view', 'info', 'show', 'v'])
})
test('view: help should return a string', () => {
const help = view.help()
expect(typeof help).toBe('string')
expect(help.length).toBeGreaterThan(0)
})
test('view: cliOptionsTypes should return object', () => {
const types = view.cliOptionsTypes()
expect(typeof types).toBe('object')
})
test('view: rcOptionsTypes should return object', () => {
const types = view.rcOptionsTypes()
expect(typeof types).toBe('object')
})
test('view: missing package name throws error', async () => {
await expect(
view.handler(VIEW_OPTIONS as unknown as Config, [])
).rejects.toMatchObject({ code: 'ERR_PNPM_MISSING_PACKAGE_NAME' })
})
test('view: non-registry spec throws error', async () => {
await expect(
view.handler(VIEW_OPTIONS as unknown as Config, ['github:user/repo'])
).rejects.toMatchObject({ code: 'ERR_PNPM_INVALID_PACKAGE_NAME' })
})
test('view: successful lookup of package', async () => {
const result = await view.handler(VIEW_OPTIONS as unknown as Config, ['is-negative'])
expect(typeof result).toBe('string')
expect(result).toContain('is-negative')
})
test('view: package not found throws an error', async () => {
await expect(
view.handler(VIEW_OPTIONS as unknown as Config, ['not-a-real-package-123456789'])
).rejects.toMatchObject({ code: 'ERR_PNPM_FETCH_404' })
})
test('view: no matching version throws an error', async () => {
await expect(
view.handler(VIEW_OPTIONS as unknown as Config, ['is-negative@99999.0.0'])
).rejects.toMatchObject({ code: 'ERR_PNPM_PACKAGE_NOT_FOUND' })
})
test('view: with --json option', async () => {
const result = await view.handler({ ...VIEW_OPTIONS, json: true } as unknown as Config, ['is-negative'])
expect(typeof result).toBe('string')
const parsed = JSON.parse(result as string)
expect(parsed.name).toBe('is-negative')
})
test('view: accessing a specific field', async () => {
const result = await view.handler(VIEW_OPTIONS as unknown as Config, ['is-negative', 'name'])
expect(result).toBe('is-negative')
})
test('view: accessing a specific version', async () => {
const result = await view.handler(VIEW_OPTIONS as unknown as Config, ['is-negative@1.0.0', 'version'])
expect(result).toBe('1.0.0')
})
test('view: accessing multiple fields adds quotes for strings', async () => {
const result = await view.handler(VIEW_OPTIONS as unknown as Config, ['is-negative@1.0.0', 'name', 'version'])
expect(typeof result).toBe('string')
expect(result).toContain("name = 'is-negative'")
expect(result).toContain("version = '1.0.0'")
})
test('view: version range resolves to matching version', async () => {
const result = await view.handler(VIEW_OPTIONS as unknown as Config, ['is-negative@^1.0.0', 'version'])
expect(typeof result).toBe('string')
expect(result).toMatch(/^1\./)
})
test('view: dist-tag resolves correctly', async () => {
const result = await view.handler(VIEW_OPTIONS as unknown as Config, ['is-negative@latest', 'version'])
expect(typeof result).toBe('string')
expect(result).toMatch(/^\d+\.\d+\.\d+/)
})
test('view: nested field selection', async () => {
const result = await view.handler(VIEW_OPTIONS as unknown as Config, ['is-negative@1.0.0', 'dist.shasum'])
expect(typeof result).toBe('string')
expect(result!.length).toBeGreaterThan(0)
})
test('view: field selection with --json', async () => {
const result = await view.handler(
{ ...VIEW_OPTIONS, json: true } as unknown as Config,
['is-negative@1.0.0', 'name', 'version']
)
const parsed = JSON.parse(result as string)
expect(parsed.name).toBe('is-negative')
expect(parsed.version).toBe('1.0.0')
})
test('view: text output includes header with name@version', async () => {
const result = await view.handler(VIEW_OPTIONS as unknown as Config, ['is-negative@1.0.0']) as string
const firstLine = result.split('\n')[0]
expect(firstLine).toContain('is-negative@1.0.0')
})
test('view: text output includes dist section', async () => {
const result = await view.handler(VIEW_OPTIONS as unknown as Config, ['is-negative@1.0.0']) as string
expect(result).toContain('.tarball:')
expect(result).toContain('.shasum:')
})
test('view: text output includes dist-tags', async () => {
const result = await view.handler(VIEW_OPTIONS as unknown as Config, ['is-negative']) as string
expect(result).toContain('dist-tags:')
expect(result).toContain('latest:')
})
test('view: text output for package with dependencies shows deps count', async () => {
const result = await view.handler(VIEW_OPTIONS as unknown as Config, ['@pnpm.e2e/pkg-with-1-dep@100.0.0']) as string
const firstLine = result.split('\n')[0]
expect(firstLine).toContain('deps: ')
expect(firstLine).not.toContain('deps: none')
})
test('view: text output for package without dependencies shows deps: none', async () => {
const result = await view.handler(VIEW_OPTIONS as unknown as Config, ['is-negative@1.0.0']) as string
const firstLine = result.split('\n')[0]
expect(firstLine).toContain('deps: none')
})
test('view: scoped package lookup', async () => {
const result = await view.handler(VIEW_OPTIONS as unknown as Config, ['@pnpm.e2e/pkg-with-1-dep@100.0.0', 'name'])
expect(result).toBe('@pnpm.e2e/pkg-with-1-dep')
})
test('view: object field renders as JSON', async () => {
const result = await view.handler(VIEW_OPTIONS as unknown as Config, ['is-negative@1.0.0', 'dist'])
expect(typeof result).toBe('string')
const parsed = JSON.parse(result as string)
expect(parsed.tarball).toBeDefined()
expect(parsed.shasum).toBeDefined()
})

View File

@@ -27,6 +27,9 @@
{
"path": "../../../config/matcher"
},
{
"path": "../../../config/pick-registry-for-package"
},
{
"path": "../../../config/reader"
},
@@ -54,9 +57,18 @@
{
"path": "../../../lockfile/fs"
},
{
"path": "../../../network/auth-header"
},
{
"path": "../../../network/fetch"
},
{
"path": "../../../resolving/default-resolver"
},
{
"path": "../../../resolving/npm-resolver"
},
{
"path": "../../../store/path"
},

15
pnpm-lock.yaml generated
View File

@@ -3122,6 +3122,9 @@ importers:
'@pnpm/config.matcher':
specifier: workspace:*
version: link:../../../config/matcher
'@pnpm/config.pick-registry-for-package':
specifier: workspace:*
version: link:../../../config/pick-registry-for-package
'@pnpm/config.reader':
specifier: workspace:*
version: link:../../../config/reader
@@ -3152,9 +3155,21 @@ importers:
'@pnpm/logger':
specifier: 'catalog:'
version: 1001.0.1
'@pnpm/network.auth-header':
specifier: workspace:*
version: link:../../../network/auth-header
'@pnpm/network.fetch':
specifier: workspace:*
version: link:../../../network/fetch
'@pnpm/npm-package-arg':
specifier: 'catalog:'
version: 2.0.0
'@pnpm/resolving.default-resolver':
specifier: workspace:*
version: link:../../../resolving/default-resolver
'@pnpm/resolving.npm-resolver':
specifier: workspace:*
version: link:../../../resolving/npm-resolver
'@pnpm/semver-diff':
specifier: 'catalog:'
version: 1.1.0

View File

@@ -6,7 +6,7 @@ import { createCompletionServer, doctor, generateCompletion } from '@pnpm/cli.co
import { config, getCommand, setCommand } from '@pnpm/config.commands'
import { types as allTypes } from '@pnpm/config.reader'
import { audit, licenses, sbom } from '@pnpm/deps.compliance.commands'
import { list, ll, outdated, peers, why } from '@pnpm/deps.inspection.commands'
import { list, ll, outdated, peers, view, why } from '@pnpm/deps.inspection.commands'
import { selfUpdate, setup } from '@pnpm/engine.pm.commands'
import { env, runtime } from '@pnpm/engine.runtime.commands'
import {
@@ -167,6 +167,7 @@ const commands: CommandDefinition[] = [
unlink,
update,
version,
view,
why,
createHelp(helpByCommandName),
...notImplementedCommandDefinitions,

View File

@@ -11,7 +11,6 @@ const NOT_IMPLEMENTED_COMMANDS = [
'edit',
'find',
'home',
'info',
'issues',
'logout',
'owner',
@@ -24,15 +23,12 @@ const NOT_IMPLEMENTED_COMMANDS = [
'se',
'search',
'set-script',
'show',
'star',
'stars',
'team',
'token',
'unpublish',
'unstar',
'v',
'view',
'whoami',
'xmas',
]

View File

@@ -52,7 +52,7 @@ import {
pickPackage,
type PickPackageOptions,
} from './pickPackage.js'
import { pickVersionByVersionRange } from './pickPackageFromMeta.js'
import { pickPackageFromMeta, pickVersionByVersionRange } from './pickPackageFromMeta.js'
import { failIfTrustDowngraded } from './trustChecks.js'
import { whichVersionIsPinned } from './whichVersionIsPinned.js'
import { workspacePrefToNpm } from './workspacePrefToNpm.js'
@@ -110,9 +110,13 @@ function formatTimeAgo (date: Date): string {
}
export {
fetchMetadataFromFromRegistry,
type FetchMetadataFromFromRegistryOptions,
type PackageMeta,
type PackageMetaCache,
parseBareSpecifier,
pickPackageFromMeta,
pickVersionByVersionRange,
type RegistryPackageSpec,
RegistryResponseError,
workspacePrefToNpm,

View File

@@ -33,10 +33,21 @@ export interface PackageInRegistry extends PackageManifest {
oidcConfigId: string
}
}
maintainers?: Array<{
name: string
email?: string
url?: string
}>
contributors?: Array<{
name: string
email?: string
url?: string
}>
dist: {
integrity?: string
shasum: string
tarball: string
unpackedSize?: number
attestations?: {
provenance?: {
predicateType: string