From 35d5318ed7ed4d09fc634715d69b97bfd3f42e36 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Wed, 4 Mar 2026 21:52:53 +0100 Subject: [PATCH] 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 * fix: resolve relative local selectors against opts.dir instead of process.cwd() This fixes `pnpm -C -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 * 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 * 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 --------- Co-authored-by: Claude Opus 4.6 --- config/config/src/index.ts | 9 +++---- global/commands/src/globalAdd.ts | 20 ++++++++++++++ pnpm/src/cmd/root.ts | 3 ++- pnpm/test/install/global.ts | 45 ++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 7 deletions(-) diff --git a/config/config/src/index.ts b/config/config/src/index.ts index 76808e04c5..ce309343eb 100644 --- a/config/config/src/index.ts +++ b/config/config/src/index.ts @@ -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 diff --git a/global/commands/src/globalAdd.ts b/global/commands/src/globalAdd.ts index 2e9dd2d577..095a88ddd0 100644 --- a/global/commands/src/globalAdd.ts +++ b/global/commands/src/globalAdd.ts @@ -37,6 +37,10 @@ export async function handleGlobalAdd ( opts: GlobalAddOptions, params: string[] ): Promise { + // 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, diff --git a/pnpm/src/cmd/root.ts b/pnpm/src/cmd/root.ts index ee037cd40f..7cdac30448 100644 --- a/pnpm/src/cmd/root.ts +++ b/pnpm/src/cmd/root.ts @@ -39,10 +39,11 @@ export async function handler ( opts: { dir: string global?: boolean + globalPkgDir?: string } ): Promise { if (opts.global) { - return `${opts.dir}\n` + return `${opts.globalPkgDir}\n` } return `${path.join(opts.dir, 'node_modules')}\n` } diff --git a/pnpm/test/install/global.ts b/pnpm/test/install/global.ts index 1aeb2ba43f..0cc5b1ac74 100644 --- a/pnpm/test/install/global.ts +++ b/pnpm/test/install/global.ts @@ -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')