feat: add pnpm pm prefix to force built-in commands (#11147)

- Added `pnpm pm <command>` syntax that always runs the built-in pnpm command, bypassing any same-named script in `package.json`
- When a project defines a script like `"clean": "rm -rf dist"`, `pnpm clean` runs that script, but `pnpm pm clean` runs the built-in clean command
- This applies to all overridable commands: `clean`, `purge`, `rebuild`, `deploy`, `setup`
This commit is contained in:
Zoltan Kochan
2026-03-30 09:51:04 +02:00
committed by GitHub
parent ce4dd758de
commit 00dcdfd38d
4 changed files with 50 additions and 5 deletions

View File

@@ -0,0 +1,5 @@
---
"pnpm": minor
---
Added support for `pnpm pm <command>` to force running the built-in pnpm command, bypassing any same-named script in package.json. For example, `pnpm pm clean` always runs the built-in clean command even if a "clean" script exists. Note that `pm` is now effectively reserved as a leading token; if you have a script named `pm`, run it explicitly with `pnpm run pm`.

View File

@@ -10,7 +10,6 @@ import path from 'node:path'
import { stripVTControlCharacters as stripAnsi } from 'node:util'
import { isExecutedByCorepack, packageManager } from '@pnpm/cli.meta'
import type { ParsedCliArgs } from '@pnpm/cli.parse-cli-args'
import type { Config } from '@pnpm/config.reader'
import { executionTimeLogger, scopeLogger } from '@pnpm/core-loggers'
import { PnpmError } from '@pnpm/error'
@@ -28,6 +27,7 @@ import { checkForUpdates } from './checkForUpdates.js'
import { NOT_IMPLEMENTED_COMMAND_SET, overridableByScriptCommands, pnpmCmds, rcOptionsTypes, recursiveByDefaultCommands, skipPackageManagerCheckForCommand } from './cmd/index.js'
import { formatUnknownOptionsError } from './formatError.js'
import { getConfig, installConfigDepsAndLoadHooks } from './getConfig.js'
import type { ParsedCliArgsWithBuiltIn } from './parseCliArgs.js'
import { parseCliArgs } from './parseCliArgs.js'
import { initReporter, type ReporterType } from './reporter/index.js'
import { switchCliVersion } from './switchCliVersion.js'
@@ -56,7 +56,7 @@ const DEPRECATED_OPTIONS = new Set([
])
export async function main (inputArgv: string[]): Promise<void> {
let parsedCliArgs!: ParsedCliArgs
let parsedCliArgs!: ParsedCliArgsWithBuiltIn
try {
parsedCliArgs = await parseCliArgs(inputArgv)
} catch (err: any) { // eslint-disable-line
@@ -71,6 +71,7 @@ export async function main (inputArgv: string[]): Promise<void> {
options: cliOptions,
cmd,
fallbackCommandUsed,
builtInCommandForced,
unknownOptions,
workspaceDir,
} = parsedCliArgs
@@ -194,7 +195,7 @@ export async function main (inputArgv: string[]): Promise<void> {
// Commands with scriptOverride: if the current project's package.json has a
// script with the same name, run the script instead of the built-in command.
const typedCommandName = argv.remain[0]
if (cmd != null && overridableByScriptCommands.has(typedCommandName) && !cliOptions.global) {
if (cmd != null && !builtInCommandForced && overridableByScriptCommands.has(typedCommandName) && !cliOptions.global) {
const currentDirManifest = config.dir === config.rootProjectManifestDir
? config.rootProjectManifest
: await safeReadProjectManifestOnly(config.dir)

View File

@@ -13,8 +13,14 @@ const RENAMED_OPTIONS = {
store: 'store-dir',
}
export async function parseCliArgs (inputArgv: string[]): Promise<ParsedCliArgs> {
return parseCliArgsLib({
export type ParsedCliArgsWithBuiltIn = ParsedCliArgs & { builtInCommandForced: boolean }
export async function parseCliArgs (inputArgv: string[]): Promise<ParsedCliArgsWithBuiltIn> {
const builtInCommandForced = inputArgv[0] === 'pm'
if (builtInCommandForced) {
inputArgv.splice(0, 1)
}
const result = await parseCliArgsLib({
fallbackCommand: 'run',
escapeArgs: ['create', 'exec', 'test'],
getCommandLongName: getCommandFullName,
@@ -24,4 +30,5 @@ export async function parseCliArgs (inputArgv: string[]): Promise<ParsedCliArgs>
universalOptionsTypes: GLOBAL_OPTIONS,
universalShorthands,
}, inputArgv)
return { ...result, builtInCommandForced }
}

View File

@@ -240,6 +240,38 @@ test('pnpm clean errors in workspace subdir when root has clean script', () => {
expect(output).toContain('ERR_PNPM_SCRIPT_OVERRIDE_IN_WORKSPACE_ROOT')
})
test('pnpm pm clean runs the built-in command even when a clean script exists', () => {
tempDir()
writeJsonFile('package.json', {
name: 'has-clean-script',
scripts: { clean: 'echo "script-clean-ran"' },
})
fs.mkdirSync('node_modules/.pnpm', { recursive: true })
const result = execPnpmSync(['pm', 'clean'])
expect(result.status).toBe(0)
expect(result.stdout.toString()).not.toContain('script-clean-ran')
expect(fs.existsSync('node_modules/.pnpm')).toBe(false)
})
test('pnpm pm clean does not error in workspace subdir when root has clean script', () => {
preparePackages([
{ name: 'project-a', version: '1.0.0' },
])
writeJsonFile('package.json', {
name: 'root',
version: '1.0.0',
scripts: { clean: 'echo "root-clean"' },
})
writeYamlFileSync('pnpm-workspace.yaml', { packages: ['*'] })
fs.mkdirSync(path.join('project-a', 'node_modules', '.pnpm'), { recursive: true })
const result = execPnpmSync(['pm', 'clean'], { cwd: path.resolve('project-a') })
expect(result.status).toBe(0)
expect(fs.existsSync(path.join('project-a', 'node_modules', '.pnpm'))).toBe(false)
})
test('pnpm clean runs built-in in workspace subdir when root has no clean script', () => {
preparePackages([
{ name: 'project-a', version: '1.0.0' },