refactor(env): unify node version specifier parsing into parseNodeSpecifier in node.resolver (#10668)

* refactor(env): unify node version specifier parsing into parseNodeSpecifier in node.resolver

Move parseNodeSpecifier from @pnpm/plugin-commands-env to @pnpm/node.resolver and
replace the simpler parseEnvSpecifier with an enhanced version that supports all
Node.js version specifier formats: standalone release channels (nightly, rc, test,
v8-canary, release), well-known aliases (lts, latest), LTS codenames (argon, iron),
semver ranges (18, ^18), and channel/version combos (rc/18, nightly/latest).

* fix(env): address parseNodeSpecifier review feedback

- Remove overly strict release/X.Y.Z-only validation; release/latest,
  release/lts, and release/<range> are now accepted
- Validate unknown release channels (e.g. foo/18) with a clear error
  instead of letting them fall through to a confusing network failure
- Add test cases for release/latest, release/lts, and release/18
This commit is contained in:
Zoltan Kochan
2026-02-22 14:34:02 +01:00
committed by GitHub
parent 895af70320
commit 23eb4a6141
10 changed files with 109 additions and 169 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/node.resolver": patch
"@pnpm/plugin-commands-env": patch
---
`parseNodeSpecifier` is moved from `@pnpm/plugin-commands-env` to `@pnpm/node.resolver` and enhanced to support all Node.js version specifier formats. Previously `parseEnvSpecifier` (in `@pnpm/node.resolver`) handled the resolver's parsing, while `parseNodeSpecifier` (in `@pnpm/plugin-commands-env`) was a stricter but now-unused validator. They are now unified into a single `parseNodeSpecifier` in `@pnpm/node.resolver` that supports: exact versions (`22.0.0`), prerelease versions (`22.0.0-rc.4`), semver ranges (`18`, `^18`), LTS codenames (`argon`, `iron`), well-known aliases (`lts`, `latest`), standalone release channels (`nightly`, `rc`, `test`, `v8-canary`, `release`), and channel/version combos (`rc/18`, `nightly/latest`).

View File

@@ -31,6 +31,7 @@
"clonedeep",
"cmds",
"codeload",
"codenames",
"codesign",
"colorterm",
"comver",

View File

@@ -14,11 +14,11 @@ import {
import semver from 'semver'
import versionSelectorType from 'version-selector-type'
import { type PkgResolutionId } from '@pnpm/types'
import { parseEnvSpecifier } from './parseEnvSpecifier.js'
import { parseNodeSpecifier } from './parseNodeSpecifier.js'
import { getNodeMirror } from './getNodeMirror.js'
import { getNodeArtifactAddress } from './getNodeArtifactAddress.js'
export { getNodeMirror, parseEnvSpecifier, getNodeArtifactAddress }
export { getNodeMirror, parseNodeSpecifier, getNodeArtifactAddress }
export const DEFAULT_NODE_MIRROR_BASE_URL = 'https://nodejs.org/download/release/'
export const UNOFFICIAL_NODE_MIRROR_BASE_URL = 'https://unofficial-builds.nodejs.org/download/release/'
@@ -49,7 +49,7 @@ export async function resolveNodeRuntime (
if (ctx.offline) throw new PnpmError('NO_OFFLINE_NODEJS_RESOLUTION', 'Offline Node.js resolution is not supported')
const versionSpec = wantedDependency.bareSpecifier.substring('runtime:'.length)
const { releaseChannel, versionSpecifier } = parseEnvSpecifier(versionSpec)
const { releaseChannel, versionSpecifier } = parseNodeSpecifier(versionSpec)
const nodeMirrorBaseUrl = getNodeMirror(ctx.rawConfig, releaseChannel)
const version = await resolveNodeVersion(ctx.fetchFromRegistry, versionSpecifier, nodeMirrorBaseUrl)
if (!version) {

View File

@@ -1,19 +0,0 @@
export interface EnvSpecifier {
releaseChannel: string
versionSpecifier: string
}
export function parseEnvSpecifier (specifier: string): EnvSpecifier {
if (specifier.includes('/')) {
const [releaseChannel, versionSpecifier] = specifier.split('/')
return { releaseChannel, versionSpecifier }
}
const prereleaseMatch = specifier.match(/-(nightly|rc|test|v8-canary)/)
if (prereleaseMatch != null) {
return { releaseChannel: prereleaseMatch[1], versionSpecifier: specifier }
}
if (['nightly', 'rc', 'test', 'release', 'v8-canary'].includes(specifier)) {
return { releaseChannel: specifier, versionSpecifier: 'latest' }
}
return { releaseChannel: 'release', versionSpecifier: specifier }
}

View File

@@ -0,0 +1,51 @@
import { PnpmError } from '@pnpm/error'
export interface NodeSpecifier {
releaseChannel: string
versionSpecifier: string
}
const RELEASE_CHANNELS = ['nightly', 'rc', 'test', 'v8-canary', 'release']
const isStableVersion = (version: string): boolean => /^\d+\.\d+\.\d+$/.test(version)
export function parseNodeSpecifier (specifier: string): NodeSpecifier {
// Handle "channel/version" format: "rc/18", "rc/18.0.0-rc.4", "release/22.0.0", "nightly/latest"
if (specifier.includes('/')) {
const [releaseChannel, versionSpecifier] = specifier.split('/', 2)
if (!RELEASE_CHANNELS.includes(releaseChannel)) {
throw new PnpmError('INVALID_NODE_RELEASE_CHANNEL', `"${releaseChannel}" is not a valid Node.js release channel`, {
hint: `Valid release channels are: ${RELEASE_CHANNELS.join(', ')}`,
})
}
return { releaseChannel, versionSpecifier }
}
// Exact prerelease version with a recognized release channel suffix.
// e.g. "22.0.0-rc.4", "22.0.0-nightly20250315d765e70802", "22.0.0-v8-canary2025..."
const prereleaseChannelMatch = specifier.match(/^\d+\.\d+\.\d+-(nightly|rc|test|v8-canary)/)
if (prereleaseChannelMatch != null) {
return { releaseChannel: prereleaseChannelMatch[1], versionSpecifier: specifier }
}
// Exact stable version: "22.0.0"
if (isStableVersion(specifier)) {
return { releaseChannel: 'release', versionSpecifier: specifier }
}
// Standalone release channel name means "latest from that channel".
// e.g. "nightly" → latest nightly, "rc" → latest rc, "release" → latest release
if (RELEASE_CHANNELS.includes(specifier)) {
return { releaseChannel: specifier, versionSpecifier: 'latest' }
}
// Well-known version aliases on the stable release channel
if (specifier === 'lts' || specifier === 'latest') {
return { releaseChannel: 'release', versionSpecifier: specifier }
}
// Semver ranges ("18", "^18", ">=18", "18.x") and LTS codenames ("argon", "iron", "hydrogen")
// are all passed through as versionSpecifier on the release channel.
// Any truly invalid input will fail at resolution time with NODEJS_VERSION_NOT_FOUND.
return { releaseChannel: 'release', versionSpecifier: specifier }
}

View File

@@ -1,15 +0,0 @@
import { parseEnvSpecifier } from '../lib/parseEnvSpecifier.js'
test.each([
['6', '6', 'release'],
['16.0.0-rc.0', '16.0.0-rc.0', 'rc'],
['rc/10', '10', 'rc'],
['nightly', 'latest', 'nightly'],
['lts', 'lts', 'release'],
['argon', 'argon', 'release'],
['latest', 'latest', 'release'],
])('Node.js version selector is parsed', (editionSpecifier, versionSpecifier, releaseChannel) => {
const node = parseEnvSpecifier(editionSpecifier)
expect(node.versionSpecifier).toMatch(versionSpecifier)
expect(node.releaseChannel).toBe(releaseChannel)
})

View File

@@ -0,0 +1,46 @@
import { parseNodeSpecifier } from '../lib/parseNodeSpecifier.js'
test.each([
// Semver ranges → release channel
['6', '6', 'release'],
['16.0', '16.0', 'release'],
// Exact prerelease with rc channel
['16.0.0-rc.0', '16.0.0-rc.0', 'rc'],
// Channel/range combo (major only)
['rc/10', '10', 'rc'],
// Standalone channel name → latest from that channel
['nightly', 'latest', 'nightly'],
['rc', 'latest', 'rc'],
['test', 'latest', 'test'],
['v8-canary', 'latest', 'v8-canary'],
['release', 'latest', 'release'],
// Well-known aliases
['lts', 'lts', 'release'],
['latest', 'latest', 'release'],
// LTS codenames
['argon', 'argon', 'release'],
['iron', 'iron', 'release'],
// Exact stable version
['22.0.0', '22.0.0', 'release'],
// Stable release with explicit channel prefix, aliases, and semver ranges
['release/22.0.0', '22.0.0', 'release'],
['release/latest', 'latest', 'release'],
['release/lts', 'lts', 'release'],
['release/18', '18', 'release'],
// Channel/version combos
['rc/18', '18', 'rc'],
['rc/18.0.0-rc.4', '18.0.0-rc.4', 'rc'],
['nightly/latest', 'latest', 'nightly'],
// Exact nightly version
['24.0.0-nightly20250315d765e70802', '24.0.0-nightly20250315d765e70802', 'nightly'],
// Exact v8-canary version
['22.0.0-v8-canary20250101abc', '22.0.0-v8-canary20250101abc', 'v8-canary'],
])('Node.js version specifier is parsed: %s', (specifier, expectedVersionSpecifier, expectedReleaseChannel) => {
const result = parseNodeSpecifier(specifier)
expect(result.versionSpecifier).toBe(expectedVersionSpecifier)
expect(result.releaseChannel).toBe(expectedReleaseChannel)
})
test('throws for unknown release channel', () => {
expect(() => parseNodeSpecifier('foo/18')).toThrow('"foo" is not a valid Node.js release channel')
})

View File

@@ -1,5 +1,5 @@
import { createFetchFromRegistry } from '@pnpm/fetch'
import { resolveNodeVersions, parseEnvSpecifier, getNodeMirror } from '@pnpm/node.resolver'
import { resolveNodeVersions, parseNodeSpecifier, getNodeMirror } from '@pnpm/node.resolver'
import { type NvmNodeCommandOptions } from './node.js'
export async function envList (opts: NvmNodeCommandOptions, params: string[]): Promise<string> {
@@ -10,7 +10,7 @@ export async function envList (opts: NvmNodeCommandOptions, params: string[]): P
async function listRemoteVersions (opts: NvmNodeCommandOptions, versionSpec?: string): Promise<string[]> {
const fetch = createFetchFromRegistry(opts)
const { releaseChannel, versionSpecifier } = parseEnvSpecifier(versionSpec ?? '')
const { releaseChannel, versionSpecifier } = versionSpec ? parseNodeSpecifier(versionSpec) : { releaseChannel: 'release', versionSpecifier: '' }
const nodeMirrorBaseUrl = getNodeMirror(opts.rawConfig, releaseChannel)
return resolveNodeVersions(fetch, versionSpecifier, nodeMirrorBaseUrl)
}

View File

@@ -1,60 +0,0 @@
import { PnpmError } from '@pnpm/error'
export interface NodeSpecifier {
releaseChannel: string
useNodeVersion: string
}
const isStableVersion = (version: string): boolean => /^\d+\.\d+\.\d+$/.test(version)
const matchPrereleaseVersion = (version: string): RegExpMatchArray | null => version.match(/^\d+\.\d+\.\d+-((rc)(\..+)|(test|v8-canary|nightly)(.+))$/)
const STABLE_RELEASE_ERROR_HINT = 'The correct syntax for stable release is strictly X.Y.Z or release/X.Y.Z'
export function isValidVersion (specifier: string): boolean {
if (specifier.includes('/')) {
const [releaseChannel, useNodeVersion] = specifier.split('/')
if (releaseChannel === 'release') {
return isStableVersion(useNodeVersion)
}
return useNodeVersion.includes(releaseChannel)
}
return isStableVersion(specifier) || matchPrereleaseVersion(specifier) != null
}
export function parseNodeSpecifier (specifier: string): NodeSpecifier {
if (specifier.includes('/')) {
const [releaseChannel, useNodeVersion] = specifier.split('/')
if (releaseChannel === 'release') {
if (!isStableVersion(useNodeVersion)) {
throw new PnpmError('INVALID_NODE_VERSION', `"${specifier}" is not a valid Node.js version`, {
hint: STABLE_RELEASE_ERROR_HINT,
})
}
} else if (!useNodeVersion.includes(releaseChannel)) {
throw new PnpmError('MISMATCHED_RELEASE_CHANNEL', `Node.js version (${useNodeVersion}) must contain the release channel (${releaseChannel})`)
}
return { releaseChannel, useNodeVersion }
}
const prereleaseMatch = matchPrereleaseVersion(specifier)
if (prereleaseMatch != null) {
return { releaseChannel: prereleaseMatch[2], useNodeVersion: specifier }
}
if (isStableVersion(specifier)) {
return { releaseChannel: 'release', useNodeVersion: specifier }
}
let hint: string | undefined
if (['nightly', 'rc', 'test', 'v8-canary'].includes(specifier)) {
hint = `The correct syntax for ${specifier} release is strictly X.Y.Z-${specifier}.W`
} else if (/^\d+\.\d+$/.test(specifier) || /^\d+$/.test(specifier) || ['release', 'stable', 'latest'].includes(specifier)) {
hint = STABLE_RELEASE_ERROR_HINT
}
throw new PnpmError('INVALID_NODE_VERSION', `"${specifier}" is not a valid Node.js version`, { hint })
}

View File

@@ -1,70 +0,0 @@
import { isValidVersion, parseNodeSpecifier } from '../lib/parseNodeSpecifier.js'
test.each([
['rc/16.0.0-rc.0', '16.0.0-rc.0', 'rc'],
['16.0.0-rc.0', '16.0.0-rc.0', 'rc'],
['release/16.0.0', '16.0.0', 'release'],
['16.0.0', '16.0.0', 'release'],
])('Node.js version selector is parsed', (editionSpecifier, useNodeVersion, releaseChannel) => {
const node = parseNodeSpecifier(editionSpecifier)
expect(node.useNodeVersion).toBe(useNodeVersion)
expect(node.releaseChannel).toBe(releaseChannel)
})
test.each([
['rc/10', '10', 'rc'],
['rc/10.0', '10.0', 'rc'],
['rc/10.0.0', '10.0.0', 'rc'],
['rc/10.0.0.test.0', '10.0.0.test.0', 'rc'],
])('invalid Node.js specifier', (editionSpecifier, useNodeVersion, releaseChannel) => {
expect(() => parseNodeSpecifier(editionSpecifier)).toThrow(`Node.js version (${useNodeVersion}) must contain the release channel (${releaseChannel})`)
})
test.each([
['nightly'],
['rc'],
['test'],
['v8-canary'],
])('invalid Node.js specifier', async (specifier) => {
const promise = Promise.resolve().then(() => parseNodeSpecifier(specifier))
await expect(promise).rejects.toThrow(`"${specifier}" is not a valid Node.js version`)
await expect(promise).rejects.toHaveProperty('hint', `The correct syntax for ${specifier} release is strictly X.Y.Z-${specifier}.W`)
})
test.each([
['release'],
['stable'],
['latest'],
['release/16.0.0.release.0'],
['16'],
['16.0'],
])('invalid Node.js specifier', async (specifier) => {
const promise = Promise.resolve().then(() => parseNodeSpecifier(specifier))
await expect(promise).rejects.toThrow(`"${specifier}" is not a valid Node.js version`)
await expect(promise).rejects.toHaveProperty('hint', 'The correct syntax for stable release is strictly X.Y.Z or release/X.Y.Z')
})
test.each([
['rc/16.0.0-rc.0', '16.0.0-rc.0', 'rc'],
['16.0.0-rc.0', '16.0.0-rc.0', 'rc'],
['release/16.0.0', '16.0.0', 'release'],
['16.0.0', '16.0.0', 'release'],
['24.0.0-nightly20250315d765e70802', '24.0.0-nightly20250315d765e70802', 'nightly'],
])('valid Node.js specifier', async (specifier) => {
expect(isValidVersion(specifier)).toBe(true)
})
test.each([
['nightly'],
['rc'],
['test'],
['v8-canary'],
['release'],
['stable'],
['latest'],
['release/16.0.0.release.0'],
['16'],
['16.0'],
])('invalid Node.js specifier', async (specifier) => {
expect(isValidVersion(specifier)).toBe(false)
})