fix(list): honor --json and --parseable for pnpm -g ls (#11451)

Restores `--json` / `--parseable` / `--long` support on `pnpm -g ls` and tightens `--depth>0` semantics around isolated global installs. Closes #11440.

- **`--json` / `--parseable` (the regression):** aggregate global packages from all isolated install dirs into a single synthesized `PackageDependencyHierarchy` and dispatch to the existing `renderJson` / `renderParseable` / `renderTree`. Output shape matches pnpm 10 (`result[0].dependencies[name].version`), so tools like `npm-check-updates` work again.
- **`--depth>0`:** the v11 architecture installs each global package into its own isolated dir with its own lockfile, so merging transitive trees across installs would be incoherent. New behavior:
  - One global install dir total → fast-path delegate to the regular `list` flow with `params` unchanged, so `listForPackages` can match top-level *or* transitive packages.
  - Multiple installs, params narrow to one install dir (top-level alias match) → drop the params and render that install dir's full tree.
  - Multiple installs, params don't narrow → throw `ERR_PNPM_GLOBAL_LS_DEPTH_NOT_SUPPORTED` with a message asking the user to filter to a single global package or omit `--depth`.

The regression was introduced by the isolated global packages refactor (#10697), which added a custom `listGlobalPackages` shortcut that always returned plain text and ignored format flags.
This commit is contained in:
Zoltan Kochan
2026-05-04 19:10:53 +02:00
parent 2b8932d8fc
commit 72629fc611
9 changed files with 341 additions and 13 deletions

View File

@@ -0,0 +1,8 @@
---
"@pnpm/global.commands": patch
"pnpm": patch
---
Fix `pnpm -g ls --json` and `pnpm -g ls --parseable` so they emit valid JSON and parseable output respectively, matching pnpm 10 behavior. Since the isolated global packages refactor in pnpm 11, the global list command had a custom path that always printed plain text and ignored `--json`/`--parseable`, which broke tools like `npm-check-updates` that parse the JSON output [#11440](https://github.com/pnpm/pnpm/issues/11440).
`pnpm -g ls --depth=<n>` (with n > 0) now errors when more than one isolated global install would be involved, since each install has its own lockfile and merging their transitive trees would be incoherent. When the request can be narrowed to a single install group, the regular `list` flow is used and the full dependency tree is shown.

View File

@@ -2,7 +2,8 @@ import { FILTERING, OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/cli.common-cli-opti
import { docsUrl } from '@pnpm/cli.utils'
import { type Config, type ConfigContext, types as allTypes } from '@pnpm/config.reader'
import { list, listForPackages } from '@pnpm/deps.inspection.list'
import { listGlobalPackages } from '@pnpm/global.commands'
import { PnpmError } from '@pnpm/error'
import { findGlobalInstallDirs, listGlobalPackages } from '@pnpm/global.commands'
import type { Finder, IncludedDependencies } from '@pnpm/types'
import { pick } from 'ramda'
import { renderHelp } from 'render-help'
@@ -112,11 +113,53 @@ export async function handler (
opts: ListCommandOptions,
params: string[]
): Promise<string> {
if (opts.global && opts.globalPkgDir) {
return listGlobalPackages(opts.globalPkgDir, params)
}
const include = computeInclude(opts)
const depth = opts.cliOptions?.['depth'] ?? 0
if (opts.global && opts.globalPkgDir) {
if (depth > 0) {
const allInstallDirs = findGlobalInstallDirs(opts.globalPkgDir, [])
if (allInstallDirs.length === 1) {
// Single global install: delegate with params unchanged so
// listForPackages can search across the whole tree (including
// transitive deps), matching regular `pnpm ls` semantics.
return render([allInstallDirs[0]], params, {
...opts,
depth,
include,
lockfileDir: allInstallDirs[0],
checkWantedLockfileOnly: opts.lockfileOnly,
onlyProjects: opts.cliOptions?.['only-projects'] ?? opts.onlyProjects,
})
}
// Multiple installs — try to narrow to a single one via params,
// matching against top-level aliases of each install group.
const matchingInstallDirs = findGlobalInstallDirs(opts.globalPkgDir, params)
if (matchingInstallDirs.length > 1 || (matchingInstallDirs.length === 0 && allInstallDirs.length > 0)) {
throw new PnpmError('GLOBAL_LS_DEPTH_NOT_SUPPORTED',
'Cannot list a merged dependency tree across multiple global packages. ' +
'Each global package is installed in an isolated directory with its own lockfile, ' +
'so transitive dependencies cannot be coherently merged. ' +
'Filter to a single global package by its top-level name, or omit --depth.')
}
if (matchingInstallDirs.length === 1) {
// Drop params: they served their purpose of narrowing to a single
// install group. Passing them through to `render` would activate
// search semantics, which prune the matched package's children.
return render([matchingInstallDirs[0]], [], {
...opts,
depth,
include,
lockfileDir: matchingInstallDirs[0],
checkWantedLockfileOnly: opts.lockfileOnly,
onlyProjects: opts.cliOptions?.['only-projects'] ?? opts.onlyProjects,
})
}
}
return listGlobalPackages(opts.globalPkgDir, params, {
long: opts.long,
reportAs: determineReportAs(opts),
})
}
if (opts.recursive && (opts.selectedProjectsGraph != null)) {
const pkgs = Object.values(opts.selectedProjectsGraph).map((wsPkg) => wsPkg.package)
return listRecursive(pkgs, params, { ...opts, depth, include, checkWantedLockfileOnly: opts.lockfileOnly, onlyProjects: opts.cliOptions?.['only-projects'] ?? opts.onlyProjects })

View File

@@ -39,6 +39,7 @@
"@pnpm/cli.utils": "workspace:*",
"@pnpm/config.matcher": "workspace:*",
"@pnpm/config.reader": "workspace:*",
"@pnpm/deps.inspection.list": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/global.packages": "workspace:*",
"@pnpm/installing.deps-installer": "workspace:*",

View File

@@ -3,4 +3,4 @@ export { type GlobalAddOptions, handleGlobalAdd } from './globalAdd.js'
export { handleGlobalRemove } from './globalRemove.js'
export { type GlobalUpdateOptions, handleGlobalUpdate } from './globalUpdate.js'
export { installGlobalPackages, type InstallGlobalPackagesOptions } from './installGlobalPackages.js'
export { listGlobalPackages } from './listGlobalPackages.js'
export { findGlobalInstallDirs, listGlobalPackages } from './listGlobalPackages.js'

View File

@@ -1,24 +1,91 @@
import path from 'node:path'
import { createMatcher } from '@pnpm/config.matcher'
import { type DependencyNode, renderJson, renderParseable, renderTree } from '@pnpm/deps.inspection.list'
import {
getGlobalPackageDetails,
scanGlobalPackages,
} from '@pnpm/global.packages'
import { lexCompare } from '@pnpm/util.lex-comparator'
export async function listGlobalPackages (globalPkgDir: string, params: string[]): Promise<string> {
export function findGlobalInstallDirs (globalPkgDir: string, params: string[]): string[] {
const packages = scanGlobalPackages(globalPkgDir)
const matches = params.length > 0 ? createMatcher(params) : () => true
const installDirs = new Set<string>()
for (const pkg of packages) {
for (const alias of Object.keys(pkg.dependencies)) {
if (matches(alias)) {
installDirs.add(pkg.installDir)
break
}
}
}
return [...installDirs]
}
export interface ListGlobalPackagesOptions {
long?: boolean
reportAs?: 'parseable' | 'tree' | 'json'
}
export async function listGlobalPackages (
globalPkgDir: string,
params: string[],
opts: ListGlobalPackagesOptions = {}
): Promise<string> {
const reportAs = opts.reportAs ?? 'tree'
const long = opts.long ?? false
const packages = scanGlobalPackages(globalPkgDir)
const allDetails = await Promise.all(packages.map((pkg) => getGlobalPackageDetails(pkg)))
const matches = params.length > 0 ? createMatcher(params) : () => true
const lines: string[] = []
for (const installed of allDetails.flat()) {
if (!matches(installed.alias)) continue
lines.push(`${installed.alias}@${installed.version}`)
const dependencies: DependencyNode[] = []
for (let i = 0; i < packages.length; i++) {
const installDir = packages[i].installDir
for (const installed of allDetails[i]) {
if (!matches(installed.alias)) continue
dependencies.push({
alias: installed.alias,
name: installed.manifest.name,
version: installed.version,
path: path.join(installDir, 'node_modules', installed.alias),
isPeer: false,
isSkipped: false,
isMissing: false,
})
}
}
if (lines.length === 0) {
dependencies.sort((a, b) => lexCompare(a.alias, b.alias))
if (dependencies.length === 0) {
if (reportAs === 'json') {
return JSON.stringify([{ path: globalPkgDir, private: true, dependencies: {} }], null, 2)
}
if (reportAs === 'parseable') {
return globalPkgDir
}
return params.length > 0
? 'No matching global packages found'
: 'No global packages found'
}
lines.sort(lexCompare)
return lines.join('\n')
const hierarchy = [{
path: globalPkgDir,
private: true,
dependencies,
}]
switch (reportAs) {
case 'json':
return renderJson(hierarchy, { depth: 0, long, search: false })
case 'parseable':
return renderParseable(hierarchy, { depth: 0, long, alwaysPrintRootPackage: true, search: false })
case 'tree':
return renderTree(hierarchy, {
alwaysPrintRootPackage: false,
depth: 0,
long,
search: false,
showExtraneous: false,
})
}
}

View File

@@ -0,0 +1,93 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { describe, expect, it } from '@jest/globals'
import { listGlobalPackages } from '@pnpm/global.commands'
import { symlinkDirSync } from 'symlink-dir'
function makeTempDir (): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-test-'))
}
function createGlobalPackage (
globalDir: string,
opts: { alias: string, name?: string, version?: string }
): void {
const pkgName = opts.name ?? opts.alias
const version = opts.version ?? '1.0.0'
const installDir = makeTempDir()
const depDir = path.join(installDir, 'node_modules', opts.alias)
fs.mkdirSync(depDir, { recursive: true })
fs.writeFileSync(
path.join(installDir, 'package.json'),
JSON.stringify({ dependencies: { [opts.alias]: version } })
)
fs.writeFileSync(
path.join(depDir, 'package.json'),
JSON.stringify({ name: pkgName, version })
)
const safeAlias = opts.alias.replace(/\//g, '-')
symlinkDirSync(installDir, path.join(globalDir, `fakehash-${safeAlias}`))
}
describe('listGlobalPackages', () => {
it('outputs valid JSON when reportAs=json', async () => {
const globalDir = makeTempDir()
createGlobalPackage(globalDir, { alias: 'foo', version: '1.2.3' })
createGlobalPackage(globalDir, { alias: 'bar', version: '4.5.6' })
const out = await listGlobalPackages(globalDir, [], { reportAs: 'json' })
const parsed = JSON.parse(out)
expect(Array.isArray(parsed)).toBe(true)
expect(parsed).toHaveLength(1)
expect(parsed[0].path).toBe(globalDir)
expect(parsed[0].private).toBe(true)
expect(parsed[0].dependencies).toBeDefined()
expect(parsed[0].dependencies.foo.version).toBe('1.2.3')
expect(parsed[0].dependencies.foo.from).toBe('foo')
expect(parsed[0].dependencies.bar.version).toBe('4.5.6')
})
it('outputs an empty-but-valid JSON array element when no packages installed', async () => {
const globalDir = makeTempDir()
const out = await listGlobalPackages(globalDir, [], { reportAs: 'json' })
const parsed = JSON.parse(out)
expect(Array.isArray(parsed)).toBe(true)
expect(parsed).toHaveLength(1)
expect(parsed[0].path).toBe(globalDir)
expect(parsed[0].private).toBe(true)
expect(parsed[0].dependencies).toEqual({})
})
it('outputs paths when reportAs=parseable', async () => {
const globalDir = makeTempDir()
createGlobalPackage(globalDir, { alias: 'foo', version: '1.2.3' })
const out = await listGlobalPackages(globalDir, [], { reportAs: 'parseable' })
const lines = out.split('\n')
expect(lines[0]).toBe(globalDir)
expect(lines.some((line) => line.endsWith(path.join('node_modules', 'foo')))).toBe(true)
})
it('outputs plain text by default', async () => {
const globalDir = makeTempDir()
createGlobalPackage(globalDir, { alias: 'foo', version: '1.2.3' })
createGlobalPackage(globalDir, { alias: 'bar', version: '4.5.6' })
const out = await listGlobalPackages(globalDir, [])
expect(out).toContain('foo@1.2.3')
expect(out).toContain('bar@4.5.6')
})
it('filters by parameters', async () => {
const globalDir = makeTempDir()
createGlobalPackage(globalDir, { alias: 'foo', version: '1.2.3' })
createGlobalPackage(globalDir, { alias: 'bar', version: '4.5.6' })
const out = await listGlobalPackages(globalDir, ['foo'], { reportAs: 'json' })
const parsed = JSON.parse(out)
expect(Object.keys(parsed[0].dependencies)).toEqual(['foo'])
})
})

View File

@@ -36,6 +36,9 @@
{
"path": "../../core/types"
},
{
"path": "../../deps/inspection/list"
},
{
"path": "../../installing/deps-installer"
},

3
pnpm-lock.yaml generated
View File

@@ -4867,6 +4867,9 @@ importers:
'@pnpm/config.reader':
specifier: workspace:*
version: link:../../config/reader
'@pnpm/deps.inspection.list':
specifier: workspace:*
version: link:../../deps/inspection/list
'@pnpm/error':
specifier: workspace:*
version: link:../../core/error

View File

@@ -468,6 +468,116 @@ test('global add from a local directory using "."', () => {
expect(fs.existsSync(path.join(pnpmHome, 'bin', 'my-local-tool'))).toBeTruthy()
})
test('global ls --json outputs valid JSON (#11440)', async () => {
prepare()
const global = path.resolve('..', 'global')
const pnpmHome = path.join(global, 'pnpm')
fs.mkdirSync(global)
const env = { [PATH_NAME]: path.join(pnpmHome, 'bin'), PNPM_HOME: pnpmHome, XDG_DATA_HOME: global }
await execPnpm(['add', '--global', 'is-positive@1.0.0'], { env })
await execPnpm(['add', '--global', 'is-negative@1.0.0'], { env })
const { stdout } = execPnpmSync(['ls', '-g', '--json'], { env, expectSuccess: true })
const parsed = JSON.parse(stdout.toString())
expect(Array.isArray(parsed)).toBe(true)
expect(parsed).toHaveLength(1)
expect(parsed[0].dependencies['is-positive'].version).toBe('1.0.0')
expect(parsed[0].dependencies['is-negative'].version).toBe('1.0.0')
})
test('global ls --parseable outputs paths', async () => {
prepare()
const global = path.resolve('..', 'global')
const pnpmHome = path.join(global, 'pnpm')
fs.mkdirSync(global)
const env = { [PATH_NAME]: path.join(pnpmHome, 'bin'), PNPM_HOME: pnpmHome, XDG_DATA_HOME: global }
await execPnpm(['add', '--global', 'is-positive@1.0.0'], { env })
const { stdout } = execPnpmSync(['ls', '-g', '--parseable'], { env, expectSuccess: true })
const lines = stdout.toString().trim().split(/\r?\n/).map((line) => line.trim())
expect(lines.length).toBeGreaterThanOrEqual(2)
expect(lines.some((line) => line.endsWith(path.join('node_modules', 'is-positive')))).toBe(true)
})
test('global ls --depth>0 errors across multiple isolated installs', async () => {
prepare()
const global = path.resolve('..', 'global')
const pnpmHome = path.join(global, 'pnpm')
fs.mkdirSync(global)
const env = { [PATH_NAME]: path.join(pnpmHome, 'bin'), PNPM_HOME: pnpmHome, XDG_DATA_HOME: global }
await execPnpm(['add', '--global', 'is-positive@1.0.0'], { env })
await execPnpm(['add', '--global', 'is-negative@1.0.0'], { env })
const result = execPnpmSync(['ls', '-g', '--depth=1'], { env })
expect(result.status).not.toBe(0)
expect(result.stdout.toString() + result.stderr.toString()).toContain('GLOBAL_LS_DEPTH_NOT_SUPPORTED')
})
test('global ls --depth>0 shows the full dependency tree of a single global install', async () => {
prepare()
const global = path.resolve('..', 'global')
const pnpmHome = path.join(global, 'pnpm')
fs.mkdirSync(global)
const env = { [PATH_NAME]: path.join(pnpmHome, 'bin'), PNPM_HOME: pnpmHome, XDG_DATA_HOME: global }
await execPnpm(['add', '--global', '@pnpm.e2e/pkg-with-1-dep@100.0.0'], { env })
const { stdout } = execPnpmSync(['ls', '-g', '--depth=1', '--json'], { env, expectSuccess: true })
const parsed = JSON.parse(stdout.toString())
expect(Array.isArray(parsed)).toBe(true)
expect(parsed).toHaveLength(1)
const pkg = parsed[0].dependencies['@pnpm.e2e/pkg-with-1-dep']
expect(pkg.version).toBe('100.0.0')
expect(pkg.dependencies['@pnpm.e2e/dep-of-pkg-with-1-dep']).toBeDefined()
})
test('global ls <transitive> --depth>0 against a single global install reports the match', async () => {
prepare()
const global = path.resolve('..', 'global')
const pnpmHome = path.join(global, 'pnpm')
fs.mkdirSync(global)
const env = { [PATH_NAME]: path.join(pnpmHome, 'bin'), PNPM_HOME: pnpmHome, XDG_DATA_HOME: global }
await execPnpm(['add', '--global', '@pnpm.e2e/pkg-with-1-dep@100.0.0'], { env })
const { stdout } = execPnpmSync(['ls', '-g', '@pnpm.e2e/dep-of-pkg-with-1-dep', '--depth=1', '--json'], { env, expectSuccess: true })
const parsed = JSON.parse(stdout.toString())
expect(Array.isArray(parsed)).toBe(true)
expect(parsed).toHaveLength(1)
const pkg = parsed[0].dependencies['@pnpm.e2e/pkg-with-1-dep']
expect(pkg.dependencies['@pnpm.e2e/dep-of-pkg-with-1-dep']).toBeDefined()
})
test('global ls <pkg> --depth>0 narrows to the install dir containing <pkg> and shows its tree', async () => {
prepare()
const global = path.resolve('..', 'global')
const pnpmHome = path.join(global, 'pnpm')
fs.mkdirSync(global)
const env = { [PATH_NAME]: path.join(pnpmHome, 'bin'), PNPM_HOME: pnpmHome, XDG_DATA_HOME: global }
await execPnpm(['add', '--global', '@pnpm.e2e/pkg-with-1-dep@100.0.0'], { env })
await execPnpm(['add', '--global', 'is-negative@1.0.0'], { env })
// The filter narrows depth>0 down to the single install group containing
// pkg-with-1-dep, so its transitive dep should be visible.
const { stdout } = execPnpmSync(['ls', '-g', '@pnpm.e2e/pkg-with-1-dep', '--depth=1', '--json'], { env, expectSuccess: true })
const parsed = JSON.parse(stdout.toString())
expect(Array.isArray(parsed)).toBe(true)
expect(parsed).toHaveLength(1)
const pkg = parsed[0].dependencies['@pnpm.e2e/pkg-with-1-dep']
expect(pkg.version).toBe('100.0.0')
expect(pkg.dependencies['@pnpm.e2e/dep-of-pkg-with-1-dep']).toBeDefined()
})
test('global remove deletes install group and bin shims', async () => {
prepare()
const global = path.resolve('..', 'global')