Skip to content

Commit 72629fc

Browse files
committed
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.
1 parent 2b8932d commit 72629fc

9 files changed

Lines changed: 341 additions & 13 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@pnpm/global.commands": patch
3+
"pnpm": patch
4+
---
5+
6+
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).
7+
8+
`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.

deps/inspection/commands/src/listing/list.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { FILTERING, OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/cli.common-cli-opti
22
import { docsUrl } from '@pnpm/cli.utils'
33
import { type Config, type ConfigContext, types as allTypes } from '@pnpm/config.reader'
44
import { list, listForPackages } from '@pnpm/deps.inspection.list'
5-
import { listGlobalPackages } from '@pnpm/global.commands'
5+
import { PnpmError } from '@pnpm/error'
6+
import { findGlobalInstallDirs, listGlobalPackages } from '@pnpm/global.commands'
67
import type { Finder, IncludedDependencies } from '@pnpm/types'
78
import { pick } from 'ramda'
89
import { renderHelp } from 'render-help'
@@ -112,11 +113,53 @@ export async function handler (
112113
opts: ListCommandOptions,
113114
params: string[]
114115
): Promise<string> {
115-
if (opts.global && opts.globalPkgDir) {
116-
return listGlobalPackages(opts.globalPkgDir, params)
117-
}
118116
const include = computeInclude(opts)
119117
const depth = opts.cliOptions?.['depth'] ?? 0
118+
if (opts.global && opts.globalPkgDir) {
119+
if (depth > 0) {
120+
const allInstallDirs = findGlobalInstallDirs(opts.globalPkgDir, [])
121+
if (allInstallDirs.length === 1) {
122+
// Single global install: delegate with params unchanged so
123+
// listForPackages can search across the whole tree (including
124+
// transitive deps), matching regular `pnpm ls` semantics.
125+
return render([allInstallDirs[0]], params, {
126+
...opts,
127+
depth,
128+
include,
129+
lockfileDir: allInstallDirs[0],
130+
checkWantedLockfileOnly: opts.lockfileOnly,
131+
onlyProjects: opts.cliOptions?.['only-projects'] ?? opts.onlyProjects,
132+
})
133+
}
134+
// Multiple installs — try to narrow to a single one via params,
135+
// matching against top-level aliases of each install group.
136+
const matchingInstallDirs = findGlobalInstallDirs(opts.globalPkgDir, params)
137+
if (matchingInstallDirs.length > 1 || (matchingInstallDirs.length === 0 && allInstallDirs.length > 0)) {
138+
throw new PnpmError('GLOBAL_LS_DEPTH_NOT_SUPPORTED',
139+
'Cannot list a merged dependency tree across multiple global packages. ' +
140+
'Each global package is installed in an isolated directory with its own lockfile, ' +
141+
'so transitive dependencies cannot be coherently merged. ' +
142+
'Filter to a single global package by its top-level name, or omit --depth.')
143+
}
144+
if (matchingInstallDirs.length === 1) {
145+
// Drop params: they served their purpose of narrowing to a single
146+
// install group. Passing them through to `render` would activate
147+
// search semantics, which prune the matched package's children.
148+
return render([matchingInstallDirs[0]], [], {
149+
...opts,
150+
depth,
151+
include,
152+
lockfileDir: matchingInstallDirs[0],
153+
checkWantedLockfileOnly: opts.lockfileOnly,
154+
onlyProjects: opts.cliOptions?.['only-projects'] ?? opts.onlyProjects,
155+
})
156+
}
157+
}
158+
return listGlobalPackages(opts.globalPkgDir, params, {
159+
long: opts.long,
160+
reportAs: determineReportAs(opts),
161+
})
162+
}
120163
if (opts.recursive && (opts.selectedProjectsGraph != null)) {
121164
const pkgs = Object.values(opts.selectedProjectsGraph).map((wsPkg) => wsPkg.package)
122165
return listRecursive(pkgs, params, { ...opts, depth, include, checkWantedLockfileOnly: opts.lockfileOnly, onlyProjects: opts.cliOptions?.['only-projects'] ?? opts.onlyProjects })

global/commands/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"@pnpm/cli.utils": "workspace:*",
4040
"@pnpm/config.matcher": "workspace:*",
4141
"@pnpm/config.reader": "workspace:*",
42+
"@pnpm/deps.inspection.list": "workspace:*",
4243
"@pnpm/error": "workspace:*",
4344
"@pnpm/global.packages": "workspace:*",
4445
"@pnpm/installing.deps-installer": "workspace:*",

global/commands/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ export { type GlobalAddOptions, handleGlobalAdd } from './globalAdd.js'
33
export { handleGlobalRemove } from './globalRemove.js'
44
export { type GlobalUpdateOptions, handleGlobalUpdate } from './globalUpdate.js'
55
export { installGlobalPackages, type InstallGlobalPackagesOptions } from './installGlobalPackages.js'
6-
export { listGlobalPackages } from './listGlobalPackages.js'
6+
export { findGlobalInstallDirs, listGlobalPackages } from './listGlobalPackages.js'
Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,91 @@
1+
import path from 'node:path'
2+
13
import { createMatcher } from '@pnpm/config.matcher'
4+
import { type DependencyNode, renderJson, renderParseable, renderTree } from '@pnpm/deps.inspection.list'
25
import {
36
getGlobalPackageDetails,
47
scanGlobalPackages,
58
} from '@pnpm/global.packages'
69
import { lexCompare } from '@pnpm/util.lex-comparator'
710

8-
export async function listGlobalPackages (globalPkgDir: string, params: string[]): Promise<string> {
11+
export function findGlobalInstallDirs (globalPkgDir: string, params: string[]): string[] {
12+
const packages = scanGlobalPackages(globalPkgDir)
13+
const matches = params.length > 0 ? createMatcher(params) : () => true
14+
const installDirs = new Set<string>()
15+
for (const pkg of packages) {
16+
for (const alias of Object.keys(pkg.dependencies)) {
17+
if (matches(alias)) {
18+
installDirs.add(pkg.installDir)
19+
break
20+
}
21+
}
22+
}
23+
return [...installDirs]
24+
}
25+
26+
export interface ListGlobalPackagesOptions {
27+
long?: boolean
28+
reportAs?: 'parseable' | 'tree' | 'json'
29+
}
30+
31+
export async function listGlobalPackages (
32+
globalPkgDir: string,
33+
params: string[],
34+
opts: ListGlobalPackagesOptions = {}
35+
): Promise<string> {
36+
const reportAs = opts.reportAs ?? 'tree'
37+
const long = opts.long ?? false
938
const packages = scanGlobalPackages(globalPkgDir)
1039
const allDetails = await Promise.all(packages.map((pkg) => getGlobalPackageDetails(pkg)))
1140
const matches = params.length > 0 ? createMatcher(params) : () => true
12-
const lines: string[] = []
13-
for (const installed of allDetails.flat()) {
14-
if (!matches(installed.alias)) continue
15-
lines.push(`${installed.alias}@${installed.version}`)
41+
const dependencies: DependencyNode[] = []
42+
for (let i = 0; i < packages.length; i++) {
43+
const installDir = packages[i].installDir
44+
for (const installed of allDetails[i]) {
45+
if (!matches(installed.alias)) continue
46+
dependencies.push({
47+
alias: installed.alias,
48+
name: installed.manifest.name,
49+
version: installed.version,
50+
path: path.join(installDir, 'node_modules', installed.alias),
51+
isPeer: false,
52+
isSkipped: false,
53+
isMissing: false,
54+
})
55+
}
1656
}
17-
if (lines.length === 0) {
57+
dependencies.sort((a, b) => lexCompare(a.alias, b.alias))
58+
59+
if (dependencies.length === 0) {
60+
if (reportAs === 'json') {
61+
return JSON.stringify([{ path: globalPkgDir, private: true, dependencies: {} }], null, 2)
62+
}
63+
if (reportAs === 'parseable') {
64+
return globalPkgDir
65+
}
1866
return params.length > 0
1967
? 'No matching global packages found'
2068
: 'No global packages found'
2169
}
22-
lines.sort(lexCompare)
23-
return lines.join('\n')
70+
71+
const hierarchy = [{
72+
path: globalPkgDir,
73+
private: true,
74+
dependencies,
75+
}]
76+
77+
switch (reportAs) {
78+
case 'json':
79+
return renderJson(hierarchy, { depth: 0, long, search: false })
80+
case 'parseable':
81+
return renderParseable(hierarchy, { depth: 0, long, alwaysPrintRootPackage: true, search: false })
82+
case 'tree':
83+
return renderTree(hierarchy, {
84+
alwaysPrintRootPackage: false,
85+
depth: 0,
86+
long,
87+
search: false,
88+
showExtraneous: false,
89+
})
90+
}
2491
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import fs from 'node:fs'
2+
import os from 'node:os'
3+
import path from 'node:path'
4+
5+
import { describe, expect, it } from '@jest/globals'
6+
import { listGlobalPackages } from '@pnpm/global.commands'
7+
import { symlinkDirSync } from 'symlink-dir'
8+
9+
function makeTempDir (): string {
10+
return fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-test-'))
11+
}
12+
13+
function createGlobalPackage (
14+
globalDir: string,
15+
opts: { alias: string, name?: string, version?: string }
16+
): void {
17+
const pkgName = opts.name ?? opts.alias
18+
const version = opts.version ?? '1.0.0'
19+
const installDir = makeTempDir()
20+
const depDir = path.join(installDir, 'node_modules', opts.alias)
21+
fs.mkdirSync(depDir, { recursive: true })
22+
fs.writeFileSync(
23+
path.join(installDir, 'package.json'),
24+
JSON.stringify({ dependencies: { [opts.alias]: version } })
25+
)
26+
fs.writeFileSync(
27+
path.join(depDir, 'package.json'),
28+
JSON.stringify({ name: pkgName, version })
29+
)
30+
const safeAlias = opts.alias.replace(/\//g, '-')
31+
symlinkDirSync(installDir, path.join(globalDir, `fakehash-${safeAlias}`))
32+
}
33+
34+
describe('listGlobalPackages', () => {
35+
it('outputs valid JSON when reportAs=json', async () => {
36+
const globalDir = makeTempDir()
37+
createGlobalPackage(globalDir, { alias: 'foo', version: '1.2.3' })
38+
createGlobalPackage(globalDir, { alias: 'bar', version: '4.5.6' })
39+
40+
const out = await listGlobalPackages(globalDir, [], { reportAs: 'json' })
41+
const parsed = JSON.parse(out)
42+
expect(Array.isArray(parsed)).toBe(true)
43+
expect(parsed).toHaveLength(1)
44+
expect(parsed[0].path).toBe(globalDir)
45+
expect(parsed[0].private).toBe(true)
46+
expect(parsed[0].dependencies).toBeDefined()
47+
expect(parsed[0].dependencies.foo.version).toBe('1.2.3')
48+
expect(parsed[0].dependencies.foo.from).toBe('foo')
49+
expect(parsed[0].dependencies.bar.version).toBe('4.5.6')
50+
})
51+
52+
it('outputs an empty-but-valid JSON array element when no packages installed', async () => {
53+
const globalDir = makeTempDir()
54+
55+
const out = await listGlobalPackages(globalDir, [], { reportAs: 'json' })
56+
const parsed = JSON.parse(out)
57+
expect(Array.isArray(parsed)).toBe(true)
58+
expect(parsed).toHaveLength(1)
59+
expect(parsed[0].path).toBe(globalDir)
60+
expect(parsed[0].private).toBe(true)
61+
expect(parsed[0].dependencies).toEqual({})
62+
})
63+
64+
it('outputs paths when reportAs=parseable', async () => {
65+
const globalDir = makeTempDir()
66+
createGlobalPackage(globalDir, { alias: 'foo', version: '1.2.3' })
67+
68+
const out = await listGlobalPackages(globalDir, [], { reportAs: 'parseable' })
69+
const lines = out.split('\n')
70+
expect(lines[0]).toBe(globalDir)
71+
expect(lines.some((line) => line.endsWith(path.join('node_modules', 'foo')))).toBe(true)
72+
})
73+
74+
it('outputs plain text by default', async () => {
75+
const globalDir = makeTempDir()
76+
createGlobalPackage(globalDir, { alias: 'foo', version: '1.2.3' })
77+
createGlobalPackage(globalDir, { alias: 'bar', version: '4.5.6' })
78+
79+
const out = await listGlobalPackages(globalDir, [])
80+
expect(out).toContain('foo@1.2.3')
81+
expect(out).toContain('bar@4.5.6')
82+
})
83+
84+
it('filters by parameters', async () => {
85+
const globalDir = makeTempDir()
86+
createGlobalPackage(globalDir, { alias: 'foo', version: '1.2.3' })
87+
createGlobalPackage(globalDir, { alias: 'bar', version: '4.5.6' })
88+
89+
const out = await listGlobalPackages(globalDir, ['foo'], { reportAs: 'json' })
90+
const parsed = JSON.parse(out)
91+
expect(Object.keys(parsed[0].dependencies)).toEqual(['foo'])
92+
})
93+
})

global/commands/tsconfig.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@
3636
{
3737
"path": "../../core/types"
3838
},
39+
{
40+
"path": "../../deps/inspection/list"
41+
},
3942
{
4043
"path": "../../installing/deps-installer"
4144
},

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)