feat: support finder functions for performing complex searches with list and why commands (#9946)

This commit is contained in:
Zoltan Kochan
2025-09-12 11:46:32 +02:00
committed by GitHub
parent 38e2599ecd
commit e792927841
30 changed files with 342 additions and 59 deletions

View File

@@ -0,0 +1,5 @@
---
"@pnpm/read-package-json": minor
---
Implemented `readPackageJsonSync`.

View File

@@ -0,0 +1,11 @@
---
"@pnpm/plugin-commands-listing": minor
"@pnpm/reviewing.dependencies-hierarchy": minor
"@pnpm/pnpmfile": minor
"@pnpm/types": minor
"@pnpm/list": minor
"@pnpm/cli-utils": minor
"@pnpm/config": minor
---
Added support for `finders` [#9946](https://github.com/pnpm/pnpm/pull/9946).

View File

@@ -0,0 +1,78 @@
---
"pnpm": minor
---
Added support for `finders` [#9946](https://github.com/pnpm/pnpm/pull/9946).
In the past, `pnpm list` and `pnpm why` could only search for dependencies by **name** (and optionally version). For example:
```
pnpm why minimist
```
prints the chain of dependencies to any installed instance of `minimist`:
```
verdaccio 5.20.1
├─┬ handlebars 4.7.7
│ └── minimist 1.2.8
└─┬ mv 2.1.1
└─┬ mkdirp 0.5.6
└── minimist 1.2.8
```
What if we want to search by **other properties** of a dependency, not just its name? For instance, find all packages that have `react@17` in their peer dependencies?
This is now possible with "finder functions". Finder functions can be declared in `.pnpmfile.cjs` and invoked with the `--find-by=<function name>` flag when running `pnpm list` or `pnpm why`.
Let's say we want to find any dependencies that have React 17 in peer dependencies. We can add this finder to our `.pnpmfile.cjs`:
```js
module.exports = {
finders: {
react17: (ctx) => {
return ctx.readManifest().peerDependencies?.react === '^17.0.0'
}
}
}
```
Now we can use this finder function by running:
```
pnpm why --find-by=react17
```
pnpm will find all dependencies that have this React in peer dependencies and print their exact locations in the dependency graph.
```
@apollo/client 4.0.4
├── @graphql-typed-document-node/core 3.2.0
└── graphql-tag 2.12.6
```
It is also possible to print out some additional information in the output by returning a string from the finder. For example, with the following finder:
```js
module.exports = {
finders: {
react17: (ctx) => {
const manifest = ctx.readManifest()
if (manifest.peerDependencies?.react === '^17.0.0') {
return `license: ${manifest.license}`
}
return false
}
}
}
```
Every matched package will also print out the license from its `package.json`:
```
@apollo/client 4.0.4
├── @graphql-typed-document-node/core 3.2.0
│ license: MIT
└── graphql-tag 2.12.6
license: MIT
```

View File

@@ -43,12 +43,13 @@ export async function getConfig (
const configModulesDir = path.join(config.lockfileDir ?? config.rootProjectManifestDir, 'node_modules/.pnpm-config')
pnpmfiles.unshift(...calcPnpmfilePathsOfPluginDeps(configModulesDir, config.configDependencies))
}
const { hooks, resolvedPnpmfilePaths } = requireHooks(config.lockfileDir ?? config.dir, {
const { hooks, finders, resolvedPnpmfilePaths } = requireHooks(config.lockfileDir ?? config.dir, {
globalPnpmfile: config.globalPnpmfile,
pnpmfiles,
tryLoadDefaultPnpmfile: config.tryLoadDefaultPnpmfile,
})
config.hooks = hooks
config.finders = finders
config.pnpmfile = resolvedPnpmfilePaths
if (config.hooks?.updateConfig) {
for (const updateConfig of config.hooks.updateConfig) {

View File

@@ -1,5 +1,6 @@
import type { Catalogs } from '@pnpm/catalogs.types'
import {
type Finder,
type Project,
type ProjectManifest,
type ProjectsGraph,
@@ -141,6 +142,7 @@ export interface Config extends OptionsFromRootManifest {
pnpmfile: string[] | string
tryLoadDefaultPnpmfile?: boolean
hooks?: Hooks
finders?: Record<string, Finder>
packageImportMethod?: 'auto' | 'hardlink' | 'copy' | 'clone' | 'clone-or-copy'
hoistPattern?: string[]
publicHoistPattern?: string[] | string

View File

@@ -47,7 +47,8 @@
"devDependencies": {
"@pnpm/fetcher-base": "workspace:*",
"@pnpm/logger": "workspace:*",
"@pnpm/pnpmfile": "workspace:*"
"@pnpm/pnpmfile": "workspace:*",
"@pnpm/test-fixtures": "workspace:*"
},
"engines": {
"node": ">=18.12"

View File

@@ -5,7 +5,7 @@ import { createHashFromMultipleFiles } from '@pnpm/crypto.hash'
import pathAbsolute from 'path-absolute'
import type { CustomFetchers } from '@pnpm/fetcher-base'
import { type ImportIndexedPackageAsync } from '@pnpm/store-controller-types'
import { requirePnpmfile, type Pnpmfile } from './requirePnpmfile.js'
import { requirePnpmfile, type Pnpmfile, type Finders } from './requirePnpmfile.js'
import { type HookContext, type Hooks } from './Hooks.js'
// eslint-disable-next-line
@@ -24,6 +24,7 @@ interface PnpmfileEntry {
interface PnpmfileEntryLoaded {
file: string
hooks: Pnpmfile['hooks'] | undefined
finders: Pnpmfile['finders'] | undefined
includeInChecksum: boolean
}
@@ -40,6 +41,7 @@ export interface CookedHooks {
export interface RequireHooksResult {
hooks: CookedHooks
finders: Finders
resolvedPnpmfilePaths: string[]
}
@@ -85,6 +87,7 @@ export function requireHooks (
file,
includeInChecksum,
hooks: requirePnpmfileResult.pnpmfileModule?.hooks,
finders: requirePnpmfileResult.pnpmfileModule?.finders,
})
} else if (!optional) {
throw new PnpmError('PNPMFILE_NOT_FOUND', `pnpmfile at "${file}" is not found`)
@@ -92,6 +95,7 @@ export function requireHooks (
}
}
const mergedFinders: Finders = {}
const cookedHooks: CookedHooks & Required<Pick<CookedHooks, 'readPackage' | 'preResolution' | 'afterAllResolved' | 'filterLog' | 'updateConfig'>> = {
readPackage: [],
preResolution: [],
@@ -116,9 +120,23 @@ export function requireHooks (
let importProvider: string | undefined
let fetchersProvider: string | undefined
const finderProviders: Record<string, string> = {}
// process hooks in order
for (const { hooks, file } of entries) {
for (const { hooks, file, finders } of entries) {
if (finders != null) {
for (const [finderName, finder] of Object.entries(finders)) {
if (mergedFinders[finderName] != null) {
const firstDefinedIn = finderProviders[finderName]
throw new PnpmError(
'DUPLICATE_FINDER',
`Finder "${finderName}" defined in both ${firstDefinedIn} and ${file}`
)
}
mergedFinders[finderName] = finder
finderProviders[finderName] = file
}
}
const fileHooks: Hooks = hooks ?? {}
// readPackage & afterAllResolved
@@ -180,6 +198,7 @@ export function requireHooks (
return {
hooks: cookedHooks,
finders: mergedFinders,
resolvedPnpmfilePaths: entries.map(({ file }) => file),
}
}

View File

@@ -3,7 +3,7 @@ import fs from 'fs'
import util from 'util'
import { PnpmError } from '@pnpm/error'
import { logger } from '@pnpm/logger'
import { type PackageManifest } from '@pnpm/types'
import { type PackageManifest, type Finder } from '@pnpm/types'
import chalk from 'chalk'
import { type Hooks } from './Hooks.js'
@@ -27,8 +27,11 @@ class PnpmFileFailError extends PnpmError {
}
}
export type Finders = Record<string, Finder>
export interface Pnpmfile {
hooks?: Hooks
finders?: Finders
}
export function requirePnpmfile (pnpmFilePath: string, prefix: string): { pnpmfileModule: Pnpmfile | undefined } | undefined {

View File

@@ -0,0 +1,6 @@
module.exports = {
finders: {
bar: () => false,
},
}

View File

@@ -0,0 +1,5 @@
module.exports = {
finders: {
foo: () => false,
},
}

View File

@@ -0,0 +1,5 @@
module.exports = {
finders: {
foo: () => false,
},
}

View File

@@ -1,9 +1,11 @@
import path from 'path'
import { type Log } from '@pnpm/core-loggers'
import { requireHooks, BadReadPackageHookError, type HookContext } from '@pnpm/pnpmfile'
import { fixtures } from '@pnpm/test-fixtures'
import { requirePnpmfile } from '../src/requirePnpmfile.js'
const defaultHookContext: HookContext = { log () {} }
const f = fixtures(__dirname)
test('ignoring a pnpmfile that exports undefined', () => {
const { pnpmfileModule: pnpmfile } = requirePnpmfile(path.join(__dirname, '__fixtures__/undefined.js'), __dirname)!
@@ -83,3 +85,19 @@ test('updateConfig throws an error if it returns undefined', async () => {
test('requireHooks throw an error if one of the specified pnpmfiles does not exist', async () => {
expect(() => requireHooks(__dirname, { pnpmfiles: ['does-not-exist.cjs'] })).toThrow('is not found')
})
test('requireHooks throws an error if there are two finders with the same name', async () => {
const findersDir = f.find('finders')
const pnpmfile1 = path.join(findersDir, 'finderFoo1.js')
const pnpmfile2 = path.join(findersDir, 'finderFoo2.js')
expect(() => requireHooks(__dirname, { pnpmfiles: [pnpmfile1, pnpmfile2] })).toThrow('Finder "foo" defined in both')
})
test('requireHooks merges all the finders', async () => {
const findersDir = f.find('finders')
const pnpmfile1 = path.join(findersDir, 'finderFoo1.js')
const pnpmfile2 = path.join(findersDir, 'finderBar.js')
const { finders } = requireHooks(__dirname, { pnpmfiles: [pnpmfile1, pnpmfile2] })
expect(finders.foo).toBeDefined()
expect(finders.bar).toBeDefined()
})

View File

@@ -9,6 +9,9 @@
"../../__typings__/**/*.d.ts"
],
"references": [
{
"path": "../../__utils__/test-fixtures"
},
{
"path": "../../crypto/hash"
},

View File

@@ -1,5 +1,5 @@
import { type DependenciesField } from './misc.js'
import { type BaseManifest } from './package.js'
import { type BaseManifest, type DependencyManifest } from './package.js'
export type LogBase = {
level: 'debug' | 'error'
@@ -14,3 +14,11 @@ export type IncludedDependencies = {
}
export type ReadPackageHook = <Pkg extends BaseManifest> (pkg: Pkg, dir?: string) => Pkg | Promise<Pkg>
export interface FinderContext {
name: string
version: string
readManifest: () => DependencyManifest
}
export type Finder = (ctx: FinderContext) => boolean | string

View File

@@ -4,6 +4,17 @@ import { type PackageManifest } from '@pnpm/types'
import loadJsonFile from 'load-json-file'
import normalizePackageData from 'normalize-package-data'
export function readPackageJsonSync (pkgPath: string): PackageManifest {
try {
const manifest = loadJsonFile.sync<PackageManifest>(pkgPath)
normalizePackageData(manifest)
return manifest
} catch (err: any) { // eslint-disable-line
if (err.code) throw err
throw new PnpmError('BAD_PACKAGE_JSON', `${pkgPath}: ${err.message as string}`)
}
}
export async function readPackageJson (pkgPath: string): Promise<PackageManifest> {
try {
const manifest = await loadJsonFile<PackageManifest>(pkgPath)
@@ -15,6 +26,10 @@ export async function readPackageJson (pkgPath: string): Promise<PackageManifest
}
}
export function readPackageJsonFromDirSync (pkgPath: string): PackageManifest {
return readPackageJsonSync(path.join(pkgPath, 'package.json'))
}
export async function readPackageJsonFromDir (pkgPath: string): Promise<PackageManifest> {
return readPackageJson(path.join(pkgPath, 'package.json'))
}

3
pnpm-lock.yaml generated
View File

@@ -3444,6 +3444,9 @@ importers:
'@pnpm/pnpmfile':
specifier: workspace:*
version: 'link:'
'@pnpm/test-fixtures':
specifier: workspace:*
version: link:../../__utils__/test-fixtures
hooks/read-package-hook:
dependencies:

View File

@@ -1,6 +1,7 @@
import { preparePackages } from '@pnpm/prepare'
import fs from 'fs'
import { prepare, preparePackages } from '@pnpm/prepare'
import { sync as writeYamlFile } from 'write-yaml-file'
import { execPnpmSync } from './utils/index.js'
import { execPnpm, execPnpmSync } from './utils/index.js'
test('ls --filter=not-exist --json should prints an empty array (#9672)', async () => {
preparePackages([
@@ -21,3 +22,23 @@ test('ls --filter=not-exist --json should prints an empty array (#9672)', async
const { stdout } = execPnpmSync(['ls', '--filter=project-that-does-not-exist', '--json'], { expectSuccess: true })
expect(JSON.parse(stdout.toString())).toStrictEqual([])
})
test('ls should load a finder from .pnpmfile.cjs', async () => {
prepare()
const pnpmfile = `
module.exports = { finders: { hasPeerA } }
function hasPeerA (context) {
const manifest = context.readManifest()
if (manifest?.peerDependencies?.['@pnpm.e2e/peer-a'] == null) {
return false
}
return \`@pnpm.e2e/peer-a@$\{manifest.peerDependencies['@pnpm.e2e/peer-a']}\`
}
`
fs.writeFileSync('.pnpmfile.cjs', pnpmfile, 'utf8')
await execPnpm(['add', 'is-positive@1.0.0', '@pnpm.e2e/abc@1.0.0'])
const result = execPnpmSync(['list', '--find-by=hasPeerA'])
expect(result.stdout.toString()).toMatch(`dependencies:
@pnpm.e2e/abc 1.0.0
@pnpm.e2e/peer-a@^1.0.0`)
})

View File

@@ -12,4 +12,5 @@ export interface PackageNode {
resolved?: string
searched?: true
version: string
searchMessage?: string
}

View File

@@ -11,13 +11,12 @@ import { detectDepTypes } from '@pnpm/lockfile.detect-dep-types'
import { readModulesManifest } from '@pnpm/modules-yaml'
import { normalizeRegistries } from '@pnpm/normalize-registries'
import { readModulesDir } from '@pnpm/read-modules-dir'
import { safeReadPackageJsonFromDir } from '@pnpm/read-package-json'
import { type DependenciesField, DEPENDENCIES_FIELDS, type Registries } from '@pnpm/types'
import { safeReadPackageJsonFromDir, readPackageJsonFromDirSync } from '@pnpm/read-package-json'
import { type DependenciesField, type Finder, DEPENDENCIES_FIELDS, type Registries } from '@pnpm/types'
import normalizePath from 'normalize-path'
import realpathMissing from 'realpath-missing'
import resolveLinkTarget from 'resolve-link-target'
import { type PackageNode } from './PackageNode.js'
import { type SearchFunction } from './types.js'
import { getTree } from './getTree.js'
import { getTreeNodeChildId } from './getTreeNodeChildId.js'
import { getPkgInfo } from './getPkgInfo.js'
@@ -38,7 +37,7 @@ export async function buildDependenciesHierarchy (
include?: { [dependenciesField in DependenciesField]: boolean }
registries?: Registries
onlyProjects?: boolean
search?: SearchFunction
search?: Finder
lockfileDir: string
modulesDir?: string
virtualStoreDirMaxLength: number
@@ -109,7 +108,7 @@ async function dependenciesHierarchyForPackage (
include: { [dependenciesField in DependenciesField]: boolean }
registries: Registries
onlyProjects?: boolean
search?: SearchFunction
search?: Finder
skipped: Set<string>
lockfileDir: string
modulesDir?: string
@@ -152,7 +151,7 @@ async function dependenciesHierarchyForPackage (
result[dependenciesField] = []
for (const alias in topDeps) {
const ref = topDeps[alias]
const packageInfo = getPkgInfo({
const { pkgInfo: packageInfo, readManifest } = getPkgInfo({
alias,
currentPackages: currentLockfile.packages ?? {},
depTypes,
@@ -166,7 +165,11 @@ async function dependenciesHierarchyForPackage (
virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength,
})
let newEntry: PackageNode | null = null
const matchedSearched = opts.search?.(packageInfo)
const matchedSearched = opts.search?.({
name: packageInfo.name,
version: packageInfo.version,
readManifest,
})
const nodeId = getTreeNodeChildId({
parentId,
dep: { alias, ref },
@@ -192,6 +195,9 @@ async function dependenciesHierarchyForPackage (
if (newEntry != null) {
if (matchedSearched) {
newEntry.searched = true
if (typeof matchedSearched === 'string') {
newEntry.searchMessage = matchedSearched
}
}
result[dependenciesField]!.push(newEntry)
}
@@ -219,11 +225,18 @@ async function dependenciesHierarchyForPackage (
path: pkgPath,
version,
}
const matchedSearched = opts.search?.(pkg)
const matchedSearched = opts.search?.({
name: pkg.name,
version: pkg.version,
readManifest: () => readPackageJsonFromDirSync(pkgPath),
})
if ((opts.search != null) && !matchedSearched) return
const newEntry: PackageNode = pkg
if (matchedSearched) {
newEntry.searched = true
if (typeof matchedSearched === 'string') {
newEntry.searchMessage = matchedSearched
}
}
result.unsavedDependencies = result.unsavedDependencies ?? []
result.unsavedDependencies.push(newEntry)

View File

@@ -1,13 +1,31 @@
import { createMatcher } from '@pnpm/matcher'
import npa from '@pnpm/npm-package-arg'
import { type SearchFunction } from './types.js'
import { type FinderContext, type Finder } from '@pnpm/types'
import semver from 'semver'
export function createPackagesSearcher (queries: string[]): SearchFunction {
const searchers: SearchFunction[] = queries
export function createPackagesSearcher (queries: string[], finders?: Finder[]): Finder {
const searchers: Finder[] = queries
.map(parseSearchQuery)
.map((packageSelector) => search.bind(null, packageSelector))
return (pkg) => searchers.some((search) => search(pkg))
return (pkg) => {
if (searchers.length > 0 && searchers.some((search) => search(pkg))) {
return true
}
if (finders == null) return false
const messages: string[] = []
let found = false
for (const finder of finders) {
const result = finder(pkg)
if (result) {
found = true
if (typeof result === 'string') {
messages.push(result)
}
}
}
if (messages.length) return messages.join('\n')
return found
}
}
type MatchFunction = (entry: string) => boolean
@@ -17,15 +35,15 @@ function search (
matchName: MatchFunction
matchVersion?: MatchFunction
},
pkg: { name: string, version: string }
{ name, version }: FinderContext
): boolean {
if (!packageSelector.matchName(pkg.name)) {
if (!packageSelector.matchName(name)) {
return false
}
if (packageSelector.matchVersion == null) {
return true
}
return !pkg.version.startsWith('link:') && packageSelector.matchVersion(pkg.version)
return !version.startsWith('link:') && packageSelector.matchVersion(version)
}
interface ParsedSearchQuery {

View File

@@ -9,8 +9,9 @@ import {
pkgSnapshotToResolution,
} from '@pnpm/lockfile.utils'
import { type DepTypes, DepType } from '@pnpm/lockfile.detect-dep-types'
import { type Registries } from '@pnpm/types'
import { type DependencyManifest, type Registries } from '@pnpm/types'
import { depPathToFilename, refToRelative } from '@pnpm/dependency-path'
import { readPackageJsonFromDirSync } from '@pnpm/read-package-json'
import normalizePath from 'normalize-path'
export interface GetPkgInfoOpts {
@@ -40,7 +41,7 @@ export interface GetPkgInfoOpts {
readonly rewriteLinkVersionDir?: string
}
export function getPkgInfo (opts: GetPkgInfoOpts): PackageInfo {
export function getPkgInfo (opts: GetPkgInfoOpts): { pkgInfo: PackageInfo, readManifest: () => DependencyManifest } {
let name!: string
let version: string
let resolved: string | undefined
@@ -107,7 +108,10 @@ export function getPkgInfo (opts: GetPkgInfoOpts): PackageInfo {
} else if (depType === DepType.ProdOnly) {
packageInfo.dev = false
}
return packageInfo
return {
pkgInfo: packageInfo,
readManifest: () => readPackageJsonFromDirSync(fullPackagePath),
}
}
interface PackageInfo {

View File

@@ -1,8 +1,7 @@
import path from 'path'
import { type PackageSnapshots, type ProjectSnapshot } from '@pnpm/lockfile.fs'
import { type DepTypes } from '@pnpm/lockfile.detect-dep-types'
import { type Registries } from '@pnpm/types'
import { type SearchFunction } from './types.js'
import { type Finder, type Registries } from '@pnpm/types'
import { type PackageNode } from './PackageNode.js'
import { getPkgInfo } from './getPkgInfo.js'
import { getTreeNodeChildId } from './getTreeNodeChildId.js'
@@ -16,7 +15,7 @@ interface GetTreeOpts {
excludePeerDependencies?: boolean
lockfileDir: string
onlyProjects?: boolean
search?: SearchFunction
search?: Finder
skipped: Set<string>
registries: Registries
importers: Record<string, ProjectSnapshot>
@@ -127,7 +126,7 @@ function getTreeHelper (
for (const alias in deps) {
const ref = deps[alias]
const packageInfo = getPkgInfo({
const { pkgInfo: packageInfo, readManifest } = getPkgInfo({
alias,
currentPackages: opts.currentPackages,
depTypes: opts.depTypes,
@@ -142,7 +141,11 @@ function getTreeHelper (
virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength,
})
let circular: boolean
const matchedSearched = opts.search?.(packageInfo)
const matchedSearched = opts.search?.({
name: packageInfo.name,
version: packageInfo.version,
readManifest,
})
let newEntry: PackageNode | null = null
const nodeId = getTreeNodeChildId({
parentId,
@@ -210,6 +213,9 @@ function getTreeHelper (
}
if (matchedSearched) {
newEntry.searched = true
if (typeof matchedSearched === 'string') {
newEntry.searchMessage = matchedSearched
}
}
if (!newEntry.isPeer || !opts.excludePeerDependencies || newEntry.dependencies?.length) {
resultDependencies.push(newEntry)

View File

@@ -1,4 +1,3 @@
export { buildDependenciesHierarchy, type DependenciesHierarchy } from './buildDependenciesHierarchy.js'
export { type PackageNode } from './PackageNode.js'
export { type SearchFunction } from './types.js'
export { createPackagesSearcher } from './createPackagesSearcher.js'

View File

@@ -1 +0,0 @@
export type SearchFunction = (pkg: { name: string, version: string }) => boolean

View File

@@ -1,25 +1,44 @@
import { type DependencyManifest } from '@pnpm/types'
import { createPackagesSearcher } from '../lib/createPackagesSearcher.js'
test('packages searcher', () => {
{
const search = createPackagesSearcher(['rimraf@*'])
expect(search({ name: 'rimraf', version: '1.0.0' })).toBeTruthy()
expect(search({ name: 'express', version: '1.0.0' })).not.toBeTruthy()
expect(search(mockContext({ name: 'rimraf', version: '1.0.0' }))).toBeTruthy()
expect(search(mockContext({ name: 'express', version: '1.0.0' }))).not.toBeTruthy()
}
{
const search = createPackagesSearcher(['rim*'])
expect(search({ name: 'rimraf', version: '1.0.0' })).toBeTruthy()
expect(search({ name: 'express', version: '1.0.0' })).not.toBeTruthy()
expect(search(mockContext({ name: 'rimraf', version: '1.0.0' }))).toBeTruthy()
expect(search(mockContext({ name: 'express', version: '1.0.0' }))).not.toBeTruthy()
}
{
const search = createPackagesSearcher(['rim*@2'])
expect(search({ name: 'rimraf', version: '2.0.0' })).toBeTruthy()
expect(search({ name: 'rimraf', version: '1.0.0' })).not.toBeTruthy()
expect(search(mockContext({ name: 'rimraf', version: '2.0.0' }))).toBeTruthy()
expect(search(mockContext({ name: 'rimraf', version: '1.0.0' }))).not.toBeTruthy()
}
{
const search = createPackagesSearcher(['minimatch', 'once@1.4'])
expect(search({ name: 'minimatch', version: '2.0.0' })).toBeTruthy()
expect(search({ name: 'once', version: '1.4.1' })).toBeTruthy()
expect(search({ name: 'rimraf', version: '1.0.0' })).not.toBeTruthy()
expect(search(mockContext({ name: 'minimatch', version: '2.0.0' }))).toBeTruthy()
expect(search(mockContext({ name: 'once', version: '1.4.1' }))).toBeTruthy()
expect(search(mockContext({ name: 'rimraf', version: '1.0.0' }))).not.toBeTruthy()
}
})
test('package searcher with 2 finders', () => {
const search = createPackagesSearcher([], [
(ctx) => ctx.name === 'once',
(ctx) => ctx.name === 'rimraf',
])
expect(search(mockContext({ name: 'minimatch', version: '2.0.0' }))).toBeFalsy()
expect(search(mockContext({ name: 'once', version: '1.4.1' }))).toBeTruthy()
expect(search(mockContext({ name: 'rimraf', version: '1.0.0' }))).toBeTruthy()
})
function mockContext (manifest: DependencyManifest) {
return {
name: manifest.name,
version: manifest.version,
readManifest: () => manifest,
}
}

View File

@@ -1,6 +1,6 @@
import path from 'path'
import { safeReadProjectManifestOnly } from '@pnpm/read-project-manifest'
import { type DependenciesField, type Registries } from '@pnpm/types'
import { type DependenciesField, type Registries, type Finder } from '@pnpm/types'
import { type PackageNode, buildDependenciesHierarchy, type DependenciesHierarchy, createPackagesSearcher } from '@pnpm/reviewing.dependencies-hierarchy'
import { renderJson } from './renderJson.js'
import { renderParseable } from './renderParseable.js'
@@ -66,9 +66,10 @@ export async function searchForPackages (
registries?: Registries
modulesDir?: string
virtualStoreDirMaxLength: number
finders?: Finder[]
}
): Promise<PackageDependencyHierarchy[]> {
const search = createPackagesSearcher(packages)
const search = createPackagesSearcher(packages, opts.finders)
return Promise.all(
Object.entries(await buildDependenciesHierarchy(projectPaths, {
@@ -110,6 +111,7 @@ export async function listForPackages (
registries?: Registries
modulesDir?: string
virtualStoreDirMaxLength: number
finders?: Finder[]
}
): Promise<string> {
const opts = { ...DEFAULTS, ...maybeOpts }
@@ -143,6 +145,7 @@ export async function list (
showExtraneous?: boolean
modulesDir?: string
virtualStoreDirMaxLength: number
finders?: Finder[]
}
): Promise<string> {
const opts = { ...DEFAULTS, ...maybeOpts }

View File

@@ -114,12 +114,17 @@ export async function toArchyTree (
return Promise.all(
sortPackages(entryNodes).map(async (node) => {
const nodes = await toArchyTree(getPkgColor, node.dependencies ?? [], opts)
const labelLines: string[] = [
printLabel(getPkgColor, node),
]
if (node.searchMessage) {
labelLines.push(node.searchMessage)
}
if (opts.long) {
const pkg = await getPkgInfo(node)
const labelLines = [
printLabel(getPkgColor, node),
pkg.description,
]
if (pkg.description) {
labelLines.push(pkg.description)
}
if (pkg.repository) {
labelLines.push(pkg.repository)
}
@@ -129,14 +134,9 @@ export async function toArchyTree (
if (pkg.path) {
labelLines.push(pkg.path)
}
return {
label: labelLines.join('\n'),
nodes,
}
}
return {
label: printLabel(getPkgColor, node),
label: labelLines.join('\n'),
nodes,
}
})

View File

@@ -1,8 +1,9 @@
import { PnpmError } from '@pnpm/error'
import { docsUrl } from '@pnpm/cli-utils'
import { FILTERING, OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/common-cli-options-help'
import { type Config, types as allTypes } from '@pnpm/config'
import { list, listForPackages } from '@pnpm/list'
import { type IncludedDependencies } from '@pnpm/types'
import { type Finder, type IncludedDependencies } from '@pnpm/types'
import pick from 'ramda/src/pick'
import renderHelp from 'render-help'
import { listRecursive } from './recursive.js'
@@ -32,6 +33,7 @@ export const cliOptionsTypes = (): Record<string, unknown> => ({
'exclude-peers': Boolean,
'only-projects': Boolean,
recursive: Boolean,
'find-by': [String, Array],
})
export const shorthands: Record<string, string> = {
@@ -124,6 +126,7 @@ export type ListCommandOptions = Pick<Config,
| 'allProjects'
| 'dev'
| 'dir'
| 'finders'
| 'optional'
| 'production'
| 'selectedProjectsGraph'
@@ -139,6 +142,7 @@ export type ListCommandOptions = Pick<Config,
parseable?: boolean
onlyProjects?: boolean
recursive?: boolean
findBy?: string[]
}
export async function handler (
@@ -178,8 +182,19 @@ export async function render (
parseable?: boolean
modulesDir?: string
virtualStoreDirMaxLength: number
finders?: Record<string, Finder>
findBy?: string[]
}
): Promise<string> {
const finders: Finder[] = []
if (opts.findBy) {
for (const finderName of opts.findBy) {
if (opts.finders?.[finderName] == null) {
throw new PnpmError('FINDER_NOT_FOUND', `No finder with name ${finderName} is found`)
}
finders.push(opts.finders[finderName])
}
}
const listOpts = {
alwaysPrintRootPackage: opts.alwaysPrintRootPackage,
depth: opts.depth ?? 0,
@@ -192,8 +207,9 @@ export async function render (
showExtraneous: false,
modulesDir: opts.modulesDir,
virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength,
finders,
}
return (params.length > 0)
return (params.length > 0) || listOpts.finders.length > 0
? listForPackages(params, prefixes, listOpts)
: list(prefixes, listOpts)
}

View File

@@ -25,6 +25,7 @@ export const cliOptionsTypes = (): Record<string, unknown> => ({
...rcOptionsTypes(),
'exclude-peers': Boolean,
recursive: Boolean,
'find-by': [String, Array],
})
export const shorthands: Record<string, string> = {
@@ -103,8 +104,8 @@ export async function handler (
opts: ListCommandOptions,
params: string[]
): Promise<string> {
if (params.length === 0) {
throw new PnpmError('MISSING_PACKAGE_NAME', '`pnpm why` requires the package name')
if (params.length === 0 && opts.findBy == null) {
throw new PnpmError('MISSING_PACKAGE_NAME', '`pnpm why` requires the package name or --find-by=<finder-name>')
}
return list({
...opts,

View File

@@ -260,5 +260,5 @@ test('`pnpm recursive why` should fail if no package name was provided', async (
}
expect(err.code).toBe('ERR_PNPM_MISSING_PACKAGE_NAME')
expect(err.message).toBe('`pnpm why` requires the package name')
expect(err.message).toMatch('`pnpm why` requires the package name')
})