diff --git a/.changeset/global-ls-json-parseable.md b/.changeset/global-ls-json-parseable.md new file mode 100644 index 0000000000..2e07798bff --- /dev/null +++ b/.changeset/global-ls-json-parseable.md @@ -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=` (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. diff --git a/deps/inspection/commands/src/listing/list.ts b/deps/inspection/commands/src/listing/list.ts index 9fc6719fa4..13431a7a2e 100644 --- a/deps/inspection/commands/src/listing/list.ts +++ b/deps/inspection/commands/src/listing/list.ts @@ -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 { - 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 }) diff --git a/global/commands/package.json b/global/commands/package.json index 60258dcab0..f405d39354 100644 --- a/global/commands/package.json +++ b/global/commands/package.json @@ -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:*", diff --git a/global/commands/src/index.ts b/global/commands/src/index.ts index bf2aba625b..d2d6f61a29 100644 --- a/global/commands/src/index.ts +++ b/global/commands/src/index.ts @@ -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' diff --git a/global/commands/src/listGlobalPackages.ts b/global/commands/src/listGlobalPackages.ts index 414f256d5e..3ded2feb57 100644 --- a/global/commands/src/listGlobalPackages.ts +++ b/global/commands/src/listGlobalPackages.ts @@ -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 { +export function findGlobalInstallDirs (globalPkgDir: string, params: string[]): string[] { + const packages = scanGlobalPackages(globalPkgDir) + const matches = params.length > 0 ? createMatcher(params) : () => true + const installDirs = new Set() + 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 { + 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, + }) + } } diff --git a/global/commands/test/listGlobalPackages.test.ts b/global/commands/test/listGlobalPackages.test.ts new file mode 100644 index 0000000000..c55f8f69ce --- /dev/null +++ b/global/commands/test/listGlobalPackages.test.ts @@ -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']) + }) +}) diff --git a/global/commands/tsconfig.json b/global/commands/tsconfig.json index d5e158d4d6..b8b0d07a06 100644 --- a/global/commands/tsconfig.json +++ b/global/commands/tsconfig.json @@ -36,6 +36,9 @@ { "path": "../../core/types" }, + { + "path": "../../deps/inspection/list" + }, { "path": "../../installing/deps-installer" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c7eb54c87..724b95028e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/pnpm/test/install/global.ts b/pnpm/test/install/global.ts index 292420e67b..ef82b267c1 100644 --- a/pnpm/test/install/global.ts +++ b/pnpm/test/install/global.ts @@ -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 --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 --depth>0 narrows to the install dir containing 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')