mirror of
https://github.com/pnpm/pnpm.git
synced 2026-04-27 18:46:18 -04:00
feat: add pnpm with <version|current> command (#11275)
## Summary - **New command `pnpm with <version|current> <args...>`** — runs pnpm at a specific version (or the currently active one) for a single invocation, bypassing the project's `packageManager` and `devEngines.packageManager` pins. Uses the same install mechanism as `pnpm self-update`, caching the downloaded pnpm in the global virtual store for reuse. - **New config setting `pmOnFail`** — overrides the `onFail` behavior of both `packageManager` and `devEngines.packageManager`. Accepted values: `download`, `error`, `warn`, `ignore`. Readable from CLI flag, env var, `pnpm-workspace.yaml`, or `.npmrc` — useful when version management is handled by an external tool (asdf, mise, Volta, etc.) and the project wants pnpm itself to skip the check. ``` pnpm with current install # one-shot, use running pnpm pnpm with 11.0.0-rc.1 install # one-shot, use specific version pnpm install --pm-on-fail=ignore # direct CLI flag pnpm install --config.pm-on-fail=ignore # equivalent via --config.* sugar pnpm_config_pm_on_fail=ignore pnpm install # env var # or in pnpm-workspace.yaml: pmOnFail: ignore ``` ## Implementation notes - Command handler lives in `@pnpm/engine.pm.commands` (next to `self-update` and `setup`). - `'with'` added to `SPECIALLY_ESCAPED_CMDS` in `cli/parse-cli-args` so args after `<spec>` pass through opaquely like `dlx`/`run`. - `pnpm with current <cmd> [args]` is rewritten in `pnpm/src/parseCliArgs.ts` to an in-process dispatch — argv is rebuilt in place so any global flags the user put before `with` (e.g. `--dir`, `--filter`) are preserved. `process.env.pnpm_config_pm_on_fail=ignore` is set so the override survives `parseCliArgsLib`'s `-v` / `--help` short-circuits (which discard other parsed options). - `main.ts` treats `skipPackageManagerCheck: true` as bypassing both the auto-download and the warn/error check (previously only the check). Also skips when `cmd='help'` and the help target is itself a skip-check command, so `pnpm with -h` works in pinned projects without downloading the pinned version first. - Errors reported to stderr for `with` (aligned with `dlx`/`create`/`sbom`). - `pmOnFail` wired in `config/reader/src/index.ts`: added to `types`, `Config`, and `pnpmConfigFileKeys`; applied as an override in the `onFail` resolution block. - The `with <version>` child process sets both `COREPACK_ROOT` (honored by every pnpm release via `isExecutedByCorepack()`) and `pnpm_config_pm_on_fail=ignore` (principled override on new releases that ship the setting). This gives graceful behavior when `pnpm with 9.3.0 install` spawns an older pnpm that predates the new setting. - Store controller lifecycle in the handler wrapped in `try/finally` to prevent leaks on install errors. Signal-induced child exits return a non-zero exit code so interrupted runs aren't masked as success.
This commit is contained in:
25
.changeset/feat-with-command.md
Normal file
25
.changeset/feat-with-command.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
"@pnpm/cli.parse-cli-args": minor
|
||||
"@pnpm/config.reader": minor
|
||||
"@pnpm/engine.pm.commands": minor
|
||||
"pnpm": minor
|
||||
---
|
||||
|
||||
Add `pnpm with <version|current> <args...>` command. Runs pnpm at a specific version (or the currently active one) for a single invocation, bypassing the project's `packageManager` and `devEngines.packageManager` pins. Uses the same install mechanism as `pnpm self-update`, caching the downloaded pnpm in the global virtual store for reuse.
|
||||
|
||||
Examples:
|
||||
|
||||
```
|
||||
pnpm with current install # ignore the pinned version, use the running pnpm
|
||||
pnpm with 11.0.0-rc.1 install # install using pnpm 11.0.0-rc.1
|
||||
pnpm with next install # install using the "next" dist-tag
|
||||
```
|
||||
|
||||
Also adds a new `pmOnFail` setting that overrides the `onFail` behavior of `packageManager` and `devEngines.packageManager`. Accepted values: `download`, `error`, `warn`, `ignore`. Can be set via CLI flag, env var, `pnpm-workspace.yaml`, or `.npmrc` — useful when version management is handled by an external tool (asdf, mise, Volta, etc.) and the project wants pnpm itself to skip the check.
|
||||
|
||||
```
|
||||
pnpm install --pm-on-fail=ignore # direct CLI flag
|
||||
pnpm_config_pm_on_fail=ignore pnpm install # env var
|
||||
# or in pnpm-workspace.yaml:
|
||||
# pmOnFail: ignore
|
||||
```
|
||||
@@ -4,7 +4,7 @@ import { findWorkspaceDir } from '@pnpm/workspace.root-finder'
|
||||
import didYouMean, { ReturnTypeEnums } from 'didyoumean2'
|
||||
|
||||
const RECURSIVE_CMDS = new Set(['recursive', 'multi', 'm'])
|
||||
const SPECIALLY_ESCAPED_CMDS = new Set(['run', 'dlx'])
|
||||
const SPECIALLY_ESCAPED_CMDS = new Set(['run', 'dlx', 'with'])
|
||||
|
||||
export interface ParsedCliArgs {
|
||||
argv: {
|
||||
|
||||
@@ -234,6 +234,7 @@ export interface Config extends OptionsFromRootManifest {
|
||||
lockfile?: boolean
|
||||
dedupeInjectedDeps?: boolean
|
||||
nodeOptions?: string
|
||||
pmOnFail?: 'download' | 'error' | 'warn' | 'ignore'
|
||||
packageManagerStrict?: boolean
|
||||
packageManagerStrictVersion?: boolean
|
||||
virtualStoreDirMaxLength: number
|
||||
|
||||
@@ -103,6 +103,7 @@ export const excludedPnpmKeys = [
|
||||
'pack-gzip-level',
|
||||
'patches-dir',
|
||||
'pnpmfile',
|
||||
'pm-on-fail',
|
||||
'package-manager-strict',
|
||||
'package-manager-strict-version',
|
||||
'prefer-workspace-packages',
|
||||
|
||||
@@ -617,17 +617,24 @@ export async function getConfig (opts: {
|
||||
|
||||
transformPathKeys(pnpmConfig, os.homedir())
|
||||
|
||||
// For the legacy packageManager field, derive onFail from config settings.
|
||||
// devEngines.packageManager already has onFail set during parsing.
|
||||
if (pnpmConfig.wantedPackageManager && pnpmConfig.wantedPackageManager.onFail == null) {
|
||||
if (pnpmConfig.packageManagerStrict === false) {
|
||||
pnpmConfig.wantedPackageManager.onFail = 'warn'
|
||||
} else if (pnpmConfig.managePackageManagerVersions) {
|
||||
pnpmConfig.wantedPackageManager.onFail = 'download'
|
||||
} else if (pnpmConfig.packageManagerStrictVersion) {
|
||||
pnpmConfig.wantedPackageManager.onFail = 'error'
|
||||
} else {
|
||||
pnpmConfig.wantedPackageManager.onFail = 'ignore'
|
||||
// The `pmOnFail` config setting overrides whatever onFail the
|
||||
// wantedPackageManager carried, so users (and internal callers) can force
|
||||
// a specific behavior without editing the manifest.
|
||||
// Otherwise, for the legacy packageManager field, derive onFail from config
|
||||
// settings. devEngines.packageManager already has onFail set during parsing.
|
||||
if (pnpmConfig.wantedPackageManager) {
|
||||
if (pnpmConfig.pmOnFail) {
|
||||
pnpmConfig.wantedPackageManager.onFail = pnpmConfig.pmOnFail
|
||||
} else if (pnpmConfig.wantedPackageManager.onFail == null) {
|
||||
if (pnpmConfig.packageManagerStrict === false) {
|
||||
pnpmConfig.wantedPackageManager.onFail = 'warn'
|
||||
} else if (pnpmConfig.managePackageManagerVersions) {
|
||||
pnpmConfig.wantedPackageManager.onFail = 'download'
|
||||
} else if (pnpmConfig.packageManagerStrictVersion) {
|
||||
pnpmConfig.wantedPackageManager.onFail = 'error'
|
||||
} else {
|
||||
pnpmConfig.wantedPackageManager.onFail = 'ignore'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ export const pnpmTypes = {
|
||||
'package-import-method': ['auto', 'hardlink', 'clone', 'copy'],
|
||||
'patches-dir': String,
|
||||
pnpmfile: String,
|
||||
'pm-on-fail': ['download', 'error', 'warn', 'ignore'],
|
||||
'package-manager-strict': Boolean,
|
||||
'package-manager-strict-version': Boolean,
|
||||
'prefer-frozen-lockfile': Boolean,
|
||||
|
||||
@@ -46,10 +46,12 @@
|
||||
"@pnpm/lockfile.types": "workspace:*",
|
||||
"@pnpm/os.env.path-extender": "catalog:",
|
||||
"@pnpm/resolving.npm-resolver": "workspace:*",
|
||||
"@pnpm/shell.path": "workspace:*",
|
||||
"@pnpm/store.connection-manager": "workspace:*",
|
||||
"@pnpm/store.controller": "workspace:*",
|
||||
"@pnpm/types": "workspace:*",
|
||||
"@pnpm/workspace.project-manifest-reader": "workspace:*",
|
||||
"cross-spawn": "catalog:",
|
||||
"path-name": "catalog:",
|
||||
"ramda": "catalog:",
|
||||
"render-help": "catalog:",
|
||||
@@ -66,12 +68,10 @@
|
||||
"@pnpm/error": "workspace:*",
|
||||
"@pnpm/logger": "workspace:*",
|
||||
"@pnpm/prepare": "workspace:*",
|
||||
"@pnpm/shell.path": "workspace:*",
|
||||
"@pnpm/testing.mock-agent": "workspace:*",
|
||||
"@types/cross-spawn": "catalog:",
|
||||
"@types/ramda": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
"cross-spawn": "catalog:"
|
||||
"@types/semver": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.13"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { selfUpdate } from './self-updater/index.js'
|
||||
export { installPnpm, installPnpmToStore, linkExePlatformBinary } from './self-updater/installPnpm.js'
|
||||
export { setup } from './setup/index.js'
|
||||
export { withCmd } from './with/index.js'
|
||||
|
||||
3
engine/pm/commands/src/with/index.ts
Normal file
3
engine/pm/commands/src/with/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import * as withCmd from './with.js'
|
||||
|
||||
export { withCmd }
|
||||
120
engine/pm/commands/src/with/with.ts
Normal file
120
engine/pm/commands/src/with/with.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { isExecutedByCorepack, packageManager } from '@pnpm/cli.meta'
|
||||
import { docsUrl } from '@pnpm/cli.utils'
|
||||
import { type Config, type ConfigContext, types as allTypes } from '@pnpm/config.reader'
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import { resolvePackageManagerIntegrities } from '@pnpm/installing.env-installer'
|
||||
import { prependDirsToPath } from '@pnpm/shell.path'
|
||||
import { createStoreController, type CreateStoreControllerOptions } from '@pnpm/store.connection-manager'
|
||||
import crossSpawn from 'cross-spawn'
|
||||
import { pick } from 'ramda'
|
||||
import { renderHelp } from 'render-help'
|
||||
|
||||
import { installPnpmToStore } from '../self-updater/installPnpm.js'
|
||||
|
||||
export const commandNames = ['with']
|
||||
|
||||
export const skipPackageManagerCheck = true
|
||||
|
||||
export const rcOptionsTypes = cliOptionsTypes
|
||||
|
||||
export function cliOptionsTypes (): Record<string, unknown> {
|
||||
return pick([], allTypes)
|
||||
}
|
||||
|
||||
export function help (): string {
|
||||
return renderHelp({
|
||||
description: 'Run pnpm with a specific version (or the currently running one), ignoring the "packageManager" and "devEngines.packageManager" fields of the project manifest.',
|
||||
descriptionLists: [],
|
||||
url: docsUrl('with'),
|
||||
usages: [
|
||||
'pnpm with current <pnpm args>',
|
||||
'pnpm with <version> <pnpm args>',
|
||||
'pnpm with next install',
|
||||
'pnpm with 10 install',
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
export type WithCommandOptions = CreateStoreControllerOptions & Pick<Config,
|
||||
| 'dir'
|
||||
| 'lockfileDir'
|
||||
| 'pnpmHomeDir'
|
||||
| 'virtualStoreDirMaxLength'
|
||||
> & Pick<ConfigContext,
|
||||
| 'rootProjectManifestDir'
|
||||
>
|
||||
|
||||
export async function handler (
|
||||
opts: WithCommandOptions,
|
||||
params: string[]
|
||||
): Promise<{ exitCode: number }> {
|
||||
if (params.length === 0) {
|
||||
throw new PnpmError('MISSING_WITH_SPEC', 'Missing version argument. Usage: pnpm with <version|current> <args...>')
|
||||
}
|
||||
if (isExecutedByCorepack()) {
|
||||
throw new PnpmError('CANT_USE_WITH_IN_COREPACK', 'The "pnpm with" command does not work under corepack')
|
||||
}
|
||||
// `with current` is handled earlier in parseCliArgs.ts, which re-parses it
|
||||
// for in-process execution, so this handler only ever sees version/dist-tag specs.
|
||||
const [spec, ...args] = params
|
||||
|
||||
fs.mkdirSync(opts.pnpmHomeDir, { recursive: true })
|
||||
const store = await createStoreController(opts)
|
||||
let binDir: string
|
||||
try {
|
||||
// resolvePackageManagerIntegrities resolves ranges/dist-tags via the
|
||||
// registry and writes the resolved exact version to the envLockfile.
|
||||
const envLockfile = await resolvePackageManagerIntegrities(spec, {
|
||||
rootDir: opts.pnpmHomeDir,
|
||||
registries: opts.registries,
|
||||
storeController: store.ctrl,
|
||||
storeDir: store.dir,
|
||||
})
|
||||
const resolvedVersion = envLockfile.importers['.'].packageManagerDependencies?.['pnpm']?.version
|
||||
if (!resolvedVersion) {
|
||||
throw new PnpmError('CANNOT_RESOLVE_PNPM', `Cannot resolve pnpm version for "${spec}"`)
|
||||
}
|
||||
;({ binDir } = await installPnpmToStore(resolvedVersion, {
|
||||
envLockfile,
|
||||
storeController: store.ctrl,
|
||||
storeDir: store.dir,
|
||||
registries: opts.registries,
|
||||
virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength,
|
||||
packageManager: { name: packageManager.name, version: packageManager.version },
|
||||
}))
|
||||
} finally {
|
||||
await store.ctrl.close()
|
||||
}
|
||||
|
||||
// The child pnpm must skip the packageManager/devEngines check so the requested
|
||||
// version stays active. Two keys are set for backward compatibility:
|
||||
// - `COREPACK_ROOT` is honored by every pnpm release that supports corepack
|
||||
// (older versions skip the pm check whenever this is set).
|
||||
// - `pnpm_config_pm_on_fail=ignore` is the principled override recognized
|
||||
// by pnpm releases that ship the `pmOnFail` setting.
|
||||
const pnpmEnv = prependDirsToPath([binDir])
|
||||
const spawnEnv: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
[pnpmEnv.name]: pnpmEnv.value,
|
||||
COREPACK_ROOT: process.env.COREPACK_ROOT ?? 'pnpm-with',
|
||||
pnpm_config_pm_on_fail: 'ignore',
|
||||
}
|
||||
|
||||
const pnpmBinPath = path.join(binDir, 'pnpm')
|
||||
const { status, signal, error } = crossSpawn.sync(pnpmBinPath, args, {
|
||||
stdio: 'inherit',
|
||||
env: spawnEnv,
|
||||
})
|
||||
if (error) throw error
|
||||
if (signal) {
|
||||
// Best-effort: try to terminate with the same signal the child received.
|
||||
// If the signal is handled or ignored, fall back to a non-zero exit code
|
||||
// so the caller doesn't mistake an interrupted run for a successful one.
|
||||
process.kill(process.pid, signal)
|
||||
return { exitCode: 1 }
|
||||
}
|
||||
return { exitCode: status ?? 0 }
|
||||
}
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -3615,6 +3615,9 @@ importers:
|
||||
'@pnpm/resolving.npm-resolver':
|
||||
specifier: workspace:*
|
||||
version: link:../../../resolving/npm-resolver
|
||||
'@pnpm/shell.path':
|
||||
specifier: workspace:*
|
||||
version: link:../../../shell/path
|
||||
'@pnpm/store.connection-manager':
|
||||
specifier: workspace:*
|
||||
version: link:../../../store/connection-manager
|
||||
@@ -3627,6 +3630,9 @@ importers:
|
||||
'@pnpm/workspace.project-manifest-reader':
|
||||
specifier: workspace:*
|
||||
version: link:../../../workspace/project-manifest-reader
|
||||
cross-spawn:
|
||||
specifier: 'catalog:'
|
||||
version: 7.0.6
|
||||
path-name:
|
||||
specifier: 'catalog:'
|
||||
version: 1.0.0
|
||||
@@ -3658,9 +3664,6 @@ importers:
|
||||
'@pnpm/prepare':
|
||||
specifier: workspace:*
|
||||
version: link:../../../__utils__/prepare
|
||||
'@pnpm/shell.path':
|
||||
specifier: workspace:*
|
||||
version: link:../../../shell/path
|
||||
'@pnpm/testing.mock-agent':
|
||||
specifier: workspace:*
|
||||
version: link:../../../testing/mock-agent
|
||||
@@ -3673,9 +3676,6 @@ importers:
|
||||
'@types/semver':
|
||||
specifier: 'catalog:'
|
||||
version: 7.7.1
|
||||
cross-spawn:
|
||||
specifier: 'catalog:'
|
||||
version: 7.0.6
|
||||
|
||||
engine/runtime/bun-resolver:
|
||||
dependencies:
|
||||
|
||||
@@ -7,7 +7,7 @@ import { config, getCommand, setCommand } from '@pnpm/config.commands'
|
||||
import { types as allTypes } from '@pnpm/config.reader'
|
||||
import { audit, licenses, sbom } from '@pnpm/deps.compliance.commands'
|
||||
import { docs, list, ll, outdated, peers, view, why } from '@pnpm/deps.inspection.commands'
|
||||
import { selfUpdate, setup } from '@pnpm/engine.pm.commands'
|
||||
import { selfUpdate, setup, withCmd } from '@pnpm/engine.pm.commands'
|
||||
import { env, runtime } from '@pnpm/engine.runtime.commands'
|
||||
import {
|
||||
create,
|
||||
@@ -56,6 +56,7 @@ export const GLOBAL_OPTIONS = pick([
|
||||
'yes',
|
||||
'include-workspace-root',
|
||||
'fail-if-no-match',
|
||||
'pm-on-fail',
|
||||
], allTypes)
|
||||
|
||||
export type CommandResponse = string | { output?: string, exitCode: number }
|
||||
@@ -183,6 +184,7 @@ const commands: CommandDefinition[] = [
|
||||
version,
|
||||
view,
|
||||
why,
|
||||
withCmd,
|
||||
createHelp(helpByCommandName),
|
||||
...notImplementedCommandDefinitions,
|
||||
]
|
||||
|
||||
@@ -102,11 +102,11 @@ export async function main (inputArgv: string[]): Promise<void> {
|
||||
workspaceDir,
|
||||
ignoreNonAuthSettingsFromLocal: isDlxOrCreateCommand,
|
||||
}) as { config: typeof config, context: ConfigContext })
|
||||
if (!isExecutedByCorepack() && cmd !== 'setup' && context.wantedPackageManager != null) {
|
||||
if (!isExecutedByCorepack() && cmd !== 'setup' && context.wantedPackageManager != null && !shouldSkipPmHandling(cmd, cliParams)) {
|
||||
const pm = context.wantedPackageManager
|
||||
if (pm.onFail === 'download' && pm.name === 'pnpm' && cmd !== 'self-update') {
|
||||
if (pm.onFail === 'download' && pm.name === 'pnpm') {
|
||||
await switchCliVersion(config, context)
|
||||
} else if (pm.onFail !== 'ignore' && (!cmd || !skipPackageManagerCheckForCommand.has(cmd))) {
|
||||
} else if (pm.onFail !== 'ignore') {
|
||||
if (cliOptions.global) {
|
||||
globalWarn('Using --global skips the package manager check for this project')
|
||||
} else {
|
||||
@@ -115,7 +115,7 @@ export async function main (inputArgv: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
;({ config, context } = await installConfigDepsAndLoadHooks(config, context) as { config: typeof config, context: ConfigContext })
|
||||
if (isDlxOrCreateCommand || cmd === 'sbom') {
|
||||
if (isDlxOrCreateCommand || cmd === 'sbom' || cmd === 'with') {
|
||||
config.useStderr = true
|
||||
}
|
||||
config.argv = argv
|
||||
@@ -370,6 +370,22 @@ function printError (message: string, hint?: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to skip the packageManager/devEngines handling block (both auto
|
||||
* download and warn/error check). Returns true when the command itself
|
||||
* opts out via `skipPackageManagerCheck: true`, or when the user is asking
|
||||
* for help on such a command — `pnpm help <skippable>` and
|
||||
* `pnpm <skippable> --help` (which parse-cli-args rewrites to the same
|
||||
* cmd='help' form) shouldn't download an older pinned pnpm just to render
|
||||
* help for a command that older pnpm may not even have.
|
||||
*/
|
||||
function shouldSkipPmHandling (cmd: string | null, cliParams: string[]): boolean {
|
||||
if (cmd == null) return false
|
||||
if (skipPackageManagerCheckForCommand.has(cmd)) return true
|
||||
if (cmd === 'help' && cliParams[0] != null && skipPackageManagerCheckForCommand.has(cliParams[0])) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function checkPackageManager (pm: EngineDependency): void {
|
||||
if (!pm.name) return
|
||||
const shouldError = pm.onFail === 'error' || pm.onFail === 'download'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { parseCliArgs as parseCliArgsLib, type ParsedCliArgs } from '@pnpm/cli.parse-cli-args'
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
|
||||
import {
|
||||
getCliOptionsTypes,
|
||||
@@ -20,7 +21,7 @@ export async function parseCliArgs (inputArgv: string[]): Promise<ParsedCliArgsW
|
||||
if (builtInCommandForced) {
|
||||
inputArgv.splice(0, 1)
|
||||
}
|
||||
const result = await parseCliArgsLib({
|
||||
const libOpts = {
|
||||
fallbackCommand: 'run',
|
||||
escapeArgs: ['create', 'exec', 'test'],
|
||||
getCommandLongName: getCommandFullName,
|
||||
@@ -29,6 +30,47 @@ export async function parseCliArgs (inputArgv: string[]): Promise<ParsedCliArgsW
|
||||
shorthandsByCommandName,
|
||||
universalOptionsTypes: GLOBAL_OPTIONS,
|
||||
universalShorthands,
|
||||
}, inputArgv)
|
||||
}
|
||||
let result = await parseCliArgsLib(libOpts, inputArgv)
|
||||
// `pnpm [global-opts] with current <cmd> [args]` is sugar for
|
||||
// `pnpm [global-opts] --pm-on-fail=ignore <cmd> [args]` — re-parse so the
|
||||
// inner command is dispatched directly, in-process. The override is
|
||||
// propagated via env var (not --pm-on-fail=ignore in argv) so it survives
|
||||
// parseCliArgsLib's special short-circuits like the -v/--version
|
||||
// interceptor, which discards other parsed options.
|
||||
//
|
||||
// We rebuild argv by removing the `with current` tokens in place so that
|
||||
// any global flags the user put BEFORE `with` (e.g. `--dir`, `--filter`)
|
||||
// are preserved.
|
||||
if (result.cmd === 'with' && result.params[0] === 'current') {
|
||||
const withIdx = findWithCurrentIndex(inputArgv)
|
||||
if (withIdx < 0 || inputArgv.length - withIdx - 2 === 0) {
|
||||
throw new PnpmError('MISSING_WITH_CURRENT_CMD',
|
||||
'Missing command after "current". Usage: pnpm with current <command> [args...]')
|
||||
}
|
||||
process.env.pnpm_config_pm_on_fail = 'ignore'
|
||||
result = await parseCliArgsLib(libOpts, [
|
||||
...inputArgv.slice(0, withIdx),
|
||||
...inputArgv.slice(withIdx + 2),
|
||||
])
|
||||
}
|
||||
return { ...result, builtInCommandForced }
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the `with current` token pair in argv. We assume the first
|
||||
* occurrence that's plausibly the command (not the value of a preceding flag)
|
||||
* is the one. Good enough for realistic CLI usage — no pnpm option is
|
||||
* expected to take the literal value `with`.
|
||||
*/
|
||||
function findWithCurrentIndex (argv: string[]): number {
|
||||
for (let i = 0; i < argv.length - 1; i++) {
|
||||
if (argv[i] !== 'with' || argv[i + 1] !== 'current') continue
|
||||
const prev = argv[i - 1]
|
||||
// If the previous token is a long flag without an `=value` form, it may
|
||||
// be consuming `with` as its value — skip this occurrence in that case.
|
||||
if (prev != null && prev.startsWith('--') && !prev.includes('=')) continue
|
||||
return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function switchCliVersion (config: Config, context: ConfigContext):
|
||||
pmVersion = envLockfile.importers['.'].packageManagerDependencies?.['pnpm']?.version
|
||||
if (!pmVersion) {
|
||||
globalWarn(`Cannot resolve pnpm version for "${pm.version}"`)
|
||||
await storeToUse?.ctrl.close()
|
||||
await storeToUse.ctrl.close()
|
||||
return
|
||||
}
|
||||
} else if (!isPackageManagerResolved(envLockfile, pmVersion)) {
|
||||
@@ -48,7 +48,8 @@ export async function switchCliVersion (config: Config, context: ConfigContext):
|
||||
})
|
||||
}
|
||||
|
||||
// If the wanted version matches the current version, no switch needed
|
||||
// If the wanted version matches the current version, no switch needed.
|
||||
// Skip install-to-store entirely — we're already running this version.
|
||||
if (pmVersion === packageManager.version) {
|
||||
await storeToUse?.ctrl.close()
|
||||
return
|
||||
@@ -61,19 +62,23 @@ export async function switchCliVersion (config: Config, context: ConfigContext):
|
||||
}
|
||||
|
||||
if (!envLockfile) {
|
||||
await storeToUse.ctrl.close()
|
||||
throw new PnpmError('NO_PKG_MANAGER_INTEGRITY', `The packageManager dependency ${pmVersion} was not found in pnpm-lock.yaml`)
|
||||
}
|
||||
|
||||
const { binDir: wantedPnpmBinDir } = await installPnpmToStore(pmVersion, {
|
||||
envLockfile,
|
||||
storeController: storeToUse.ctrl,
|
||||
storeDir: storeToUse.dir,
|
||||
registries: config.registries,
|
||||
virtualStoreDirMaxLength: config.virtualStoreDirMaxLength,
|
||||
packageManager: { name: packageManager.name, version: packageManager.version },
|
||||
})
|
||||
|
||||
await storeToUse.ctrl.close()
|
||||
let wantedPnpmBinDir: string
|
||||
try {
|
||||
;({ binDir: wantedPnpmBinDir } = await installPnpmToStore(pmVersion, {
|
||||
envLockfile,
|
||||
storeController: storeToUse.ctrl,
|
||||
storeDir: storeToUse.dir,
|
||||
registries: config.registries,
|
||||
virtualStoreDirMaxLength: config.virtualStoreDirMaxLength,
|
||||
packageManager: { name: packageManager.name, version: packageManager.version },
|
||||
}))
|
||||
} finally {
|
||||
await storeToUse.ctrl.close()
|
||||
}
|
||||
|
||||
const pnpmEnv = prependDirsToPath([wantedPnpmBinDir])
|
||||
if (!pnpmEnv.updated) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prepare } from '@pnpm/prepare'
|
||||
import { writeYamlFileSync } from 'write-yaml-file'
|
||||
|
||||
import { execPnpmSync } from './utils/index.js'
|
||||
|
||||
@@ -245,3 +246,57 @@ test('devEngines.packageManager takes precedence over packageManager field', asy
|
||||
expect(stderr.toString()).toContain('This project is configured to use 0.0.1 of pnpm')
|
||||
expect(stderr.toString()).toContain('"packageManager" will be ignored')
|
||||
})
|
||||
|
||||
test('pmOnFail=ignore via env var bypasses the devEngines.packageManager check', async () => {
|
||||
prepare({
|
||||
devEngines: {
|
||||
packageManager: {
|
||||
name: 'pnpm',
|
||||
version: '0.0.1',
|
||||
onFail: 'error',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { status, stderr } = execPnpmSync(['install'], {
|
||||
env: { pnpm_config_pm_on_fail: 'ignore' },
|
||||
})
|
||||
|
||||
expect(status).toBe(0)
|
||||
expect(stderr.toString()).not.toContain('0.0.1')
|
||||
})
|
||||
|
||||
test('pmOnFail via --pm-on-fail CLI flag bypasses the devEngines.packageManager check', async () => {
|
||||
prepare({
|
||||
devEngines: {
|
||||
packageManager: {
|
||||
name: 'pnpm',
|
||||
version: '0.0.1',
|
||||
onFail: 'error',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(execPnpmSync(['install', '--pm-on-fail=ignore']).status).toBe(0)
|
||||
expect(execPnpmSync(['install', '--config.pm-on-fail=ignore']).status).toBe(0)
|
||||
})
|
||||
|
||||
test('pmOnFail=ignore set in pnpm-workspace.yaml bypasses the devEngines.packageManager check', async () => {
|
||||
prepare({
|
||||
devEngines: {
|
||||
packageManager: {
|
||||
name: 'pnpm',
|
||||
version: '0.0.1',
|
||||
onFail: 'error',
|
||||
},
|
||||
},
|
||||
})
|
||||
writeYamlFileSync('pnpm-workspace.yaml', {
|
||||
pmOnFail: 'ignore',
|
||||
})
|
||||
|
||||
const { status, stderr } = execPnpmSync(['install'])
|
||||
|
||||
expect(status).toBe(0)
|
||||
expect(stderr.toString()).not.toContain('0.0.1')
|
||||
})
|
||||
|
||||
102
pnpm/test/withCommand.test.ts
Normal file
102
pnpm/test/withCommand.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { prepare } from '@pnpm/prepare'
|
||||
import { writeJsonFileSync } from 'write-json-file'
|
||||
|
||||
import { execPnpmSync } from './utils/index.js'
|
||||
|
||||
test('pnpm with current runs the currently active pnpm even when the project pins a different version', () => {
|
||||
prepare()
|
||||
const pnpmHome = path.resolve('pnpm')
|
||||
const env = { PNPM_HOME: pnpmHome }
|
||||
writeJsonFileSync('package.json', {
|
||||
packageManager: 'pnpm@9.3.0',
|
||||
})
|
||||
|
||||
const { status, stdout } = execPnpmSync(['with', 'current', 'help'], { env })
|
||||
|
||||
expect(status).toBe(0)
|
||||
expect(stdout.toString()).not.toContain('Version 9.3.0')
|
||||
})
|
||||
|
||||
test('pnpm with current bypasses the packageManager check when an unrelated package manager is pinned', () => {
|
||||
prepare()
|
||||
const pnpmHome = path.resolve('pnpm')
|
||||
const env = { PNPM_HOME: pnpmHome }
|
||||
writeJsonFileSync('package.json', {
|
||||
packageManager: 'yarn@4.0.0',
|
||||
})
|
||||
|
||||
const { status, stderr } = execPnpmSync(['with', 'current', 'help'], { env })
|
||||
|
||||
expect(status).toBe(0)
|
||||
expect(stderr.toString()).not.toContain('This project is configured to use yarn')
|
||||
})
|
||||
|
||||
test('pnpm with current bypasses devEngines.packageManager with onFail=download', () => {
|
||||
prepare()
|
||||
const pnpmHome = path.resolve('pnpm')
|
||||
const env = { PNPM_HOME: pnpmHome }
|
||||
writeJsonFileSync('package.json', {
|
||||
devEngines: {
|
||||
packageManager: {
|
||||
name: 'pnpm',
|
||||
version: '9.3.0',
|
||||
onFail: 'download',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { status, stdout } = execPnpmSync(['with', 'current', 'help'], { env })
|
||||
|
||||
expect(status).toBe(0)
|
||||
expect(stdout.toString()).not.toContain('Version 9.3.0')
|
||||
})
|
||||
|
||||
test('pnpm with forwards subsequent args to the child pnpm', () => {
|
||||
prepare()
|
||||
writeJsonFileSync('package.json', {
|
||||
name: 'project',
|
||||
version: '1.0.0',
|
||||
})
|
||||
|
||||
const { status, stdout } = execPnpmSync(['with', 'current', '--version'])
|
||||
|
||||
expect(status).toBe(0)
|
||||
expect(stdout.toString().trim()).toMatch(/^\d+\.\d+\.\d+/)
|
||||
})
|
||||
|
||||
test('pnpm with fails when no spec is provided', () => {
|
||||
prepare()
|
||||
|
||||
const { status, stderr } = execPnpmSync(['with'])
|
||||
|
||||
expect(status).not.toBe(0)
|
||||
expect(stderr.toString()).toContain('Missing version argument')
|
||||
})
|
||||
|
||||
test('pnpm with <version> downloads and runs the specified pnpm version', () => {
|
||||
prepare()
|
||||
const pnpmHome = path.resolve('pnpm')
|
||||
const env = { PNPM_HOME: pnpmHome }
|
||||
|
||||
const { status, stdout } = execPnpmSync(['with', '9.3.0', 'help'], { env })
|
||||
|
||||
expect(status).toBe(0)
|
||||
expect(stdout.toString()).toContain('Version 9.3.0')
|
||||
})
|
||||
|
||||
test('pnpm with <version> ignores the packageManager pin and uses the requested version', () => {
|
||||
prepare()
|
||||
const pnpmHome = path.resolve('pnpm')
|
||||
const env = { PNPM_HOME: pnpmHome }
|
||||
writeJsonFileSync('package.json', {
|
||||
packageManager: 'pnpm@9.1.0',
|
||||
})
|
||||
|
||||
const { status, stdout } = execPnpmSync(['with', '9.3.0', 'help'], { env })
|
||||
|
||||
expect(status).toBe(0)
|
||||
expect(stdout.toString()).toContain('Version 9.3.0')
|
||||
expect(stdout.toString()).not.toContain('Version 9.1.0')
|
||||
})
|
||||
Reference in New Issue
Block a user