mirror of
https://github.com/pnpm/pnpm.git
synced 2026-04-27 18:46:18 -04:00
feat: add runtimeOnFail setting (#11277)
* feat: add runtimeOnFail setting
Adds a `runtimeOnFail` config setting ('ignore' | 'warn' | 'error' |
'download') that overrides the `onFail` field on `devEngines.runtime`
and `engines.runtime` in the root project's package.json. This makes
it possible to opt into (or out of) runtime auto-download without
changing the project manifest.
* fix: skip runtime download when version is missing
Without a version, convertEnginesRuntimeToDependencies would write
`runtime:undefined` into the manifest. Warn and skip instead.
* feat: apply runtimeOnFail override during install
The config reader override only mutates the context's rootProjectManifest,
but installDeps reads the manifest fresh via tryReadProjectManifest and
findWorkspaceProjects. Apply the override there too so `runtimeOnFail`
actually affects what gets installed. Adds an e2e test covering both
download and ignore overrides through the real CLI bundle.
This commit is contained in:
8
.changeset/runtime-on-fail.md
Normal file
8
.changeset/runtime-on-fail.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
"@pnpm/config.reader": minor
|
||||
"@pnpm/installing.commands": minor
|
||||
"@pnpm/pkg-manifest.utils": minor
|
||||
"pnpm": minor
|
||||
---
|
||||
|
||||
Added a new setting `runtimeOnFail` that overrides the `onFail` field of `devEngines.runtime` (and `engines.runtime`) in the root project's `package.json`. Accepted values: `ignore`, `warn`, `error`, `download`. For example, setting `runtimeOnFail=download` makes pnpm download the declared runtime version even when the manifest does not set `onFail: "download"`.
|
||||
@@ -41,6 +41,7 @@
|
||||
"@pnpm/error": "workspace:*",
|
||||
"@pnpm/hooks.pnpmfile": "workspace:*",
|
||||
"@pnpm/network.git-utils": "workspace:*",
|
||||
"@pnpm/pkg-manifest.utils": "workspace:*",
|
||||
"@pnpm/text.naming-cases": "workspace:*",
|
||||
"@pnpm/types": "workspace:*",
|
||||
"@pnpm/workspace.project-manifest-reader": "workspace:*",
|
||||
|
||||
@@ -235,6 +235,7 @@ export interface Config extends OptionsFromRootManifest {
|
||||
dedupeInjectedDeps?: boolean
|
||||
nodeOptions?: string
|
||||
pmOnFail?: 'download' | 'error' | 'warn' | 'ignore'
|
||||
runtimeOnFail?: 'download' | 'error' | 'warn' | 'ignore'
|
||||
virtualStoreDirMaxLength: number
|
||||
peersSuffixMaxLength?: number
|
||||
strictStorePkgContentCheck: boolean
|
||||
|
||||
@@ -110,6 +110,7 @@ export const excludedPnpmKeys = [
|
||||
'publish-branch',
|
||||
'recursive-install',
|
||||
'resolve-peers-from-workspace-root',
|
||||
'runtime-on-fail',
|
||||
'aggregate-output',
|
||||
'reporter-hide-prefix',
|
||||
'save-catalog-name',
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createMatcher } from '@pnpm/config.matcher'
|
||||
import { GLOBAL_CONFIG_YAML_FILENAME, GLOBAL_LAYOUT_VERSION } from '@pnpm/constants'
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import { getCurrentBranch } from '@pnpm/network.git-utils'
|
||||
import { applyRuntimeOnFailOverride } from '@pnpm/pkg-manifest.utils'
|
||||
import { isCamelCase } from '@pnpm/text.naming-cases'
|
||||
import type { DevEngines, EngineDependency, ProjectManifest } from '@pnpm/types'
|
||||
import { safeReadProjectManifestOnly } from '@pnpm/workspace.project-manifest-reader'
|
||||
@@ -627,6 +628,10 @@ export async function getConfig (opts: {
|
||||
}
|
||||
}
|
||||
|
||||
if (pnpmConfig.runtimeOnFail && pnpmConfig.rootProjectManifest) {
|
||||
applyRuntimeOnFailOverride(pnpmConfig.rootProjectManifest, pnpmConfig.runtimeOnFail)
|
||||
}
|
||||
|
||||
const {
|
||||
hooks, finders,
|
||||
allProjects, selectedProjectsGraph, allProjectsGraph,
|
||||
|
||||
@@ -97,6 +97,7 @@ export const pnpmTypes = {
|
||||
reporter: String,
|
||||
'resolution-mode': ['highest', 'time-based', 'lowest-direct'],
|
||||
'resolve-peers-from-workspace-root': Boolean,
|
||||
'runtime-on-fail': ['ignore', 'warn', 'error', 'download'],
|
||||
'aggregate-output': Boolean,
|
||||
'reporter-hide-prefix': Boolean,
|
||||
'save-peer': Boolean,
|
||||
|
||||
@@ -106,6 +106,65 @@ test('nodeVersion from config takes priority over devEngines.runtime', async ()
|
||||
expect(config.nodeVersion).toBe('20.0.0')
|
||||
})
|
||||
|
||||
test('runtimeOnFail=download overrides devEngines.runtime.onFail and adds node to devDependencies', async () => {
|
||||
prepare({
|
||||
devEngines: {
|
||||
runtime: {
|
||||
name: 'node',
|
||||
version: '22.20.0',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { config, context } = await getConfig({
|
||||
cliOptions: {
|
||||
'runtime-on-fail': 'download',
|
||||
},
|
||||
packageManager: {
|
||||
name: 'pnpm',
|
||||
version: '1.0.0',
|
||||
},
|
||||
})
|
||||
|
||||
expect(config.runtimeOnFail).toBe('download')
|
||||
const runtime = context.rootProjectManifest?.devEngines?.runtime
|
||||
expect(Array.isArray(runtime) ? runtime[0] : runtime).toMatchObject({
|
||||
name: 'node',
|
||||
onFail: 'download',
|
||||
})
|
||||
expect(context.rootProjectManifest?.devDependencies?.node).toBe('runtime:22.20.0')
|
||||
})
|
||||
|
||||
test('runtimeOnFail=ignore overrides an existing onFail=download and removes node from devDependencies', async () => {
|
||||
prepare({
|
||||
devEngines: {
|
||||
runtime: {
|
||||
name: 'node',
|
||||
version: '22.20.0',
|
||||
onFail: 'download',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { config, context } = await getConfig({
|
||||
cliOptions: {
|
||||
'runtime-on-fail': 'ignore',
|
||||
},
|
||||
packageManager: {
|
||||
name: 'pnpm',
|
||||
version: '1.0.0',
|
||||
},
|
||||
})
|
||||
|
||||
expect(config.runtimeOnFail).toBe('ignore')
|
||||
const runtime = context.rootProjectManifest?.devEngines?.runtime
|
||||
expect(Array.isArray(runtime) ? runtime[0] : runtime).toMatchObject({
|
||||
name: 'node',
|
||||
onFail: 'ignore',
|
||||
})
|
||||
expect(context.rootProjectManifest?.devDependencies?.node).toBeUndefined()
|
||||
})
|
||||
|
||||
test('throw error if --link-workspace-packages is used with --global', async () => {
|
||||
await expect(getConfig({
|
||||
cliOptions: {
|
||||
|
||||
@@ -36,6 +36,9 @@
|
||||
{
|
||||
"path": "../../network/git-utils"
|
||||
},
|
||||
{
|
||||
"path": "../../pkg-manifest/utils"
|
||||
},
|
||||
{
|
||||
"path": "../../text/naming-cases"
|
||||
},
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from '@pnpm/installing.deps-installer'
|
||||
import type { LockfileObject } from '@pnpm/lockfile.types'
|
||||
import { globalInfo, logger } from '@pnpm/logger'
|
||||
import { filterDependenciesByType } from '@pnpm/pkg-manifest.utils'
|
||||
import { applyRuntimeOnFailOverride, filterDependenciesByType } from '@pnpm/pkg-manifest.utils'
|
||||
import type { PreferredVersions, VersionSelectors } from '@pnpm/resolving.resolver-base'
|
||||
import { createStoreController, type CreateStoreControllerOptions } from '@pnpm/store.connection-manager'
|
||||
import type {
|
||||
@@ -83,6 +83,7 @@ export type InstallDepsOptions = Pick<Config,
|
||||
| 'production'
|
||||
| 'preferWorkspacePackages'
|
||||
| 'registries'
|
||||
| 'runtimeOnFail'
|
||||
| 'save'
|
||||
| 'saveDev'
|
||||
| 'saveExact'
|
||||
@@ -200,6 +201,11 @@ export async function installDeps (
|
||||
? await findWorkspaceProjects(opts.workspaceDir, { ...opts, patterns: opts.workspacePackagePatterns })
|
||||
: []
|
||||
)
|
||||
if (opts.runtimeOnFail) {
|
||||
for (const project of allProjects) {
|
||||
applyRuntimeOnFailOverride(project.manifest, opts.runtimeOnFail)
|
||||
}
|
||||
}
|
||||
if (opts.workspaceDir) {
|
||||
const selectedProjectsGraph = opts.selectedProjectsGraph ?? selectProjectByDir(allProjects, opts.dir)
|
||||
if (selectedProjectsGraph != null) {
|
||||
@@ -255,6 +261,8 @@ export async function installDeps (
|
||||
throw new PnpmError('NO_PKG_MANIFEST', `No package.json found in ${opts.dir}`)
|
||||
}
|
||||
manifest = {}
|
||||
} else if (opts.runtimeOnFail) {
|
||||
applyRuntimeOnFailOverride(manifest, opts.runtimeOnFail)
|
||||
}
|
||||
|
||||
const installOpts: Omit<MutateModulesOptions, 'allProjects'> = {
|
||||
|
||||
@@ -5,12 +5,14 @@ import type {
|
||||
ProjectManifest,
|
||||
} from '@pnpm/types'
|
||||
|
||||
const RUNTIME_NAMES = ['node', 'deno', 'bun'] as const
|
||||
|
||||
export function convertEnginesRuntimeToDependencies (
|
||||
manifest: ProjectManifest,
|
||||
enginesFieldName: 'devEngines' | 'engines',
|
||||
dependenciesFieldName: DependenciesField
|
||||
): void {
|
||||
for (const runtimeName of ['node', 'deno', 'bun']) {
|
||||
for (const runtimeName of RUNTIME_NAMES) {
|
||||
const enginesFieldRuntime = manifest[enginesFieldName]?.runtime
|
||||
if (enginesFieldRuntime == null || manifest[dependenciesFieldName]?.[runtimeName]) {
|
||||
continue
|
||||
@@ -20,6 +22,10 @@ export function convertEnginesRuntimeToDependencies (
|
||||
if (runtime?.onFail !== 'download') {
|
||||
continue
|
||||
}
|
||||
if (!runtime.version) {
|
||||
globalWarn(`Cannot download ${runtimeName} because no version is specified in ${enginesFieldName}.runtime`)
|
||||
continue
|
||||
}
|
||||
if ('webcontainer' in process.versions) {
|
||||
globalWarn(`Installation of ${runtimeName} versions is not supported in WebContainer`)
|
||||
} else {
|
||||
@@ -28,3 +34,32 @@ export function convertEnginesRuntimeToDependencies (
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function applyRuntimeOnFailOverride (
|
||||
manifest: ProjectManifest,
|
||||
onFailOverride: 'ignore' | 'warn' | 'error' | 'download'
|
||||
): void {
|
||||
for (const [enginesFieldName, dependenciesFieldName] of [
|
||||
['devEngines', 'devDependencies'],
|
||||
['engines', 'dependencies'],
|
||||
] as const) {
|
||||
const enginesFieldRuntime = manifest[enginesFieldName]?.runtime
|
||||
if (enginesFieldRuntime == null) continue
|
||||
const runtimes: EngineDependency[] = Array.isArray(enginesFieldRuntime) ? enginesFieldRuntime : [enginesFieldRuntime]
|
||||
for (const runtime of runtimes) {
|
||||
runtime.onFail = onFailOverride
|
||||
}
|
||||
if (onFailOverride !== 'download') {
|
||||
const deps = manifest[dependenciesFieldName]
|
||||
if (deps) {
|
||||
for (const runtimeName of RUNTIME_NAMES) {
|
||||
if (typeof deps[runtimeName] === 'string' && deps[runtimeName].startsWith('runtime:')) {
|
||||
delete deps[runtimeName]
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
convertEnginesRuntimeToDependencies(manifest, enginesFieldName, dependenciesFieldName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
applyRuntimeOnFailOverride,
|
||||
convertEnginesRuntimeToDependencies,
|
||||
} from '@pnpm/pkg-manifest.utils'
|
||||
import type { ProjectManifest } from '@pnpm/types'
|
||||
|
||||
test('convertEnginesRuntimeToDependencies() skips runtime entries without a version', () => {
|
||||
const manifest: ProjectManifest = {
|
||||
devEngines: {
|
||||
runtime: {
|
||||
name: 'node',
|
||||
onFail: 'download',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
convertEnginesRuntimeToDependencies(manifest, 'devEngines', 'devDependencies')
|
||||
|
||||
expect(manifest.devDependencies).toBeUndefined()
|
||||
})
|
||||
|
||||
test('applyRuntimeOnFailOverride(download) skips runtime entries without a version', () => {
|
||||
const manifest: ProjectManifest = {
|
||||
devEngines: {
|
||||
runtime: {
|
||||
name: 'node',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
applyRuntimeOnFailOverride(manifest, 'download')
|
||||
|
||||
expect(manifest.devEngines?.runtime).toMatchObject({ name: 'node', onFail: 'download' })
|
||||
expect(manifest.devDependencies).toBeUndefined()
|
||||
})
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -2466,6 +2466,9 @@ importers:
|
||||
'@pnpm/network.git-utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../network/git-utils
|
||||
'@pnpm/pkg-manifest.utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../pkg-manifest/utils
|
||||
'@pnpm/text.naming-cases':
|
||||
specifier: workspace:*
|
||||
version: link:../../text/naming-cases
|
||||
|
||||
46
pnpm/test/install/runtimeOnFail.ts
Normal file
46
pnpm/test/install/runtimeOnFail.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import fs from 'node:fs'
|
||||
|
||||
import { prepare } from '@pnpm/prepare'
|
||||
|
||||
import { execPnpm } from '../utils/index.js'
|
||||
|
||||
test('runtimeOnFail=download causes Node.js to be downloaded even when the manifest does not set onFail', async () => {
|
||||
const project = prepare({
|
||||
devEngines: {
|
||||
runtime: {
|
||||
name: 'node',
|
||||
version: '24.0.0',
|
||||
},
|
||||
},
|
||||
})
|
||||
fs.writeFileSync('pnpm-workspace.yaml', 'runtimeOnFail: download\n', 'utf8')
|
||||
|
||||
await execPnpm(['install'])
|
||||
|
||||
project.isExecutable('.bin/node')
|
||||
const lockfile = project.readLockfile()
|
||||
expect(lockfile.importers['.'].devDependencies).toStrictEqual({
|
||||
node: {
|
||||
specifier: 'runtime:24.0.0',
|
||||
version: 'runtime:24.0.0',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('runtimeOnFail=ignore prevents Node.js download even when manifest sets onFail=download', async () => {
|
||||
const project = prepare({
|
||||
devEngines: {
|
||||
runtime: {
|
||||
name: 'node',
|
||||
version: '24.0.0',
|
||||
onFail: 'download',
|
||||
},
|
||||
},
|
||||
})
|
||||
fs.writeFileSync('pnpm-workspace.yaml', 'runtimeOnFail: ignore\n', 'utf8')
|
||||
|
||||
await execPnpm(['install'])
|
||||
|
||||
const lockfile = project.readLockfile()
|
||||
expect(lockfile.importers['.'].devDependencies).toBeUndefined()
|
||||
})
|
||||
Reference in New Issue
Block a user