Skip to content

Commit 293921a

Browse files
bteaCopilot
andauthored
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>
1 parent cc4cada commit 293921a

11 files changed

Lines changed: 244 additions & 45 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@pnpm/deps.inspection.commands": minor
3+
"@pnpm/workspace.projects-filter": patch
4+
"@pnpm/workspace.root-finder": patch
5+
"pnpm": minor
6+
---
7+
8+
feat(view): support searching project manifest upward when package name is omitted
9+
10+
When running `pnpm view` without a package name, the command now searches
11+
upward for the nearest project manifest (`package.json`, `package.yaml`, or `package.json5`) and uses its `name` field.
12+
If the manifest exists but lacks a `name` field, an error is thrown.
13+
14+
This change also replaces the `find-up` dependency with `empathic` for
15+
improved performance and consistency across workspace tools.

deps/inspection/commands/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"@pnpm/semver-diff": "catalog:",
5757
"@pnpm/store.path": "workspace:*",
5858
"@pnpm/types": "workspace:*",
59+
"@pnpm/workspace.project-manifest-reader": "workspace:*",
5960
"@zkochan/table": "catalog:",
6061
"chalk": "catalog:",
6162
"hosted-git-info": "catalog:",

deps/inspection/commands/src/view/index.ts

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
import path from 'node:path'
2+
13
import { type Config, type ConfigContext, types as allTypes } from '@pnpm/config.reader'
24
import { PnpmError } from '@pnpm/error'
35
import { formatTimeAgo } from '@pnpm/resolving.npm-resolver'
6+
import type { ProjectManifest } from '@pnpm/types'
7+
import { tryReadProjectManifest } from '@pnpm/workspace.project-manifest-reader'
48
import chalk from 'chalk'
59
import { pick } from 'ramda'
610
import { renderHelp } from 'render-help'
@@ -22,11 +26,11 @@ export const commandNames = ['view', 'info', 'show', 'v']
2226

2327
export function help (): string {
2428
return renderHelp({
25-
description: 'View package information from the registry without using npm CLI.',
29+
description: 'View package information from the registry. If package name is omitted, searches upward for the nearest package manifest.',
2630
usages: [
27-
'pnpm view <package-name>',
28-
'pnpm view <package-name>@<version>',
29-
'pnpm view <package-name> [<field>[.subfield]...]',
31+
'pnpm view [<package-name>]',
32+
'pnpm view [<package-name>@<version>]',
33+
'pnpm view [<package-name>] [<field>[.subfield]...]',
3034
],
3135
descriptionLists: [
3236
{
@@ -48,10 +52,20 @@ export async function handler (
4852
},
4953
params: string[]
5054
): Promise<string | void> {
51-
const packageSpec = params[0]
55+
let packageSpec = params[0]
5256

5357
if (!packageSpec) {
54-
throw new PnpmError('MISSING_PACKAGE_NAME', 'Package name is required. Usage: pnpm view <package-name>')
58+
const nearestManifest = await findNearestProjectManifest(opts.dir ?? process.cwd(), opts)
59+
if (!nearestManifest) {
60+
throw new PnpmError('MISSING_PACKAGE_NAME', 'Package name is required. Usage: pnpm view [<package-name>]')
61+
}
62+
if (typeof nearestManifest.manifest.name !== 'string' || nearestManifest.manifest.name.length === 0) {
63+
throw new PnpmError(
64+
'INVALID_PACKAGE_JSON',
65+
`Invalid ${nearestManifest.fileName} at "${nearestManifest.projectDir}". The "name" field is required and must be a non-empty string.`
66+
)
67+
}
68+
packageSpec = nearestManifest.manifest.name
5569
}
5670

5771
const fields = params.slice(1)
@@ -248,6 +262,30 @@ function getPublishedInfo (info: ExtendedPackageInfo): string | null {
248262
return `published ${chalk.cyan(timeAgo)}`
249263
}
250264

265+
async function findNearestProjectManifest (
266+
startDir: string,
267+
_opts: Config & ConfigContext
268+
): Promise<{ manifest: ProjectManifest, fileName: string, projectDir: string } | null> {
269+
try {
270+
const result = await tryReadProjectManifest(startDir)
271+
if (result.manifest != null) {
272+
return {
273+
manifest: result.manifest,
274+
fileName: result.fileName,
275+
projectDir: startDir,
276+
}
277+
}
278+
} catch (err: unknown) {
279+
const message = err instanceof Error ? err.message : String(err)
280+
throw new PnpmError('INVALID_PACKAGE_JSON', `Failed to read or parse project manifest in "${startDir}": ${message}`)
281+
}
282+
const parentDir = path.dirname(startDir)
283+
if (parentDir === startDir) {
284+
return null
285+
}
286+
return findNearestProjectManifest(parentDir, _opts)
287+
}
288+
251289
/**
252290
* Retrieves the publisher name from package metadata.
253291
* Checks fields in order: _npmUser, maintainers, author.

deps/inspection/commands/test/view.ts

Lines changed: 156 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import fs from 'node:fs'
2+
import os from 'node:os'
3+
import path from 'node:path'
4+
import { stripVTControlCharacters as stripAnsi } from 'node:util'
5+
16
import { expect, test } from '@jest/globals'
27
import type { Config, ConfigContext } from '@pnpm/config.reader'
38
import { view } from '@pnpm/deps.inspection.commands'
@@ -37,9 +42,18 @@ test('view: rcOptionsTypes should return object', () => {
3742
})
3843

3944
test('view: missing package name throws error', async () => {
40-
await expect(
41-
view.handler(VIEW_OPTIONS as unknown as Config & ConfigContext, [])
42-
).rejects.toMatchObject({ code: 'ERR_PNPM_MISSING_PACKAGE_NAME' })
45+
const cwd = process.cwd()
46+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'view-test-'))
47+
48+
try {
49+
process.chdir(tmpDir)
50+
await expect(
51+
view.handler(VIEW_OPTIONS as unknown as Config & ConfigContext, [])
52+
).rejects.toMatchObject({ code: 'ERR_PNPM_MISSING_PACKAGE_NAME' })
53+
} finally {
54+
process.chdir(cwd)
55+
fs.rmSync(tmpDir, { recursive: true, force: true })
56+
}
4357
})
4458

4559
test('view: non-registry spec throws error', async () => {
@@ -142,8 +156,9 @@ test('view: text output includes dist section', async () => {
142156

143157
test('view: text output includes dist-tags', async () => {
144158
const result = await view.handler(VIEW_OPTIONS as unknown as Config & ConfigContext, ['is-negative']) as string
145-
expect(result).toContain('dist-tags:')
146-
expect(result).toContain('latest:')
159+
const plainTextResult = stripAnsi(result)
160+
expect(plainTextResult).toContain('dist-tags:')
161+
expect(plainTextResult).toContain('latest:')
147162
})
148163

149164
test('view: text output for package with dependencies shows deps count', async () => {
@@ -233,5 +248,140 @@ test('view: published info includes timestamp', async () => {
233248
test('view: published info includes publisher when maintainer data is available', async () => {
234249
// Note: is-negative package has maintainer data in the mock registry
235250
const result = await view.handler(VIEW_OPTIONS as unknown as Config & ConfigContext, ['is-negative@1.0.0']) as string
236-
expect(result).toMatch(/published .* ago by /)
251+
expect(stripAnsi(result)).toMatch(/published .* ago by /)
252+
})
253+
254+
test('view: uses package manifest name when no package name provided', async () => {
255+
const cwd = process.cwd()
256+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'view-test-'))
257+
const pkgJsonPath = path.join(tmpDir, 'package.json')
258+
259+
try {
260+
fs.writeFileSync(pkgJsonPath, JSON.stringify({ name: 'is-negative' }))
261+
process.chdir(tmpDir)
262+
263+
const result = await view.handler(VIEW_OPTIONS as unknown as Config & ConfigContext, [])
264+
expect(typeof result).toBe('string')
265+
expect(result).toContain('is-negative')
266+
} finally {
267+
process.chdir(cwd)
268+
fs.rmSync(tmpDir, { recursive: true, force: true })
269+
}
270+
})
271+
272+
test('view: searches upward for package manifest in nested directory', async () => {
273+
const cwd = process.cwd()
274+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'view-test-'))
275+
const nestedDir = path.join(tmpDir, 'a', 'b')
276+
const pkgJsonPath = path.join(tmpDir, 'package.json')
277+
278+
try {
279+
fs.mkdirSync(nestedDir, { recursive: true })
280+
fs.writeFileSync(pkgJsonPath, JSON.stringify({ name: 'is-negative' }))
281+
process.chdir(nestedDir)
282+
283+
const result = await view.handler(VIEW_OPTIONS as unknown as Config & ConfigContext, [])
284+
expect(typeof result).toBe('string')
285+
expect(result).toContain('is-negative')
286+
} finally {
287+
process.chdir(cwd)
288+
fs.rmSync(tmpDir, { recursive: true, force: true })
289+
}
290+
})
291+
292+
test('view: package.json without name field throws error', async () => {
293+
const cwd = process.cwd()
294+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'view-test-'))
295+
const pkgJsonPath = path.join(tmpDir, 'package.json')
296+
297+
try {
298+
fs.writeFileSync(pkgJsonPath, JSON.stringify({ version: '1.0.0' }))
299+
process.chdir(tmpDir)
300+
301+
await expect(
302+
view.handler(VIEW_OPTIONS as unknown as Config & ConfigContext, [])
303+
).rejects.toMatchObject({ code: 'ERR_PNPM_INVALID_PACKAGE_JSON' })
304+
} finally {
305+
process.chdir(cwd)
306+
fs.rmSync(tmpDir, { recursive: true, force: true })
307+
}
308+
})
309+
310+
test('view: uses package.yaml name when no package name provided', async () => {
311+
const cwd = process.cwd()
312+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'view-test-'))
313+
const pkgYamlPath = path.join(tmpDir, 'package.yaml')
314+
315+
try {
316+
fs.writeFileSync(pkgYamlPath, 'name: is-negative\n')
317+
process.chdir(tmpDir)
318+
319+
const result = await view.handler(VIEW_OPTIONS as unknown as Config & ConfigContext, [])
320+
expect(typeof result).toBe('string')
321+
expect(result).toContain('is-negative')
322+
} finally {
323+
process.chdir(cwd)
324+
fs.rmSync(tmpDir, { recursive: true, force: true })
325+
}
326+
})
327+
328+
test('view: package.json with non-object JSON throws error', async () => {
329+
const cwd = process.cwd()
330+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'view-test-'))
331+
const pkgJsonPath = path.join(tmpDir, 'package.json')
332+
333+
try {
334+
fs.writeFileSync(pkgJsonPath, 'null')
335+
process.chdir(tmpDir)
336+
337+
await expect(
338+
view.handler(VIEW_OPTIONS as unknown as Config & ConfigContext, [])
339+
).rejects.toMatchObject({ code: 'ERR_PNPM_INVALID_PACKAGE_JSON' })
340+
} finally {
341+
process.chdir(cwd)
342+
fs.rmSync(tmpDir, { recursive: true, force: true })
343+
}
344+
})
345+
346+
test('view: resolves package.json from opts.dir when cwd differs', async () => {
347+
const cwd = process.cwd()
348+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'view-test-'))
349+
const pkgJsonPath = path.join(tmpDir, 'package.json')
350+
const otherDir = fs.mkdtempSync(path.join(os.tmpdir(), 'view-test-other-'))
351+
352+
try {
353+
fs.writeFileSync(pkgJsonPath, JSON.stringify({ name: 'is-negative' }))
354+
process.chdir(otherDir)
355+
356+
const result = await view.handler({ ...VIEW_OPTIONS, dir: tmpDir } as unknown as Config & ConfigContext, [])
357+
expect(typeof result).toBe('string')
358+
expect(result).toContain('is-negative')
359+
} finally {
360+
process.chdir(cwd)
361+
fs.rmSync(tmpDir, { recursive: true, force: true })
362+
fs.rmSync(otherDir, { recursive: true, force: true })
363+
}
364+
})
365+
366+
test('view: derives package name even when engines.pnpm is incompatible', async () => {
367+
const cwd = process.cwd()
368+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'view-test-'))
369+
const pkgJsonPath = path.join(tmpDir, 'package.json')
370+
371+
try {
372+
fs.writeFileSync(pkgJsonPath, JSON.stringify({
373+
name: 'is-negative',
374+
engines: {
375+
pnpm: '999.0.0',
376+
},
377+
}))
378+
process.chdir(tmpDir)
379+
380+
const result = await view.handler(VIEW_OPTIONS as unknown as Config & ConfigContext, [])
381+
expect(typeof result).toBe('string')
382+
expect(result).toContain('is-negative')
383+
} finally {
384+
process.chdir(cwd)
385+
fs.rmSync(tmpDir, { recursive: true, force: true })
386+
}
237387
})

deps/inspection/commands/tsconfig.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@
8181
{
8282
"path": "../../../testing/registry-mock"
8383
},
84+
{
85+
"path": "../../../workspace/project-manifest-reader"
86+
},
8487
{
8588
"path": "../../../workspace/projects-filter"
8689
},

0 commit comments

Comments
 (0)