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,