From 164221c5b286ededb3da99f5b079ec2109e4141a Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Fri, 8 May 2026 20:12:24 +0200 Subject: [PATCH] fix: honor --prefix when resolving workspace dir (#11549) --- .changeset/prefix-workspace-resolution.md | 6 +++ cli/parse-cli-args/src/index.ts | 50 ++++++++++++++------ cli/parse-cli-args/test/index.ts | 57 +++++++++++++++++++++++ 3 files changed, 99 insertions(+), 14 deletions(-) create mode 100644 .changeset/prefix-workspace-resolution.md diff --git a/.changeset/prefix-workspace-resolution.md b/.changeset/prefix-workspace-resolution.md new file mode 100644 index 0000000000..32a7cdf9b2 --- /dev/null +++ b/.changeset/prefix-workspace-resolution.md @@ -0,0 +1,6 @@ +--- +"@pnpm/cli.parse-cli-args": patch +"pnpm": patch +--- + +Fixed `pnpm --prefix= install` overwriting the existing `pnpm-workspace.yaml` in `` with `set this to true or false` placeholders. The renamed `--prefix` option (which maps to `dir`) was not honored when locating the workspace root, so the workspace manifest's `allowBuilds` settings were not loaded into config and got clobbered when ignored builds were auto-populated [#11535](https://github.com/pnpm/pnpm/issues/11535). diff --git a/cli/parse-cli-args/src/index.ts b/cli/parse-cli-args/src/index.ts index 184a7c8e82..cacbb7b571 100644 --- a/cli/parse-cli-args/src/index.ts +++ b/cli/parse-cli-args/src/index.ts @@ -65,7 +65,7 @@ export async function parseCliArgs ( if (noptExploratoryResults['help']) { return { ...getParsedArgsForHelp(), - workspaceDir: await getWorkspaceDir(noptExploratoryResults), + workspaceDir: await getWorkspaceDir(noptExploratoryResults, opts.renamedOptions), } } if (noptExploratoryResults['version'] || noptExploratoryResults['v']) { @@ -79,7 +79,7 @@ export async function parseCliArgs ( params: noptExploratoryResults.argv.remain, unknownOptions: new Map(), fallbackCommandUsed: false, - workspaceDir: await getWorkspaceDir(noptExploratoryResults), + workspaceDir: await getWorkspaceDir(noptExploratoryResults, opts.renamedOptions), } } } @@ -186,6 +186,23 @@ export async function parseCliArgs ( const { argv: _, ...configOptions } = nopt({}, {}, configDotArgs, 0) Object.assign(options, configOptions) } + // Apply renamedOptions before workspace detection so `--prefix=foo` + // (renamed to `dir`) participates in finding the workspace root. + // Otherwise getWorkspaceDir falls back to process.cwd() and the + // workspace manifest at the prefix dir is missed (#11535). + // The canonical option wins if both are supplied (e.g. `--prefix=foo + // --dir=bar` keeps `dir=bar`); the alias is always dropped. + if (opts.renamedOptions != null) { + for (const [cliOption, optionValue] of Object.entries(options)) { + const target = opts.renamedOptions[cliOption] + if (target) { + if (!(target in options)) { + options[target] = optionValue + } + delete options[cliOption] + } + } + } const workspaceDir = await getWorkspaceDir(options) // For the run command, it's not clear whether --help should be passed to the @@ -197,15 +214,6 @@ export async function parseCliArgs ( } } - if (opts.renamedOptions != null) { - for (const [cliOption, optionValue] of Object.entries(options)) { - if (opts.renamedOptions[cliOption]) { - options[opts.renamedOptions[cliOption]] = optionValue - delete options[cliOption] - } - } - } - const params = argv.remain.slice(1) if (options['recursive'] !== true && (options['filter'] || options['filter-prod'] || recursiveCommandUsed)) { @@ -287,8 +295,22 @@ function getClosestOptionMatches (knownOptions: string[], option: string): strin }) } -async function getWorkspaceDir (parsedOpts: Record): Promise { +async function getWorkspaceDir ( + parsedOpts: Record, + renamedOptions?: Record +): Promise { if (parsedOpts['global'] || parsedOpts['ignore-workspace']) return undefined - const dir = parsedOpts['dir'] ?? process.cwd() - return findWorkspaceDir(dir as string) + // Look up dir, also honoring renamed options like `prefix → dir` so that + // `--prefix` works even on code paths that read parsedOpts before the + // rename loop has run (e.g. the --help/--version short-circuits). + let dir = parsedOpts['dir'] + if (dir == null && renamedOptions != null) { + for (const [from, to] of Object.entries(renamedOptions)) { + if (to === 'dir' && parsedOpts[from] != null) { + dir = parsedOpts[from] + break + } + } + } + return findWorkspaceDir((dir ?? process.cwd()) as string) } diff --git a/cli/parse-cli-args/test/index.ts b/cli/parse-cli-args/test/index.ts index 420d5af62d..451443ef2e 100644 --- a/cli/parse-cli-args/test/index.ts +++ b/cli/parse-cli-args/test/index.ts @@ -1,4 +1,6 @@ +import fs from 'node:fs' import os from 'node:os' +import path from 'node:path' import { expect, test } from '@jest/globals' import { parseCliArgs } from '@pnpm/cli.parse-cli-args' @@ -398,6 +400,61 @@ test('--workspace-root fails if used outside of a workspace', async () => { expect(err.code).toBe('ERR_PNPM_NOT_IN_WORKSPACE') }) +// Regression for #11535. The renamed option (`--prefix` → `dir`) must be +// considered when locating the workspace root; otherwise running pnpm from +// a directory outside the project (e.g. `pnpm --prefix=child install` from +// the parent dir) misses the workspace manifest in the prefix dir, and +// settings declared there (e.g. allowBuilds) are silently overwritten. +function setupParentWithChildWorkspace (): { parent: string, child: string } { + const parent = temporaryDirectory() + const child = path.join(parent, 'child') + fs.mkdirSync(child) + fs.writeFileSync(path.join(child, 'pnpm-workspace.yaml'), '') + process.chdir(parent) + return { parent, child } +} + +test('workspaceDir resolves from --prefix when prefix is renamed to dir', async () => { + const { child } = setupParentWithChildWorkspace() + const { workspaceDir } = await parseCliArgs({ + ...DEFAULT_OPTS, + universalOptionsTypes: { prefix: String }, + }, ['install', '--prefix=child']) + expect(workspaceDir && fs.realpathSync.native(workspaceDir)).toBe(fs.realpathSync.native(child)) +}) + +test('workspaceDir resolves from --prefix on the --help short-circuit', async () => { + const { child } = setupParentWithChildWorkspace() + const { cmd, workspaceDir } = await parseCliArgs({ + ...DEFAULT_OPTS, + universalOptionsTypes: { prefix: String, help: Boolean }, + }, ['install', '--prefix=child', '--help']) + expect(cmd).toBe('help') + expect(workspaceDir && fs.realpathSync.native(workspaceDir)).toBe(fs.realpathSync.native(child)) +}) + +test('workspaceDir resolves from --prefix on the --version short-circuit', async () => { + const { child } = setupParentWithChildWorkspace() + const { cmd, workspaceDir } = await parseCliArgs({ + ...DEFAULT_OPTS, + universalOptionsTypes: { prefix: String, version: Boolean }, + }, ['--prefix=child', '--version']) + expect(cmd).toBeNull() + expect(workspaceDir && fs.realpathSync.native(workspaceDir)).toBe(fs.realpathSync.native(child)) +}) + +// When both the alias and the canonical option are supplied, the canonical +// value must win and the alias must be dropped — otherwise --prefix could +// silently overwrite an explicit --dir. +test('canonical option wins when both --prefix and --dir are passed', async () => { + const { options } = await parseCliArgs({ + ...DEFAULT_OPTS, + universalOptionsTypes: { prefix: String, dir: String }, + }, ['install', '--prefix=fromPrefix', '--dir=fromDir']) + expect(options.dir).toBe('fromDir') + expect(options).not.toHaveProperty(['prefix']) +}) + test('everything after an escape arg is a parameter', async () => { const { params, options, cmd } = await parseCliArgs({ ...DEFAULT_OPTS,