feat: add pnpm runtime set command and deprecate pnpm env use (#10671)

* feat: add `pnpm runtime set` command and deprecate `pnpm env use`

Add a new `pnpm runtime set <name> <version> [-g]` command (alias: `rt`)
in a new `@pnpm/runtime.commands` package. It delegates to
`pnpm add <name>@runtime:<version>` supporting node, deno, and bun.

`pnpm env use` now prints a deprecation warning pointing users to the
new command.

* fix: address PR review feedback for runtime command

- Use opts.dir as cwd when --global is not passed (instead of always
  using pnpmHomeDir)
- Only pass --global-bin-dir when in global mode
- Keep deprecated env command in help output for discoverability
- Add Jest tests for the runtime command (7 tests)
This commit is contained in:
Zoltan Kochan
2026-03-01 18:14:57 +01:00
committed by GitHub
parent 543c7e4bae
commit 2efb5d2919
15 changed files with 386 additions and 7 deletions

View File

@@ -0,0 +1,7 @@
---
"@pnpm/plugin-commands-env": patch
"@pnpm/runtime.commands": minor
"pnpm": minor
---
Added a new command `pnpm runtime set <runtime name> <runtime version spec> [-g]` for installing runtimes. Deprecated `pnpm env use` in favor of the new command.

View File

@@ -1,8 +1,10 @@
import { PnpmError } from '@pnpm/error' import { PnpmError } from '@pnpm/error'
import { runPnpmCli } from '@pnpm/exec.pnpm-cli-runner' import { runPnpmCli } from '@pnpm/exec.pnpm-cli-runner'
import { globalWarn } from '@pnpm/logger'
import { type NvmNodeCommandOptions } from './node.js' import { type NvmNodeCommandOptions } from './node.js'
export async function envUse (opts: NvmNodeCommandOptions, params: string[]): Promise<void> { export async function envUse (opts: NvmNodeCommandOptions, params: string[]): Promise<void> {
globalWarn('"pnpm env use" is deprecated. Use "pnpm runtime set node <version> -g" instead.')
if (!opts.global) { if (!opts.global) {
throw new PnpmError('NOT_IMPLEMENTED_YET', '"pnpm env use <version>" can only be used with the "--global" option currently') throw new PnpmError('NOT_IMPLEMENTED_YET', '"pnpm env use <version>" can only be used with the "--global" option currently')
} }

34
pnpm-lock.yaml generated
View File

@@ -6720,6 +6720,9 @@ importers:
'@pnpm/run-npm': '@pnpm/run-npm':
specifier: workspace:* specifier: workspace:*
version: link:../exec/run-npm version: link:../exec/run-npm
'@pnpm/runtime.commands':
specifier: workspace:*
version: link:../runtime/commands
'@pnpm/store.cafs': '@pnpm/store.cafs':
specifier: workspace:* specifier: workspace:*
version: link:../store/cafs version: link:../store/cafs
@@ -8279,6 +8282,37 @@ importers:
specifier: 'catalog:' specifier: 'catalog:'
version: 7.1.5 version: 7.1.5
runtime/commands:
dependencies:
'@pnpm/cli-utils':
specifier: workspace:*
version: link:../../cli/cli-utils
'@pnpm/config':
specifier: workspace:*
version: link:../../config/config
'@pnpm/error':
specifier: workspace:*
version: link:../../packages/error
'@pnpm/exec.pnpm-cli-runner':
specifier: workspace:*
version: link:../../exec/pnpm-cli-runner
render-help:
specifier: 'catalog:'
version: 1.0.3
devDependencies:
'@jest/globals':
specifier: 'catalog:'
version: 30.0.5
'@pnpm/logger':
specifier: workspace:*
version: link:../../packages/logger
'@pnpm/runtime.commands':
specifier: workspace:*
version: 'link:'
cross-env:
specifier: 'catalog:'
version: 10.1.0
semver/peer-range: semver/peer-range:
dependencies: dependencies:
semver: semver:

View File

@@ -37,6 +37,7 @@ packages:
- releasing/* - releasing/*
- resolving/* - resolving/*
- reviewing/* - reviewing/*
- runtime/*
- semver/* - semver/*
- store/* - store/*
- text/* - text/*

View File

@@ -123,6 +123,7 @@
"@pnpm/plugin-commands-store-inspecting": "workspace:*", "@pnpm/plugin-commands-store-inspecting": "workspace:*",
"@pnpm/prepare": "workspace:*", "@pnpm/prepare": "workspace:*",
"@pnpm/read-package-json": "workspace:*", "@pnpm/read-package-json": "workspace:*",
"@pnpm/runtime.commands": "workspace:*",
"@pnpm/read-project-manifest": "workspace:*", "@pnpm/read-project-manifest": "workspace:*",
"@pnpm/registry-mock": "catalog:", "@pnpm/registry-mock": "catalog:",
"@pnpm/run-npm": "workspace:*", "@pnpm/run-npm": "workspace:*",

View File

@@ -243,10 +243,6 @@ function getHelpText ({ all }: { all: boolean }): string {
description: 'Publishes a package to the registry', description: 'Publishes a package to the registry',
name: 'publish', name: 'publish',
}, },
{
description: 'Updates pnpm to the latest version',
name: 'self-update',
},
{ {
description: 'Create a package.json file', description: 'Create a package.json file',
name: 'init', name: 'init',
@@ -279,13 +275,22 @@ function getHelpText ({ all }: { all: boolean }): string {
], ],
}, },
{ {
title: 'Manage your environments', title: 'Manage your engines',
advanced: true, advanced: true,
list: [ list: [
{ {
description: 'Manage Node.js versions', description: 'Manage runtimes',
name: 'env ', name: 'runtime',
shortAlias: 'rt',
},
{
description: 'Manage Node.js versions (deprecated, use runtime)',
name: 'env',
},
{
description: 'Updates pnpm to the latest version',
name: 'self-update',
}, },
], ],
}, },

View File

@@ -7,6 +7,7 @@ import { generateCompletion, createCompletionServer } from '@pnpm/plugin-command
import { config, getCommand, setCommand } from '@pnpm/plugin-commands-config' import { config, getCommand, setCommand } from '@pnpm/plugin-commands-config'
import { doctor } from '@pnpm/plugin-commands-doctor' import { doctor } from '@pnpm/plugin-commands-doctor'
import { env } from '@pnpm/plugin-commands-env' import { env } from '@pnpm/plugin-commands-env'
import { runtime } from '@pnpm/runtime.commands'
import { deploy } from '@pnpm/plugin-commands-deploy' import { deploy } from '@pnpm/plugin-commands-deploy'
import { add, ci, dedupe, fetch, install, link, prune, remove, unlink, update, importCommand } from '@pnpm/plugin-commands-installation' import { add, ci, dedupe, fetch, install, link, prune, remove, unlink, update, importCommand } from '@pnpm/plugin-commands-installation'
import { selfUpdate } from '@pnpm/tools.plugin-commands-self-updater' import { selfUpdate } from '@pnpm/tools.plugin-commands-self-updater'
@@ -130,6 +131,7 @@ const commands: CommandDefinition[] = [
doctor, doctor,
env, env,
exec, exec,
runtime,
fetch, fetch,
generateCompletion, generateCompletion,
ignoredBuilds, ignoredBuilds,

View File

@@ -152,6 +152,9 @@
{ {
"path": "../reviewing/plugin-commands-sbom" "path": "../reviewing/plugin-commands-sbom"
}, },
{
"path": "../runtime/commands"
},
{ {
"path": "../store/cafs" "path": "../store/cafs"
}, },

View File

@@ -0,0 +1,56 @@
{
"name": "@pnpm/runtime.commands",
"version": "1000.0.0-0",
"description": "pnpm commands for managing runtimes",
"keywords": [
"pnpm",
"pnpm11",
"runtime"
],
"license": "MIT",
"funding": "https://opencollective.com/pnpm",
"repository": "https://github.com/pnpm/pnpm/tree/main/runtime/commands",
"homepage": "https://github.com/pnpm/pnpm/tree/main/runtime/commands#readme",
"bugs": {
"url": "https://github.com/pnpm/pnpm/issues"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
".": "./lib/index.js"
},
"files": [
"lib",
"!*.map"
],
"scripts": {
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
"_test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest",
"prepublishOnly": "pnpm run compile",
"compile": "tsgo --build && pnpm run lint --fix",
"test": "pnpm run compile && pnpm run _test"
},
"dependencies": {
"@pnpm/cli-utils": "workspace:*",
"@pnpm/config": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/exec.pnpm-cli-runner": "workspace:*",
"render-help": "catalog:"
},
"peerDependencies": {
"@pnpm/logger": "catalog:"
},
"devDependencies": {
"@jest/globals": "catalog:",
"@pnpm/logger": "workspace:*",
"@pnpm/runtime.commands": "workspace:*",
"cross-env": "catalog:"
},
"engines": {
"node": ">=22.13"
},
"jest": {
"preset": "@pnpm/jest-config"
}
}

View File

@@ -0,0 +1,3 @@
import * as runtime from './runtime.js'
export { runtime }

View File

@@ -0,0 +1,101 @@
import { docsUrl } from '@pnpm/cli-utils'
import { type Config } from '@pnpm/config'
import { PnpmError } from '@pnpm/error'
import { runPnpmCli } from '@pnpm/exec.pnpm-cli-runner'
import renderHelp from 'render-help'
export type RuntimeCommandOptions = Pick<Config,
| 'bin'
| 'dir'
| 'global'
| 'pnpmHomeDir'
> & Partial<Pick<Config,
| 'storeDir'
| 'cacheDir'
>>
export const skipPackageManagerCheck = true
export function rcOptionsTypes (): Record<string, unknown> {
return {}
}
export function cliOptionsTypes (): Record<string, unknown> {
return {
global: Boolean,
}
}
export const commandNames = ['runtime', 'rt']
export function help (): string {
return renderHelp({
description: 'Manage runtimes.',
descriptionLists: [
{
title: 'Commands',
list: [
{
description: 'Installs the specified version of a runtime (e.g. node, deno, bun).',
name: 'set',
},
],
},
{
title: 'Options',
list: [
{
description: 'Installs the runtime globally',
name: '--global',
shortAlias: '-g',
},
],
},
],
url: docsUrl('runtime'),
usages: [
'pnpm runtime set node 22 -g',
'pnpm runtime set node lts -g',
'pnpm runtime set node rc/22 -g',
'pnpm runtime set deno 2 -g',
'pnpm runtime set bun latest -g',
],
})
}
export async function handler (opts: RuntimeCommandOptions, params: string[]): Promise<void> {
if (params.length === 0) {
throw new PnpmError('RUNTIME_NO_SUBCOMMAND', 'Please specify the subcommand', {
hint: help(),
})
}
switch (params[0]) {
case 'set': {
runtimeSet(opts, params.slice(1))
return
}
default: {
throw new PnpmError('RUNTIME_UNKNOWN_SUBCOMMAND', `Unknown subcommand: ${params[0]}`, {
hint: help(),
})
}
}
}
function runtimeSet (opts: RuntimeCommandOptions, params: string[]): void {
const runtimeName = params[0]?.trim()
if (!runtimeName) {
throw new PnpmError('MISSING_RUNTIME_NAME', '"pnpm runtime set <name> <version>" requires a runtime name (e.g. node, deno, bun)')
}
const versionSpec = params[1]?.trim()
const args = ['add', `${runtimeName}@runtime:${versionSpec ?? ''}`]
if (opts.global) {
args.push('--global')
if (opts.bin) args.push('--global-bin-dir', opts.bin)
}
if (opts.storeDir) args.push('--store-dir', opts.storeDir)
if (opts.cacheDir) args.push('--cache-dir', opts.cacheDir)
runPnpmCli(args, { cwd: opts.global ? opts.pnpmHomeDir : opts.dir })
}

View File

@@ -0,0 +1,110 @@
import { jest } from '@jest/globals'
import { PnpmError } from '@pnpm/error'
const mockRunPnpmCli = jest.fn()
jest.unstable_mockModule('@pnpm/exec.pnpm-cli-runner', () => ({
runPnpmCli: mockRunPnpmCli,
}))
const { runtime } = await import('@pnpm/runtime.commands')
beforeEach(() => {
mockRunPnpmCli.mockClear()
})
test('runtime set calls pnpm add with the correct arguments globally', async () => {
await runtime.handler({
bin: '/usr/local/bin',
cacheDir: '/tmp/cache',
dir: '/tmp/project',
global: true,
pnpmHomeDir: '/tmp/pnpm-home',
storeDir: '/tmp/store',
}, ['set', 'node', '22'])
expect(mockRunPnpmCli).toHaveBeenCalledWith(
['add', 'node@runtime:22', '--global', '--global-bin-dir', '/usr/local/bin', '--store-dir', '/tmp/store', '--cache-dir', '/tmp/cache'],
{ cwd: '/tmp/pnpm-home' }
)
})
test('runtime set uses project dir when not global', async () => {
await runtime.handler({
bin: '/usr/local/bin',
dir: '/tmp/project',
global: false,
pnpmHomeDir: '/tmp/pnpm-home',
}, ['set', 'node', '22'])
expect(mockRunPnpmCli).toHaveBeenCalledWith(
['add', 'node@runtime:22'],
{ cwd: '/tmp/project' }
)
})
test('runtime set without version spec', async () => {
await runtime.handler({
bin: '/usr/local/bin',
dir: '/tmp/project',
global: true,
pnpmHomeDir: '/tmp/pnpm-home',
}, ['set', 'node'])
expect(mockRunPnpmCli).toHaveBeenCalledWith(
['add', 'node@runtime:', '--global', '--global-bin-dir', '/usr/local/bin'],
{ cwd: '/tmp/pnpm-home' }
)
})
test('runtime set works with deno', async () => {
await runtime.handler({
bin: '/usr/local/bin',
dir: '/tmp/project',
global: true,
pnpmHomeDir: '/tmp/pnpm-home',
}, ['set', 'deno', '2'])
expect(mockRunPnpmCli).toHaveBeenCalledWith(
['add', 'deno@runtime:2', '--global', '--global-bin-dir', '/usr/local/bin'],
{ cwd: '/tmp/pnpm-home' }
)
})
test('fail if no subcommand is given', async () => {
await expect(
runtime.handler({
bin: '/usr/local/bin',
dir: '/tmp/project',
global: true,
pnpmHomeDir: '/tmp/pnpm-home',
}, [])
).rejects.toEqual(new PnpmError('RUNTIME_NO_SUBCOMMAND', 'Please specify the subcommand'))
expect(mockRunPnpmCli).not.toHaveBeenCalled()
})
test('fail if unknown subcommand is given', async () => {
await expect(
runtime.handler({
bin: '/usr/local/bin',
dir: '/tmp/project',
global: true,
pnpmHomeDir: '/tmp/pnpm-home',
}, ['foo'])
).rejects.toEqual(new PnpmError('RUNTIME_UNKNOWN_SUBCOMMAND', 'Unknown subcommand: foo'))
expect(mockRunPnpmCli).not.toHaveBeenCalled()
})
test('fail if runtime name is missing', async () => {
await expect(
runtime.handler({
bin: '/usr/local/bin',
dir: '/tmp/project',
global: true,
pnpmHomeDir: '/tmp/pnpm-home',
}, ['set'])
).rejects.toEqual(new PnpmError('MISSING_RUNTIME_NAME', '"pnpm runtime set <name> <version>" requires a runtime name (e.g. node, deno, bun)'))
expect(mockRunPnpmCli).not.toHaveBeenCalled()
})

View File

@@ -0,0 +1,18 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "../node_modules/.test.lib",
"rootDir": "..",
"isolatedModules": true
},
"include": [
"**/*.ts",
"../../../__typings__/**/*.d.ts"
],
"references": [
{
"path": ".."
}
]
}

View File

@@ -0,0 +1,28 @@
{
"extends": "@pnpm/tsconfig",
"compilerOptions": {
"outDir": "lib",
"rootDir": "src"
},
"include": [
"src/**/*.ts",
"../../__typings__/**/*.d.ts"
],
"references": [
{
"path": "../../cli/cli-utils"
},
{
"path": "../../config/config"
},
{
"path": "../../exec/pnpm-cli-runner"
},
{
"path": "../../packages/error"
},
{
"path": "../../packages/logger"
}
]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"include": [
"src/**/*.ts",
"test/**/*.ts",
"../../__typings__/**/*.d.ts"
]
}