diff --git a/.changeset/pnpm-pm-builtin-prefix.md b/.changeset/pnpm-pm-builtin-prefix.md new file mode 100644 index 0000000000..42f5523d63 --- /dev/null +++ b/.changeset/pnpm-pm-builtin-prefix.md @@ -0,0 +1,5 @@ +--- +"pnpm": minor +--- + +Added support for `pnpm pm ` 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`. diff --git a/pnpm/src/main.ts b/pnpm/src/main.ts index b3aa6fbc75..dd94182c8a 100644 --- a/pnpm/src/main.ts +++ b/pnpm/src/main.ts @@ -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 { - 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 { options: cliOptions, cmd, fallbackCommandUsed, + builtInCommandForced, unknownOptions, workspaceDir, } = parsedCliArgs @@ -194,7 +195,7 @@ export async function main (inputArgv: string[]): Promise { // 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) diff --git a/pnpm/src/parseCliArgs.ts b/pnpm/src/parseCliArgs.ts index 0bc7c91dc3..ed036ec318 100644 --- a/pnpm/src/parseCliArgs.ts +++ b/pnpm/src/parseCliArgs.ts @@ -13,8 +13,14 @@ const RENAMED_OPTIONS = { store: 'store-dir', } -export async function parseCliArgs (inputArgv: string[]): Promise { - return parseCliArgsLib({ +export type ParsedCliArgsWithBuiltIn = ParsedCliArgs & { builtInCommandForced: boolean } + +export async function parseCliArgs (inputArgv: string[]): Promise { + 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 universalOptionsTypes: GLOBAL_OPTIONS, universalShorthands, }, inputArgv) + return { ...result, builtInCommandForced } } diff --git a/pnpm/test/clean.ts b/pnpm/test/clean.ts index 6fbda9d708..26d6730455 100644 --- a/pnpm/test/clean.ts +++ b/pnpm/test/clean.ts @@ -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' },