From 2efb5d29190fa6ef17cc332fe04934e071d4d293 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Sun, 1 Mar 2026 18:14:57 +0100 Subject: [PATCH] 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 [-g]` command (alias: `rt`) in a new `@pnpm/runtime.commands` package. It delegates to `pnpm add @runtime:` 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) --- .changeset/runtime-set-command.md | 7 ++ env/plugin-commands-env/src/envUse.ts | 2 + pnpm-lock.yaml | 34 ++++++++ pnpm-workspace.yaml | 1 + pnpm/package.json | 1 + pnpm/src/cmd/help.ts | 19 +++-- pnpm/src/cmd/index.ts | 2 + pnpm/tsconfig.json | 3 + runtime/commands/package.json | 56 +++++++++++++ runtime/commands/src/index.ts | 3 + runtime/commands/src/runtime.ts | 101 +++++++++++++++++++++++ runtime/commands/test/runtime.test.ts | 110 ++++++++++++++++++++++++++ runtime/commands/test/tsconfig.json | 18 +++++ runtime/commands/tsconfig.json | 28 +++++++ runtime/commands/tsconfig.lint.json | 8 ++ 15 files changed, 386 insertions(+), 7 deletions(-) create mode 100644 .changeset/runtime-set-command.md create mode 100644 runtime/commands/package.json create mode 100644 runtime/commands/src/index.ts create mode 100644 runtime/commands/src/runtime.ts create mode 100644 runtime/commands/test/runtime.test.ts create mode 100644 runtime/commands/test/tsconfig.json create mode 100644 runtime/commands/tsconfig.json create mode 100644 runtime/commands/tsconfig.lint.json diff --git a/.changeset/runtime-set-command.md b/.changeset/runtime-set-command.md new file mode 100644 index 0000000000..735069af68 --- /dev/null +++ b/.changeset/runtime-set-command.md @@ -0,0 +1,7 @@ +--- +"@pnpm/plugin-commands-env": patch +"@pnpm/runtime.commands": minor +"pnpm": minor +--- + +Added a new command `pnpm runtime set [-g]` for installing runtimes. Deprecated `pnpm env use` in favor of the new command. diff --git a/env/plugin-commands-env/src/envUse.ts b/env/plugin-commands-env/src/envUse.ts index 39df5c7298..ab27cfde59 100644 --- a/env/plugin-commands-env/src/envUse.ts +++ b/env/plugin-commands-env/src/envUse.ts @@ -1,8 +1,10 @@ import { PnpmError } from '@pnpm/error' import { runPnpmCli } from '@pnpm/exec.pnpm-cli-runner' +import { globalWarn } from '@pnpm/logger' import { type NvmNodeCommandOptions } from './node.js' export async function envUse (opts: NvmNodeCommandOptions, params: string[]): Promise { + globalWarn('"pnpm env use" is deprecated. Use "pnpm runtime set node -g" instead.') if (!opts.global) { throw new PnpmError('NOT_IMPLEMENTED_YET', '"pnpm env use " can only be used with the "--global" option currently') } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67c4cb4bd3..93351cb0a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6720,6 +6720,9 @@ importers: '@pnpm/run-npm': specifier: workspace:* version: link:../exec/run-npm + '@pnpm/runtime.commands': + specifier: workspace:* + version: link:../runtime/commands '@pnpm/store.cafs': specifier: workspace:* version: link:../store/cafs @@ -8279,6 +8282,37 @@ importers: specifier: 'catalog:' 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: dependencies: semver: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d9c94733b5..c7f0373635 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -37,6 +37,7 @@ packages: - releasing/* - resolving/* - reviewing/* + - runtime/* - semver/* - store/* - text/* diff --git a/pnpm/package.json b/pnpm/package.json index 216fe4c014..8a86d959af 100644 --- a/pnpm/package.json +++ b/pnpm/package.json @@ -123,6 +123,7 @@ "@pnpm/plugin-commands-store-inspecting": "workspace:*", "@pnpm/prepare": "workspace:*", "@pnpm/read-package-json": "workspace:*", + "@pnpm/runtime.commands": "workspace:*", "@pnpm/read-project-manifest": "workspace:*", "@pnpm/registry-mock": "catalog:", "@pnpm/run-npm": "workspace:*", diff --git a/pnpm/src/cmd/help.ts b/pnpm/src/cmd/help.ts index a8570cfa8f..275b30acf9 100644 --- a/pnpm/src/cmd/help.ts +++ b/pnpm/src/cmd/help.ts @@ -243,10 +243,6 @@ function getHelpText ({ all }: { all: boolean }): string { description: 'Publishes a package to the registry', name: 'publish', }, - { - description: 'Updates pnpm to the latest version', - name: 'self-update', - }, { description: 'Create a package.json file', name: 'init', @@ -279,13 +275,22 @@ function getHelpText ({ all }: { all: boolean }): string { ], }, { - title: 'Manage your environments', + title: 'Manage your engines', advanced: true, list: [ { - description: 'Manage Node.js versions', - name: 'env ', + description: 'Manage runtimes', + name: 'runtime', + shortAlias: 'rt', + }, + { + description: 'Manage Node.js versions (deprecated, use runtime)', + name: 'env', + }, + { + description: 'Updates pnpm to the latest version', + name: 'self-update', }, ], }, diff --git a/pnpm/src/cmd/index.ts b/pnpm/src/cmd/index.ts index a64a1c515d..a14a0f4c76 100644 --- a/pnpm/src/cmd/index.ts +++ b/pnpm/src/cmd/index.ts @@ -7,6 +7,7 @@ import { generateCompletion, createCompletionServer } from '@pnpm/plugin-command import { config, getCommand, setCommand } from '@pnpm/plugin-commands-config' import { doctor } from '@pnpm/plugin-commands-doctor' import { env } from '@pnpm/plugin-commands-env' +import { runtime } from '@pnpm/runtime.commands' 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 { selfUpdate } from '@pnpm/tools.plugin-commands-self-updater' @@ -130,6 +131,7 @@ const commands: CommandDefinition[] = [ doctor, env, exec, + runtime, fetch, generateCompletion, ignoredBuilds, diff --git a/pnpm/tsconfig.json b/pnpm/tsconfig.json index 6175c5faf7..5cb66ff712 100644 --- a/pnpm/tsconfig.json +++ b/pnpm/tsconfig.json @@ -152,6 +152,9 @@ { "path": "../reviewing/plugin-commands-sbom" }, + { + "path": "../runtime/commands" + }, { "path": "../store/cafs" }, diff --git a/runtime/commands/package.json b/runtime/commands/package.json new file mode 100644 index 0000000000..73ea064cde --- /dev/null +++ b/runtime/commands/package.json @@ -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" + } +} diff --git a/runtime/commands/src/index.ts b/runtime/commands/src/index.ts new file mode 100644 index 0000000000..c0c43d6d53 --- /dev/null +++ b/runtime/commands/src/index.ts @@ -0,0 +1,3 @@ +import * as runtime from './runtime.js' + +export { runtime } diff --git a/runtime/commands/src/runtime.ts b/runtime/commands/src/runtime.ts new file mode 100644 index 0000000000..73841ba197 --- /dev/null +++ b/runtime/commands/src/runtime.ts @@ -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 & Partial> + +export const skipPackageManagerCheck = true + +export function rcOptionsTypes (): Record { + return {} +} + +export function cliOptionsTypes (): Record { + 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 { + 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 " 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 }) +} diff --git a/runtime/commands/test/runtime.test.ts b/runtime/commands/test/runtime.test.ts new file mode 100644 index 0000000000..ddfd002c27 --- /dev/null +++ b/runtime/commands/test/runtime.test.ts @@ -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 " requires a runtime name (e.g. node, deno, bun)')) + + expect(mockRunPnpmCli).not.toHaveBeenCalled() +}) diff --git a/runtime/commands/test/tsconfig.json b/runtime/commands/test/tsconfig.json new file mode 100644 index 0000000000..67ce5e1d0e --- /dev/null +++ b/runtime/commands/test/tsconfig.json @@ -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": ".." + } + ] +} diff --git a/runtime/commands/tsconfig.json b/runtime/commands/tsconfig.json new file mode 100644 index 0000000000..27ae7f74ec --- /dev/null +++ b/runtime/commands/tsconfig.json @@ -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" + } + ] +} diff --git a/runtime/commands/tsconfig.lint.json b/runtime/commands/tsconfig.lint.json new file mode 100644 index 0000000000..1bbe711971 --- /dev/null +++ b/runtime/commands/tsconfig.lint.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src/**/*.ts", + "test/**/*.ts", + "../../__typings__/**/*.d.ts" + ] +}