feat(view): support searching package.json upward when package name is omitted (#11696)

* feat(view): support searching package.json upward when package name is omitted

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: apply review

* fix: apply review

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: update

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
btea
2026-06-17 07:04:25 +08:00
committed by GitHub
parent cc4cadad8d
commit 293921a788
11 changed files with 244 additions and 45 deletions

View File

@@ -0,0 +1,15 @@
---
"@pnpm/deps.inspection.commands": minor
"@pnpm/workspace.projects-filter": patch
"@pnpm/workspace.root-finder": patch
"pnpm": minor
---
feat(view): support searching project manifest upward when package name is omitted
When running `pnpm view` without a package name, the command now searches
upward for the nearest project manifest (`package.json`, `package.yaml`, or `package.json5`) and uses its `name` field.
If the manifest exists but lacks a `name` field, an error is thrown.
This change also replaces the `find-up` dependency with `empathic` for
improved performance and consistency across workspace tools.

View File

@@ -56,6 +56,7 @@
"@pnpm/semver-diff": "catalog:",
"@pnpm/store.path": "workspace:*",
"@pnpm/types": "workspace:*",
"@pnpm/workspace.project-manifest-reader": "workspace:*",
"@zkochan/table": "catalog:",
"chalk": "catalog:",
"hosted-git-info": "catalog:",

View File

@@ -1,6 +1,10 @@
import path from 'node:path'
import { type Config, type ConfigContext, types as allTypes } from '@pnpm/config.reader'
import { PnpmError } from '@pnpm/error'
import { formatTimeAgo } from '@pnpm/resolving.npm-resolver'
import type { ProjectManifest } from '@pnpm/types'
import { tryReadProjectManifest } from '@pnpm/workspace.project-manifest-reader'
import chalk from 'chalk'
import { pick } from 'ramda'
import { renderHelp } from 'render-help'
@@ -22,11 +26,11 @@ export const commandNames = ['view', 'info', 'show', 'v']
export function help (): string {
return renderHelp({
description: 'View package information from the registry without using npm CLI.',
description: 'View package information from the registry. If package name is omitted, searches upward for the nearest package manifest.',
usages: [
'pnpm view <package-name>',
'pnpm view <package-name>@<version>',
'pnpm view <package-name> [<field>[.subfield]...]',
'pnpm view [<package-name>]',
'pnpm view [<package-name>@<version>]',
'pnpm view [<package-name>] [<field>[.subfield]...]',
],
descriptionLists: [
{
@@ -48,10 +52,20 @@ export async function handler (
},
params: string[]
): Promise<string | void> {
const packageSpec = params[0]
let packageSpec = params[0]
if (!packageSpec) {
throw new PnpmError('MISSING_PACKAGE_NAME', 'Package name is required. Usage: pnpm view <package-name>')
const nearestManifest = await findNearestProjectManifest(opts.dir ?? process.cwd(), opts)
if (!nearestManifest) {
throw new PnpmError('MISSING_PACKAGE_NAME', 'Package name is required. Usage: pnpm view [<package-name>]')
}
if (typeof nearestManifest.manifest.name !== 'string' || nearestManifest.manifest.name.length === 0) {
throw new PnpmError(
'INVALID_PACKAGE_JSON',
`Invalid ${nearestManifest.fileName} at "${nearestManifest.projectDir}". The "name" field is required and must be a non-empty string.`
)
}
packageSpec = nearestManifest.manifest.name
}
const fields = params.slice(1)
@@ -248,6 +262,30 @@ function getPublishedInfo (info: ExtendedPackageInfo): string | null {
return `published ${chalk.cyan(timeAgo)}`
}
async function findNearestProjectManifest (
startDir: string,
_opts: Config & ConfigContext
): Promise<{ manifest: ProjectManifest, fileName: string, projectDir: string } | null> {
try {
const result = await tryReadProjectManifest(startDir)
if (result.manifest != null) {
return {
manifest: result.manifest,
fileName: result.fileName,
projectDir: startDir,
}
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
throw new PnpmError('INVALID_PACKAGE_JSON', `Failed to read or parse project manifest in "${startDir}": ${message}`)
}
const parentDir = path.dirname(startDir)
if (parentDir === startDir) {
return null
}
return findNearestProjectManifest(parentDir, _opts)
}
/**
* Retrieves the publisher name from package metadata.
* Checks fields in order: _npmUser, maintainers, author.

View File

@@ -1,3 +1,8 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { stripVTControlCharacters as stripAnsi } from 'node:util'
import { expect, test } from '@jest/globals'
import type { Config, ConfigContext } from '@pnpm/config.reader'
import { view } from '@pnpm/deps.inspection.commands'
@@ -37,9 +42,18 @@ test('view: rcOptionsTypes should return object', () => {
})
test('view: missing package name throws error', async () => {
await expect(
view.handler(VIEW_OPTIONS as unknown as Config & ConfigContext, [])
).rejects.toMatchObject({ code: 'ERR_PNPM_MISSING_PACKAGE_NAME' })
const cwd = process.cwd()
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'view-test-'))
try {
process.chdir(tmpDir)
await expect(
view.handler(VIEW_OPTIONS as unknown as Config & ConfigContext, [])
).rejects.toMatchObject({ code: 'ERR_PNPM_MISSING_PACKAGE_NAME' })
} finally {
process.chdir(cwd)
fs.rmSync(tmpDir, { recursive: true, force: true })
}
})
test('view: non-registry spec throws error', async () => {
@@ -142,8 +156,9 @@ test('view: text output includes dist section', async () => {
test('view: text output includes dist-tags', async () => {
const result = await view.handler(VIEW_OPTIONS as unknown as Config & ConfigContext, ['is-negative']) as string
expect(result).toContain('dist-tags:')
expect(result).toContain('latest:')
const plainTextResult = stripAnsi(result)
expect(plainTextResult).toContain('dist-tags:')
expect(plainTextResult).toContain('latest:')
})
test('view: text output for package with dependencies shows deps count', async () => {
@@ -233,5 +248,140 @@ test('view: published info includes timestamp', async () => {
test('view: published info includes publisher when maintainer data is available', async () => {
// Note: is-negative package has maintainer data in the mock registry
const result = await view.handler(VIEW_OPTIONS as unknown as Config & ConfigContext, ['is-negative@1.0.0']) as string
expect(result).toMatch(/published .* ago by /)
expect(stripAnsi(result)).toMatch(/published .* ago by /)
})
test('view: uses package manifest name when no package name provided', async () => {
const cwd = process.cwd()
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'view-test-'))
const pkgJsonPath = path.join(tmpDir, 'package.json')
try {
fs.writeFileSync(pkgJsonPath, JSON.stringify({ name: 'is-negative' }))
process.chdir(tmpDir)
const result = await view.handler(VIEW_OPTIONS as unknown as Config & ConfigContext, [])
expect(typeof result).toBe('string')
expect(result).toContain('is-negative')
} finally {
process.chdir(cwd)
fs.rmSync(tmpDir, { recursive: true, force: true })
}
})
test('view: searches upward for package manifest in nested directory', async () => {
const cwd = process.cwd()
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'view-test-'))
const nestedDir = path.join(tmpDir, 'a', 'b')
const pkgJsonPath = path.join(tmpDir, 'package.json')
try {
fs.mkdirSync(nestedDir, { recursive: true })
fs.writeFileSync(pkgJsonPath, JSON.stringify({ name: 'is-negative' }))
process.chdir(nestedDir)
const result = await view.handler(VIEW_OPTIONS as unknown as Config & ConfigContext, [])
expect(typeof result).toBe('string')
expect(result).toContain('is-negative')
} finally {
process.chdir(cwd)
fs.rmSync(tmpDir, { recursive: true, force: true })
}
})
test('view: package.json without name field throws error', async () => {
const cwd = process.cwd()
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'view-test-'))
const pkgJsonPath = path.join(tmpDir, 'package.json')
try {
fs.writeFileSync(pkgJsonPath, JSON.stringify({ version: '1.0.0' }))
process.chdir(tmpDir)
await expect(
view.handler(VIEW_OPTIONS as unknown as Config & ConfigContext, [])
).rejects.toMatchObject({ code: 'ERR_PNPM_INVALID_PACKAGE_JSON' })
} finally {
process.chdir(cwd)
fs.rmSync(tmpDir, { recursive: true, force: true })
}
})
test('view: uses package.yaml name when no package name provided', async () => {
const cwd = process.cwd()
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'view-test-'))
const pkgYamlPath = path.join(tmpDir, 'package.yaml')
try {
fs.writeFileSync(pkgYamlPath, 'name: is-negative\n')
process.chdir(tmpDir)
const result = await view.handler(VIEW_OPTIONS as unknown as Config & ConfigContext, [])
expect(typeof result).toBe('string')
expect(result).toContain('is-negative')
} finally {
process.chdir(cwd)
fs.rmSync(tmpDir, { recursive: true, force: true })
}
})
test('view: package.json with non-object JSON throws error', async () => {
const cwd = process.cwd()
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'view-test-'))
const pkgJsonPath = path.join(tmpDir, 'package.json')
try {
fs.writeFileSync(pkgJsonPath, 'null')
process.chdir(tmpDir)
await expect(
view.handler(VIEW_OPTIONS as unknown as Config & ConfigContext, [])
).rejects.toMatchObject({ code: 'ERR_PNPM_INVALID_PACKAGE_JSON' })
} finally {
process.chdir(cwd)
fs.rmSync(tmpDir, { recursive: true, force: true })
}
})
test('view: resolves package.json from opts.dir when cwd differs', async () => {
const cwd = process.cwd()
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'view-test-'))
const pkgJsonPath = path.join(tmpDir, 'package.json')
const otherDir = fs.mkdtempSync(path.join(os.tmpdir(), 'view-test-other-'))
try {
fs.writeFileSync(pkgJsonPath, JSON.stringify({ name: 'is-negative' }))
process.chdir(otherDir)
const result = await view.handler({ ...VIEW_OPTIONS, dir: tmpDir } as unknown as Config & ConfigContext, [])
expect(typeof result).toBe('string')
expect(result).toContain('is-negative')
} finally {
process.chdir(cwd)
fs.rmSync(tmpDir, { recursive: true, force: true })
fs.rmSync(otherDir, { recursive: true, force: true })
}
})
test('view: derives package name even when engines.pnpm is incompatible', async () => {
const cwd = process.cwd()
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'view-test-'))
const pkgJsonPath = path.join(tmpDir, 'package.json')
try {
fs.writeFileSync(pkgJsonPath, JSON.stringify({
name: 'is-negative',
engines: {
pnpm: '999.0.0',
},
}))
process.chdir(tmpDir)
const result = await view.handler(VIEW_OPTIONS as unknown as Config & ConfigContext, [])
expect(typeof result).toBe('string')
expect(result).toContain('is-negative')
} finally {
process.chdir(cwd)
fs.rmSync(tmpDir, { recursive: true, force: true })
}
})

View File

@@ -81,6 +81,9 @@
{
"path": "../../../testing/registry-mock"
},
{
"path": "../../../workspace/project-manifest-reader"
},
{
"path": "../../../workspace/projects-filter"
},

42
pnpm-lock.yaml generated
View File

@@ -620,6 +620,9 @@ catalogs:
dir-is-case-sensitive:
specifier: ^3.0.0
version: 3.0.0
empathic:
specifier: ^2.0.0
version: 2.0.1
encode-registry:
specifier: ^3.0.1
version: 3.0.1
@@ -662,9 +665,6 @@ catalogs:
fast-glob:
specifier: ^3.3.3
version: 3.3.3
find-up:
specifier: ^8.0.0
version: 8.0.0
fs-extra:
specifier: ^11.3.5
version: 11.3.5
@@ -3443,6 +3443,9 @@ importers:
'@pnpm/types':
specifier: workspace:*
version: link:../../../core/types
'@pnpm/workspace.project-manifest-reader':
specifier: workspace:*
version: link:../../../workspace/project-manifest-reader
'@zkochan/table':
specifier: 'catalog:'
version: 2.0.1
@@ -9794,12 +9797,12 @@ importers:
'@pnpm/workspace.workspace-manifest-reader':
specifier: workspace:*
version: link:../workspace-manifest-reader
empathic:
specifier: 'catalog:'
version: 2.0.1
execa:
specifier: 'catalog:'
version: safe-execa@0.3.0
find-up:
specifier: 'catalog:'
version: 8.0.0
is-subdir:
specifier: 'catalog:'
version: 2.0.0
@@ -9946,9 +9949,9 @@ importers:
'@pnpm/error':
specifier: workspace:*
version: link:../../core/error
find-up:
empathic:
specifier: 'catalog:'
version: 8.0.0
version: 2.0.1
devDependencies:
'@jest/globals':
specifier: 'catalog:'
@@ -13477,6 +13480,10 @@ packages:
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
empathic@2.0.1:
resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==}
engines: {node: '>=14'}
encode-registry@3.0.1:
resolution: {integrity: sha512-6qOwkl1g0fv0DN3Y3ggr2EaZXN71aoAqPp3p/pVaWSBSIo+YjLOWN61Fva43oVyQNPf7kgm8lkudzlzojwE2jw==}
engines: {node: '>=10'}
@@ -13859,10 +13866,6 @@ packages:
resolution: {integrity: sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
find-up@8.0.0:
resolution: {integrity: sha512-JGG8pvDi2C+JxidYdIwQDyS/CgcrIdh18cvgxcBge3wSHRQOrooMD3GlFBcmMJAN9M42SAZjDp5zv1dglJjwww==}
engines: {node: '>=20'}
find-yarn-workspace-root2@1.2.16:
resolution: {integrity: sha512-hr6hb1w8ePMpPVUK39S4RlwJzi+xPLuVuG8XlwXU3KD5Yn3qgBWVfy3AzNlDhWvE1EORCE65/Qm26rFQt3VLVA==}
@@ -14882,10 +14885,6 @@ packages:
resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
locate-path@8.0.0:
resolution: {integrity: sha512-XT9ewWAC43tiAV7xDAPflMkG0qOPn2QjHqlgX8FOqmWa/rxnyYDulF9T0F7tRy1u+TVTmK/M//6VIOye+2zDXg==}
engines: {node: '>=20'}
lodash._baseclone@4.5.7:
resolution: {integrity: sha512-nOtLg6tdIdD+TehqBv0WI7jbkLaohHhKSwLmS/UXSFWMWWUxdJc9EVtAfD4L0mV15vV+lZVfF4LEo363VdrMBw==}
@@ -21334,6 +21333,8 @@ snapshots:
emoji-regex@8.0.0: {}
empathic@2.0.1: {}
encode-registry@3.0.1:
dependencies:
mem: 8.1.1
@@ -21852,11 +21853,6 @@ snapshots:
locate-path: 7.2.0
path-exists: 5.0.0
find-up@8.0.0:
dependencies:
locate-path: 8.0.0
unicorn-magic: 0.3.0
find-yarn-workspace-root2@1.2.16:
dependencies:
micromatch: 4.0.8
@@ -23145,10 +23141,6 @@ snapshots:
dependencies:
p-locate: 6.0.0
locate-path@8.0.0:
dependencies:
p-locate: 6.0.0
lodash._baseclone@4.5.7: {}
lodash.assign@4.2.0: {}

View File

@@ -193,6 +193,7 @@ catalog:
didyoumean2: ^7.0.4
dint: ^5.1.0
dir-is-case-sensitive: ^3.0.0
empathic: ^2.0.0
encode-registry: ^3.0.1
esbuild: ^0.28.1
escape-string-regexp: ^5.0.0
@@ -207,7 +208,6 @@ catalog:
exists-link: 2.0.0
fast-deep-equal: ^3.1.3
fast-glob: ^3.3.3
find-up: ^8.0.0
fs-extra: ^11.3.5
fuse-native: ^2.2.6
get-npm-tarball-url: ^2.1.0

View File

@@ -36,8 +36,8 @@
"@pnpm/workspace.projects-graph": "workspace:*",
"@pnpm/workspace.projects-reader": "workspace:*",
"@pnpm/workspace.workspace-manifest-reader": "workspace:*",
"empathic": "catalog:",
"execa": "catalog:",
"find-up": "catalog:",
"is-subdir": "catalog:",
"micromatch": "catalog:",
"ramda": "catalog:"

View File

@@ -4,8 +4,8 @@ import util from 'node:util'
import { PnpmError } from '@pnpm/error'
import type { ProjectRootDir } from '@pnpm/types'
import * as find from 'empathic/find'
import { safeExeca as execa } from 'execa'
import { findUp } from 'find-up'
import * as micromatch from 'micromatch'
type ChangeType = 'source' | 'test'
@@ -21,8 +21,8 @@ export async function getChangedProjects (
): Promise<[ProjectRootDir[], ProjectRootDir[]]> {
// .git is a directory in regular repos, but a file in worktrees
const gitPath = await findUp('.git', { cwd: opts.workspaceDir, type: 'directory' }) ??
await findUp('.git', { cwd: opts.workspaceDir, type: 'file' })
const gitPath = find.dir('.git', { cwd: opts.workspaceDir }) ??
find.file('.git', { cwd: opts.workspaceDir })
const repoRoot = path.resolve(gitPath ?? opts.workspaceDir, '..')

View File

@@ -32,7 +32,7 @@
},
"dependencies": {
"@pnpm/error": "workspace:*",
"find-up": "catalog:"
"empathic": "catalog:"
},
"devDependencies": {
"@jest/globals": "catalog:",

View File

@@ -2,7 +2,7 @@ import fs from 'node:fs'
import path from 'node:path'
import { PnpmError } from '@pnpm/error'
import { findUp } from 'find-up'
import * as find from 'empathic/find'
const WORKSPACE_DIR_ENV_VAR = 'NPM_CONFIG_WORKSPACE_DIR'
const WORKSPACE_MANIFEST_FILENAME = 'pnpm-workspace.yaml'
@@ -20,7 +20,7 @@ export async function findWorkspaceDir (cwd: string): Promise<string | undefined
const workspaceManifestDirEnvVar = process.env[WORKSPACE_DIR_ENV_VAR] ?? process.env[WORKSPACE_DIR_ENV_VAR.toLowerCase()]
const workspaceManifestLocation = workspaceManifestDirEnvVar
? path.join(workspaceManifestDirEnvVar, WORKSPACE_MANIFEST_FILENAME)
: await findUp([WORKSPACE_MANIFEST_FILENAME, ...INVALID_WORKSPACE_MANIFEST_FILENAME], { cwd: await getRealPath(cwd) })
: find.any([WORKSPACE_MANIFEST_FILENAME, ...INVALID_WORKSPACE_MANIFEST_FILENAME], { cwd: await getRealPath(cwd) })
if (workspaceManifestLocation && path.basename(workspaceManifestLocation) !== WORKSPACE_MANIFEST_FILENAME) {
throw new PnpmError('BAD_WORKSPACE_MANIFEST_NAME', `The workspace manifest file should be named "pnpm-workspace.yaml". File found: ${workspaceManifestLocation}`)
}