diff --git a/.changeset/no-runtime-flag.md b/.changeset/no-runtime-flag.md new file mode 100644 index 0000000000..53a7abb6cb --- /dev/null +++ b/.changeset/no-runtime-flag.md @@ -0,0 +1,11 @@ +--- +"@pnpm/lockfile.filtering": minor +"@pnpm/installing.linking.modules-cleaner": minor +"@pnpm/installing.deps-restorer": minor +"@pnpm/installing.deps-installer": minor +"@pnpm/installing.commands": minor +"@pnpm/config.reader": minor +"pnpm": minor +--- + +Add `--no-runtime` flag (config: `runtime=false`) to skip installing runtime entries (e.g. Node.js downloaded via `devEngines.runtime`) without modifying the lockfile. The lockfile keeps the runtime entry so frozen-lockfile validation still passes; only the runtime fetch and `.bin` linking are skipped. Useful in CI matrices where the runtime is provisioned externally (e.g. via `pnpm runtime -g set node `) before `pnpm install` runs. diff --git a/config/reader/src/Config.ts b/config/reader/src/Config.ts index 337f558a7e..87280d241f 100644 --- a/config/reader/src/Config.ts +++ b/config/reader/src/Config.ts @@ -249,6 +249,7 @@ export interface Config extends OptionsFromRootManifest { dedupeInjectedDeps?: boolean nodeOptions?: string pmOnFail?: 'download' | 'error' | 'warn' | 'ignore' + runtime?: boolean runtimeOnFail?: 'download' | 'error' | 'warn' | 'ignore' virtualStoreDirMaxLength: number peersSuffixMaxLength?: number diff --git a/config/reader/src/configFileKey.ts b/config/reader/src/configFileKey.ts index a1891e87a9..e2f84fdf74 100644 --- a/config/reader/src/configFileKey.ts +++ b/config/reader/src/configFileKey.ts @@ -129,6 +129,7 @@ export const excludedPnpmKeys = [ 'publish-branch', 'recursive-install', 'resolve-peers-from-workspace-root', + 'runtime', 'runtime-on-fail', 'aggregate-output', 'reporter-hide-prefix', diff --git a/config/reader/src/types.ts b/config/reader/src/types.ts index c8a8cbd5c8..7be4a07038 100644 --- a/config/reader/src/types.ts +++ b/config/reader/src/types.ts @@ -98,6 +98,7 @@ export const pnpmTypes = { reporter: String, 'resolution-mode': ['highest', 'time-based', 'lowest-direct'], 'resolve-peers-from-workspace-root': Boolean, + runtime: Boolean, 'runtime-on-fail': ['ignore', 'warn', 'error', 'download'], 'aggregate-output': Boolean, 'reporter-hide-prefix': Boolean, diff --git a/installing/commands/src/install.ts b/installing/commands/src/install.ts index d3ae92bf07..7e7ceea71d 100644 --- a/installing/commands/src/install.ts +++ b/installing/commands/src/install.ts @@ -56,6 +56,7 @@ export function rcOptionsTypes (): Record { 'public-hoist-pattern', 'registry', 'reporter', + 'runtime', 'save-workspace-protocol', 'scripts-prepend-node-path', 'shamefully-hoist', @@ -132,6 +133,10 @@ For options that may be used with `-r`, see "pnpm help recursive"', description: '`optionalDependencies` are not installed', name: '--no-optional', }, + { + description: 'Skip installing runtime entries (e.g. Node.js downloaded via `devEngines.runtime`). The lockfile is left untouched, so frozen installs still validate; only the runtime fetch and bin-linking are skipped. Useful in CI matrices where the runtime is provisioned externally.', + name: '--no-runtime', + }, { description: `Don't read or generate a \`${WANTED_LOCKFILE}\` file`, name: '--no-lockfile', diff --git a/installing/commands/src/installDeps.ts b/installing/commands/src/installDeps.ts index 39c330db00..580ba59f47 100644 --- a/installing/commands/src/installDeps.ts +++ b/installing/commands/src/installDeps.ts @@ -84,6 +84,7 @@ export type InstallDepsOptions = Pick { ignoreWorkspaceCycles: false, disallowWorkspaceCycles: false, excludeLinksFromLockfile: false, + skipRuntimes: false, virtualStoreDirMaxLength: 120, peersSuffixMaxLength: 1000, blockExoticSubdeps: false, diff --git a/installing/deps-installer/src/install/index.ts b/installing/deps-installer/src/install/index.ts index 427fd8043f..6c3c458033 100644 --- a/installing/deps-installer/src/install/index.ts +++ b/installing/deps-installer/src/install/index.ts @@ -1371,6 +1371,20 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => { } } } + if (opts.skipRuntimes) { + // The lockfile filter (filterImporter) handles wantedLockfile-driven linking, + // but the direct bin-linking path at the end of _installInContext iterates + // dependenciesByProjectId and only filters by ctx.skipped. Add runtime + // depPaths there so that path skips them too. + for (const id of Object.keys(dependenciesByProjectId) as ProjectId[]) { + for (const [alias, depPath] of dependenciesByProjectId[id].entries()) { + if (depPath.includes('@runtime:')) { + ctx.skipped.add(depPath) + dependenciesByProjectId[id].delete(alias) + } + } + } + } stageLogger.debug({ prefix: ctx.lockfileDir, @@ -1423,6 +1437,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => { sideEffectsCacheRead: opts.sideEffectsCacheRead, symlink: opts.symlink, skipped: ctx.skipped, + skipRuntimes: opts.skipRuntimes, storeController: opts.storeController, virtualStoreDir: ctx.virtualStoreDir, virtualStoreDirMaxLength: ctx.virtualStoreDirMaxLength, diff --git a/installing/deps-installer/src/install/link.ts b/installing/deps-installer/src/install/link.ts index 819d68c1bc..5856b66a65 100644 --- a/installing/deps-installer/src/install/link.ts +++ b/installing/deps-installer/src/install/link.ts @@ -68,6 +68,7 @@ export interface LinkPackagesOptions { sideEffectsCacheRead: boolean symlink: boolean skipped: Set + skipRuntimes?: boolean storeController: StoreController virtualStoreDir: string virtualStoreDirMaxLength: number @@ -120,6 +121,7 @@ export async function linkPackages (projects: ImporterToUpdate[], depGraph: Depe pruneVirtualStore: opts.pruneVirtualStore, publicHoistedModulesDir: (opts.publicHoistPattern != null) ? opts.rootModulesDir : undefined, skipped: opts.skipped, + skipRuntimes: opts.skipRuntimes, storeController: opts.storeController, virtualStoreDir: opts.virtualStoreDir, virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength, @@ -136,6 +138,7 @@ export async function linkPackages (projects: ImporterToUpdate[], depGraph: Depe include: opts.include, registries: opts.registries, skipped: opts.skipped, + skipRuntimes: opts.skipRuntimes, } const newCurrentLockfile = filterLockfileByImporters(opts.wantedLockfile, projectIds, { ...filterOpts, diff --git a/installing/deps-restorer/src/index.ts b/installing/deps-restorer/src/index.ts index 8258203ad9..8c62adfd28 100644 --- a/installing/deps-restorer/src/index.ts +++ b/installing/deps-restorer/src/index.ts @@ -176,6 +176,7 @@ export interface HeadlessOptions { pendingBuilds: string[] resolveSymlinksInInjectedDirs?: boolean skipped: Set + skipRuntimes?: boolean enableModulesDir?: boolean virtualStoreOnly?: boolean nodeLinker?: 'isolated' | 'hoisted' | 'pnp' @@ -259,6 +260,7 @@ export async function headlessInstall (opts: HeadlessOptions): Promise + skipRuntimes?: boolean virtualStoreDir: string virtualStoreDirMaxLength: number lockfileDir: string @@ -59,6 +60,7 @@ export async function prune ( const wantedLockfile = filterLockfile(opts.wantedLockfile, { include: opts.include, skipped: opts.skipped, + skipRuntimes: opts.skipRuntimes, }) const rootImporter = wantedLockfile.importers['.' as ProjectId] ?? {} as ProjectSnapshot const wantedRootPkgs = mergeDependencies(rootImporter) diff --git a/lockfile/filtering/src/filterImporter.ts b/lockfile/filtering/src/filterImporter.ts index aecbfb5d80..bdaa198841 100644 --- a/lockfile/filtering/src/filterImporter.ts +++ b/lockfile/filtering/src/filterImporter.ts @@ -1,14 +1,28 @@ -import type { ProjectSnapshot } from '@pnpm/lockfile.types' +import type { ProjectSnapshot, ResolvedDependencies } from '@pnpm/lockfile.types' import type { DependenciesField } from '@pnpm/types' export function filterImporter ( importer: ProjectSnapshot, - include: { [dependenciesField in DependenciesField]: boolean } + include: { [dependenciesField in DependenciesField]: boolean }, + opts?: { skipRuntimes?: boolean } ): ProjectSnapshot { + const skipRuntimes = opts?.skipRuntimes === true return { - dependencies: !include.dependencies ? {} : importer.dependencies ?? {}, - devDependencies: !include.devDependencies ? {} : importer.devDependencies ?? {}, - optionalDependencies: !include.optionalDependencies ? {} : importer.optionalDependencies ?? {}, - specifiers: importer.specifiers, + dependencies: !include.dependencies ? {} : pickNonRuntime(importer.dependencies, skipRuntimes), + devDependencies: !include.devDependencies ? {} : pickNonRuntime(importer.devDependencies, skipRuntimes), + optionalDependencies: !include.optionalDependencies ? {} : pickNonRuntime(importer.optionalDependencies, skipRuntimes), + specifiers: pickNonRuntime(importer.specifiers, skipRuntimes), } } + +function pickNonRuntime (deps: ResolvedDependencies | undefined, skipRuntimes: boolean): ResolvedDependencies { + if (!deps) return {} + if (!skipRuntimes) return deps + const result: ResolvedDependencies = {} + for (const [name, ref] of Object.entries(deps)) { + if (!ref.startsWith('runtime:')) { + result[name] = ref + } + } + return result +} diff --git a/lockfile/filtering/src/filterLockfile.ts b/lockfile/filtering/src/filterLockfile.ts index 6d3e94f32e..04cc711a89 100644 --- a/lockfile/filtering/src/filterLockfile.ts +++ b/lockfile/filtering/src/filterLockfile.ts @@ -8,6 +8,7 @@ export function filterLockfile ( opts: { include: { [dependenciesField in DependenciesField]: boolean } skipped: Set + skipRuntimes?: boolean } ): LockfileObject { return filterLockfileByImporters(lockfile, Object.keys(lockfile.importers) as ProjectId[], { diff --git a/lockfile/filtering/src/filterLockfileByImporters.ts b/lockfile/filtering/src/filterLockfileByImporters.ts index 47266d8129..1af16ae179 100644 --- a/lockfile/filtering/src/filterLockfileByImporters.ts +++ b/lockfile/filtering/src/filterLockfileByImporters.ts @@ -18,14 +18,20 @@ export function filterLockfileByImporters ( opts: { include: { [dependenciesField in DependenciesField]: boolean } skipped: Set + skipRuntimes?: boolean failOnMissingDependencies: boolean } ): LockfileObject { + const importers = { ...lockfile.importers } + for (const importerId of importerIds) { + importers[importerId] = filterImporter(lockfile.importers[importerId], opts.include, { skipRuntimes: opts.skipRuntimes }) + } + const packages = {} as PackageSnapshots if (lockfile.packages != null) { pkgAllDeps( lockfileWalker( - lockfile, + { ...lockfile, importers }, importerIds, { include: opts.include, skipped: opts.skipped } ).step, @@ -36,11 +42,6 @@ export function filterLockfileByImporters ( ) } - const importers = { ...lockfile.importers } - for (const importerId of importerIds) { - importers[importerId] = filterImporter(lockfile.importers[importerId], opts.include) - } - return { ...lockfile, importers, diff --git a/lockfile/filtering/src/filterLockfileByImportersAndEngine.ts b/lockfile/filtering/src/filterLockfileByImportersAndEngine.ts index a21d80be37..34ca1b0d55 100644 --- a/lockfile/filtering/src/filterLockfileByImportersAndEngine.ts +++ b/lockfile/filtering/src/filterLockfileByImportersAndEngine.ts @@ -39,6 +39,7 @@ export interface FilterLockfileOptions { failOnMissingDependencies: boolean lockfileDir: string skipped: Set + skipRuntimes?: boolean supportedArchitectures?: SupportedArchitectures } @@ -52,6 +53,7 @@ export function filterLockfileByImportersAndEngine ( const directDepPaths = toImporterDepPaths(lockfile, importerIds, { include: opts.include, importerIdSet, + skipRuntimes: opts.skipRuntimes, }) const packages = @@ -65,12 +67,13 @@ export function filterLockfileByImportersAndEngine ( opts.includeIncompatiblePackages === true, lockfileDir: opts.lockfileDir, skipped: opts.skipped, + skipRuntimes: opts.skipRuntimes, supportedArchitectures: opts.supportedArchitectures, }) : {} const importers = mapValues((importer) => { - const newImporter = filterImporter(importer, opts.include) + const newImporter = filterImporter(importer, opts.include, { skipRuntimes: opts.skipRuntimes }) if (newImporter.optionalDependencies != null) { newImporter.optionalDependencies = pickBy((ref, depName) => { const depPath = dp.refToRelative(ref, depName) @@ -105,6 +108,7 @@ function pickPkgsWithAllDeps ( includeIncompatiblePackages: boolean lockfileDir: string skipped: Set + skipRuntimes?: boolean supportedArchitectures?: SupportedArchitectures } ): PackageSnapshots { @@ -132,6 +136,7 @@ function pkgAllDeps ( includeIncompatiblePackages: boolean lockfileDir: string skipped: Set + skipRuntimes?: boolean supportedArchitectures?: SupportedArchitectures } ) { @@ -189,6 +194,7 @@ function pkgAllDeps ( ...toImporterDepPaths(ctx.lockfile, additionalImporterIds, { include: opts.include, importerIdSet: ctx.importerIdSet, + skipRuntimes: opts.skipRuntimes, }) ) pkgAllDeps(ctx, nextRelDepPaths, installable, opts) @@ -201,6 +207,7 @@ function toImporterDepPaths ( opts: { include: { [dependenciesField in DependenciesField]: boolean } importerIdSet: Set + skipRuntimes?: boolean } ): DepPath[] { const importerDeps = importerIds @@ -213,6 +220,7 @@ function toImporterDepPaths ( : {}), })) .map(Object.entries) + .map(entries => opts.skipRuntimes ? entries.filter(([, ref]) => !ref.startsWith('runtime:')) : entries) let { depPaths, importerIds: nextImporterIds } = parseDepRefs(unnest(importerDeps), lockfile) diff --git a/lockfile/filtering/test/filterByImportersAndEngine.ts b/lockfile/filtering/test/filterByImportersAndEngine.ts index 9ac269a4d2..02157f31b5 100644 --- a/lockfile/filtering/test/filterByImportersAndEngine.ts +++ b/lockfile/filtering/test/filterByImportersAndEngine.ts @@ -672,3 +672,80 @@ test('filterByImportersAndEngine(): includes linked packages', () => { 'project-3', ]) }) + +test('filterByImportersAndEngine(): skipRuntimes drops runtime: entries from importers and packages', () => { + const filteredLockfile = filterLockfileByImportersAndEngine( + { + importers: { + ['.' as ProjectId]: { + dependencies: { + 'regular-dep': '1.0.0', + }, + devDependencies: { + node: 'runtime:22.13.0', + 'dev-dep': '1.0.0', + }, + optionalDependencies: { + bun: 'runtime:1.1.0', + }, + specifiers: { + 'regular-dep': '^1.0.0', + node: 'runtime:22.13.0', + 'dev-dep': '^1.0.0', + bun: 'runtime:1.1.0', + }, + }, + }, + lockfileVersion: LOCKFILE_VERSION, + packages: { + ['regular-dep@1.0.0' as DepPath]: { + resolution: { integrity: '' }, + }, + ['dev-dep@1.0.0' as DepPath]: { + resolution: { integrity: '' }, + }, + ['node@runtime:22.13.0' as DepPath]: { + resolution: { integrity: '' }, + }, + ['bun@runtime:1.1.0' as DepPath]: { + resolution: { integrity: '' }, + }, + }, + }, + ['.' as ProjectId], + { + currentEngine: { + nodeVersion: '22.0.0', + pnpmVersion: '11.0.0', + }, + engineStrict: false, + failOnMissingDependencies: true, + include: { + dependencies: true, + devDependencies: true, + optionalDependencies: true, + }, + lockfileDir: process.cwd(), + skipped: new Set(), + skipRuntimes: true, + } + ) + + expect(filteredLockfile.lockfile.importers['.' as ProjectId]).toStrictEqual({ + dependencies: { + 'regular-dep': '1.0.0', + }, + devDependencies: { + 'dev-dep': '1.0.0', + }, + optionalDependencies: {}, + specifiers: { + 'regular-dep': '^1.0.0', + 'dev-dep': '^1.0.0', + }, + }) + expect(Object.keys(filteredLockfile.lockfile.packages ?? {}).sort()).toStrictEqual([ + 'dev-dep@1.0.0', + 'regular-dep@1.0.0', + ]) +}) diff --git a/pnpm/test/install/runtimeOnFail.ts b/pnpm/test/install/runtimeOnFail.ts index b834e7df89..2e8bef732a 100644 --- a/pnpm/test/install/runtimeOnFail.ts +++ b/pnpm/test/install/runtimeOnFail.ts @@ -1,7 +1,9 @@ import fs from 'node:fs' +import path from 'node:path' import { expect, test } from '@jest/globals' import { prepare } from '@pnpm/prepare' +import { writeYamlFileSync } from 'write-yaml-file' import { execPnpm } from '../utils/index.js' @@ -45,3 +47,91 @@ test('runtimeOnFail=ignore prevents Node.js download even when manifest sets onF const lockfile = project.readLockfile() expect(lockfile.importers['.'].devDependencies).toBeUndefined() }) + +test('--no-runtime keeps the runtime entry in the lockfile but skips installing the binary', async () => { + const project = prepare({ + devEngines: { + runtime: { + name: 'node', + version: '24.0.0', + onFail: 'download', + }, + }, + }) + + await execPnpm(['install']) + project.isExecutable('.bin/node') + const lockfileBefore = project.readLockfile() + expect(lockfileBefore.importers['.'].devDependencies).toStrictEqual({ + node: { specifier: 'runtime:24.0.0', version: 'runtime:24.0.0' }, + }) + + fs.rmSync('node_modules', { recursive: true, force: true }) + await execPnpm(['install', '--frozen-lockfile', '--no-runtime']) + + const lockfileAfter = project.readLockfile() + expect(lockfileAfter.importers['.'].devDependencies).toStrictEqual({ + node: { specifier: 'runtime:24.0.0', version: 'runtime:24.0.0' }, + }) + expectNoNodeBin() +}) + +test('--no-runtime works on a fresh checkout with no lockfile (non-frozen path)', async () => { + const project = prepare({ + devEngines: { + runtime: { + name: 'node', + version: '24.0.0', + onFail: 'download', + }, + }, + }) + + await execPnpm(['install', '--no-runtime']) + + const lockfile = project.readLockfile() + expect(lockfile.importers['.'].devDependencies).toStrictEqual({ + node: { specifier: 'runtime:24.0.0', version: 'runtime:24.0.0' }, + }) + expectNoNodeBin() +}) + +test('--no-runtime works with enableGlobalVirtualStore=true', async () => { + const project = prepare({ + devEngines: { + runtime: { + name: 'node', + version: '24.0.0', + onFail: 'download', + }, + }, + }) + writeYamlFileSync(path.resolve('pnpm-workspace.yaml'), { + enableGlobalVirtualStore: true, + storeDir: path.resolve('store'), + }) + + await execPnpm(['install', '--no-runtime']) + + const lockfile = project.readLockfile() + expect(lockfile.importers['.'].devDependencies).toStrictEqual({ + node: { specifier: 'runtime:24.0.0', version: 'runtime:24.0.0' }, + }) + expectNoNodeBin() +}) + +function expectNoNodeBin (): void { + const binDir = path.join('node_modules', '.bin') + for (const name of ['node', 'node.exe', 'node.cmd', 'node.ps1']) { + const p = path.join(binDir, name) + // lstatSync (vs existsSync) catches dangling symlinks too — existsSync + // follows symlinks and would return false for a symlink whose target was + // never created, hiding a real bug. + let exists = false + try { + fs.lstatSync(p) + exists = true + } catch {} + expect(exists).toBe(false) + } +}