diff --git a/.changeset/feat-with-command.md b/.changeset/feat-with-command.md new file mode 100644 index 0000000000..2373d07213 --- /dev/null +++ b/.changeset/feat-with-command.md @@ -0,0 +1,25 @@ +--- +"@pnpm/cli.parse-cli-args": minor +"@pnpm/config.reader": minor +"@pnpm/engine.pm.commands": minor +"pnpm": minor +--- + +Add `pnpm with ` 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 +``` diff --git a/cli/parse-cli-args/src/index.ts b/cli/parse-cli-args/src/index.ts index a126dd12fa..3863263b9a 100644 --- a/cli/parse-cli-args/src/index.ts +++ b/cli/parse-cli-args/src/index.ts @@ -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: { diff --git a/config/reader/src/Config.ts b/config/reader/src/Config.ts index f0bcfb8cc6..597f51c9b2 100644 --- a/config/reader/src/Config.ts +++ b/config/reader/src/Config.ts @@ -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 diff --git a/config/reader/src/configFileKey.ts b/config/reader/src/configFileKey.ts index df9bcbaec2..bed9431b4e 100644 --- a/config/reader/src/configFileKey.ts +++ b/config/reader/src/configFileKey.ts @@ -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', diff --git a/config/reader/src/index.ts b/config/reader/src/index.ts index 60f2b3fb56..2e85063a39 100644 --- a/config/reader/src/index.ts +++ b/config/reader/src/index.ts @@ -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' + } } } diff --git a/config/reader/src/types.ts b/config/reader/src/types.ts index 37d864c6fe..67ff4655ef 100644 --- a/config/reader/src/types.ts +++ b/config/reader/src/types.ts @@ -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, diff --git a/engine/pm/commands/package.json b/engine/pm/commands/package.json index e0a72da95f..7ee8eecf24 100644 --- a/engine/pm/commands/package.json +++ b/engine/pm/commands/package.json @@ -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" diff --git a/engine/pm/commands/src/index.ts b/engine/pm/commands/src/index.ts index 926f41bd2a..4aa6424562 100644 --- a/engine/pm/commands/src/index.ts +++ b/engine/pm/commands/src/index.ts @@ -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' diff --git a/engine/pm/commands/src/with/index.ts b/engine/pm/commands/src/with/index.ts new file mode 100644 index 0000000000..911f56313e --- /dev/null +++ b/engine/pm/commands/src/with/index.ts @@ -0,0 +1,3 @@ +import * as withCmd from './with.js' + +export { withCmd } diff --git a/engine/pm/commands/src/with/with.ts b/engine/pm/commands/src/with/with.ts new file mode 100644 index 0000000000..ffbbb8df17 --- /dev/null +++ b/engine/pm/commands/src/with/with.ts @@ -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 { + 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 with ', + 'pnpm with next install', + 'pnpm with 10 install', + ], + }) +} + +export type WithCommandOptions = CreateStoreControllerOptions & Pick & Pick + +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 ') + } + 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 } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc726ca9a4..3d019d326c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/pnpm/src/cmd/index.ts b/pnpm/src/cmd/index.ts index a210166bfb..3f69f30ae7 100644 --- a/pnpm/src/cmd/index.ts +++ b/pnpm/src/cmd/index.ts @@ -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, ] diff --git a/pnpm/src/main.ts b/pnpm/src/main.ts index ff07427aa4..cc82690a03 100644 --- a/pnpm/src/main.ts +++ b/pnpm/src/main.ts @@ -102,11 +102,11 @@ export async function main (inputArgv: string[]): Promise { 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 { } } ;({ 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 ` and + * `pnpm --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' diff --git a/pnpm/src/parseCliArgs.ts b/pnpm/src/parseCliArgs.ts index ed036ec318..7d0d2090b3 100644 --- a/pnpm/src/parseCliArgs.ts +++ b/pnpm/src/parseCliArgs.ts @@ -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 [args]` is sugar for + // `pnpm [global-opts] --pm-on-fail=ignore [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 [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 +} diff --git a/pnpm/src/switchCliVersion.ts b/pnpm/src/switchCliVersion.ts index 1aa4732301..117a6d8cd5 100644 --- a/pnpm/src/switchCliVersion.ts +++ b/pnpm/src/switchCliVersion.ts @@ -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) { diff --git a/pnpm/test/packageManagerCheck.test.ts b/pnpm/test/packageManagerCheck.test.ts index 85e2a6aa3f..a5b277b700 100644 --- a/pnpm/test/packageManagerCheck.test.ts +++ b/pnpm/test/packageManagerCheck.test.ts @@ -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') +}) diff --git a/pnpm/test/withCommand.test.ts b/pnpm/test/withCommand.test.ts new file mode 100644 index 0000000000..c75af70748 --- /dev/null +++ b/pnpm/test/withCommand.test.ts @@ -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 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 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') +})