fix: resolve relative path params in global add against CWD (#10848)

* fix: resolve relative path params in global add against CWD

When running `pnpm -g add .`, the "." was resolved relative to the
temporary install directory instead of the user's working directory.
This happened because handleGlobalAdd switches opts.dir to a fresh
temp directory before the dependency selectors are resolved.

Now relative path params (., ./foo, ../bar, file:./foo, link:../bar)
are resolved to absolute paths before the directory is switched.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: resolve relative local selectors against opts.dir instead of process.cwd()

This fixes `pnpm -C <dir> -g add .` where the relative selector would
incorrectly resolve against process.cwd() instead of the user's intended
directory. Also adds test coverage for file: and link: prefixed selectors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(config): stop overwriting dir to globalPkgDir for global commands

Previously, `pnpmConfig.dir` was set to `globalPkgDir` when `--global`
was used. This caused `opts.dir` to point to the global packages
directory instead of the user's CWD, breaking `pnpm -g add .` because
relative paths resolved against the wrong directory.

Now `pnpmConfig.dir` is always set to the user's CWD. Global command
handlers already use `opts.globalPkgDir` where they need the global
packages directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use globalPkgDir in pnpm root -g handler

The root command handler was using opts.dir which no longer points to
the global packages directory. Use opts.globalPkgDir instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Zoltan Kochan
2026-03-04 21:52:53 +01:00
committed by GitHub
parent 00e7787097
commit 35d5318ed7
4 changed files with 70 additions and 7 deletions

View File

@@ -334,9 +334,9 @@ export async function getConfig (opts: {
globalDirRoot = path.join(pnpmConfig.pnpmHomeDir, 'global')
}
pnpmConfig.globalPkgDir = path.join(globalDirRoot, GLOBAL_LAYOUT_VERSION)
pnpmConfig.dir = cwd
if (cliOptions['global']) {
delete pnpmConfig.workspaceDir
pnpmConfig.dir = pnpmConfig.globalPkgDir
pnpmConfig.bin = npmConfig.get('global-bin-dir') ?? env.PNPM_HOME
if (pnpmConfig.bin) {
fs.mkdirSync(pnpmConfig.bin, { recursive: true })
@@ -382,11 +382,8 @@ export async function getConfig (opts: {
if (pnpmConfig.enableGlobalVirtualStore == null) {
pnpmConfig.enableGlobalVirtualStore = true
}
} else {
pnpmConfig.dir = cwd
if (!pnpmConfig.bin) {
pnpmConfig.bin = path.join(pnpmConfig.dir, 'node_modules', '.bin')
}
} else if (!pnpmConfig.bin) {
pnpmConfig.bin = path.join(pnpmConfig.dir, 'node_modules', '.bin')
}
pnpmConfig.packageManager = packageManager

View File

@@ -37,6 +37,10 @@ export async function handleGlobalAdd (
opts: GlobalAddOptions,
params: string[]
): Promise<void> {
// Resolve relative path selectors to absolute paths before the working
// directory is changed to the global install dir, otherwise "." or
// "./foo" would resolve against the temp install directory.
params = params.map((param) => resolveLocalParam(param, opts.dir))
const globalDir = opts.globalPkgDir!
const globalBinDir = opts.bin!
cleanOrphanedInstallDirs(globalDir)
@@ -137,6 +141,22 @@ export async function handleGlobalAdd (
await linkBinsOfPackages(pkgs, globalBinDir, { excludeBins: binsToSkip })
}
function resolveLocalParam (param: string, baseDir: string): string {
for (const prefix of ['file:', 'link:']) {
if (param.startsWith(prefix)) {
const rest = param.slice(prefix.length)
if (rest.startsWith('.')) {
return prefix + path.resolve(baseDir, rest)
}
return param
}
}
if (param.startsWith('.')) {
return path.resolve(baseDir, param)
}
return param
}
async function removeExistingGlobalInstalls (
globalDir: string,
globalBinDir: string,

View File

@@ -39,10 +39,11 @@ export async function handler (
opts: {
dir: string
global?: boolean
globalPkgDir?: string
}
): Promise<string> {
if (opts.global) {
return `${opts.dir}\n`
return `${opts.globalPkgDir}\n`
}
return `${path.join(opts.dir, 'node_modules')}\n`
}

View File

@@ -370,6 +370,51 @@ test('global add refuses to install when bin name conflicts with another global
expect(findGlobalPkg(globalPkgDir(pnpmHome), 'pkg-a')).toBeTruthy()
})
test('global add from a local directory using "."', () => {
prepare()
const global = path.resolve('..', 'global')
const pnpmHome = path.join(global, 'pnpm')
fs.mkdirSync(pnpmHome, { recursive: true })
// Create a local package with a bin
const localPkg = path.resolve('..', 'my-local-tool')
fs.mkdirSync(localPkg, { recursive: true })
fs.writeFileSync(path.join(localPkg, 'package.json'), JSON.stringify({
name: 'my-local-tool',
version: '1.0.0',
bin: { 'my-local-tool': './index.js' },
}))
fs.writeFileSync(path.join(localPkg, 'index.js'), '#!/usr/bin/env node\nconsole.log("hello")\n')
const env = {
[PATH_NAME]: pnpmHome,
PNPM_HOME: pnpmHome,
XDG_DATA_HOME: global,
pnpm_config_store_dir: path.resolve('..', 'store'),
}
// Install globally from within the package directory using "."
// This used to fail because "." was resolved relative to the temp install
// directory instead of the user's CWD.
execPnpmSync(['add', '-g', '.'], { cwd: localPkg, env, expectSuccess: true })
// Verify the package was installed globally
expect(findGlobalPkg(globalPkgDir(pnpmHome), 'my-local-tool')).toBeTruthy()
// Verify the bin was linked
expect(fs.existsSync(path.join(pnpmHome, 'my-local-tool'))).toBeTruthy()
// Install globally using a file: relative selector
execPnpmSync(['add', '-g', 'file:./'], { cwd: localPkg, env, expectSuccess: true })
expect(findGlobalPkg(globalPkgDir(pnpmHome), 'my-local-tool')).toBeTruthy()
expect(fs.existsSync(path.join(pnpmHome, 'my-local-tool'))).toBeTruthy()
// Install globally using a link: relative selector
execPnpmSync(['add', '-g', 'link:../my-local-tool'], { cwd: process.cwd(), env, expectSuccess: true })
expect(findGlobalPkg(globalPkgDir(pnpmHome), 'my-local-tool')).toBeTruthy()
expect(fs.existsSync(path.join(pnpmHome, 'my-local-tool'))).toBeTruthy()
})
test('global remove deletes install group and bin shims', async () => {
prepare()
const global = path.resolve('..', 'global')