From 237e2ba02e6bbb51082081ab84603c028f53b880 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:35:31 -0400 Subject: [PATCH] fix(@angular/cli): decouple official Node.js support check from execution gate The `isNodeVersionSupported` function strictly validates whether a Node.js runtime matches an officially supported LTS track (such as `^22.22.3`, `^24.15.0`, or `^26.0.0`). Non-LTS odd releases (Node 23, 25) and uncertified newer releases (Node 27, 28+) return `false`, ensuring that `ng version` accurately marks them as unsupported. The `isNodeVersionRunnable` function dynamically determines whether an unsupported runtime can safely execute the CLI by checking if the major is an odd release between the minimum and maximum supported versions or higher than the maximum supported LTS major. The `bin/ng.js` bootstrap script now warns and continues execution on runnable unsupported runtimes instead of hardcoding specific major versions or aborting execution with an exit code of 3. --- packages/angular/cli/bin/ng.js | 15 ++- packages/angular/cli/bin/version.js | 48 ++++++++-- packages/angular/cli/src/typings.d.ts | 9 +- .../angular/cli/src/utilities/node-version.ts | 20 +++- .../cli/src/utilities/node-version_spec.ts | 96 +++++++++++++++++++ 5 files changed, 166 insertions(+), 22 deletions(-) create mode 100644 packages/angular/cli/src/utilities/node-version_spec.ts diff --git a/packages/angular/cli/bin/ng.js b/packages/angular/cli/bin/ng.js index a95f51b8def4..d503fb895f5f 100755 --- a/packages/angular/cli/bin/ng.js +++ b/packages/angular/cli/bin/ng.js @@ -43,20 +43,19 @@ if (rawCommandName === '--get-yargs-completions' || rawCommandName === 'completi // This node version check ensures that extremely old versions of node are not used. // These may not support ES2015 features such as const/let/async/await/etc. // These would then crash with a hard to diagnose error message. -const [major] = process.versions.node.split('.', 1).map((part) => Number(part)); - -if (major === 23 || major === 25) { - // Allow new odd numbered releases with a warning. +if (nodeUtils.isNodeVersionSupported()) { + require('./bootstrap'); +} else if (nodeUtils.isNodeVersionRunnable()) { console.warn( 'Node.js version ' + process.version + ' detected.\n' + - 'Prior to version 27, odd numbered Node.js versions will not enter LTS status and should not be used for production.' + - ' For more information, please see https://nodejs.org/en/about/previous-releases/.', + 'The Angular CLI has not been officially tested on this Node.js version and it is not supported for production use.\n' + + 'For more information, please see https://nodejs.org/en/about/previous-releases/.', ); require('./bootstrap'); -} else if (!nodeUtils.isNodeVersionSupported()) { +} else { // Error and exit if less than the supported versions. console.error( 'Node.js version ' + @@ -69,6 +68,4 @@ if (major === 23 || major === 25) { ); process.exitCode = 3; -} else { - require('./bootstrap'); } diff --git a/packages/angular/cli/bin/version.js b/packages/angular/cli/bin/version.js index 38edd4e299c7..1755c1ed55c8 100644 --- a/packages/angular/cli/bin/version.js +++ b/packages/angular/cli/bin/version.js @@ -27,21 +27,24 @@ var supportedNodeVersions = SUPPORTED_NODE_VERSIONS.replace(/[\^~<>=]/g, '') }); /** - * Checks if the current Node.js version is supported. - * @returns `true` if the current Node.js version is supported, `false` otherwise. + * Checks if the current Node.js version is officially supported. + * @param {string} [currentVersion] Optional Node.js version string to check. Defaults to `process.versions.node`. + * @param {string[]} [supportedVersions] Optional supported versions array. Defaults to `supportedNodeVersions`. + * @returns {boolean} `true` if the current Node.js version is officially supported, `false` otherwise. */ -function isNodeVersionSupported() { - if (SUPPORTED_NODE_VERSIONS.charAt(0) === '0') { +function isNodeVersionSupported(currentVersion, supportedVersions) { + if (SUPPORTED_NODE_VERSIONS.charAt(0) === '0' && !supportedVersions && !currentVersion) { return true; } - var parts = process.versions.node.split('.', 3).map(Number); + var parts = (currentVersion || process.versions.node).split('.', 3).map(Number); var processMajor = parts[0]; var processMinor = parts[1]; var processPatch = parts[2]; - for (var i = 0; i < supportedNodeVersions.length; i++) { - var vParts = supportedNodeVersions[i].split('.', 3).map(Number); + var versions = supportedVersions || supportedNodeVersions; + for (var i = 0; i < versions.length; i++) { + var vParts = versions[i].split('.', 3).map(Number); var major = vParts[0]; var minor = vParts[1]; var patch = vParts[2]; @@ -56,9 +59,40 @@ function isNodeVersionSupported() { return false; } +/** + * Checks if the current Node.js version is runnable (newer than supported LTS or an interim odd release). + * @param {string} [currentVersion] Optional Node.js version string to check. Defaults to `process.versions.node`. + * @param {string[]} [supportedVersions] Optional supported versions array. Defaults to `supportedNodeVersions`. + * @returns {boolean} `true` if the current Node.js version is runnable, `false` otherwise. + */ +function isNodeVersionRunnable(currentVersion, supportedVersions) { + if (SUPPORTED_NODE_VERSIONS.charAt(0) === '0' && !supportedVersions && !currentVersion) { + return false; + } + + var processMajor = Number((currentVersion || process.versions.node).split('.', 1)[0]); + var versions = supportedVersions || supportedNodeVersions; + + var minMajor = Number(versions[0].split('.', 1)[0]); + var maxMajor = Number(versions[versions.length - 1].split('.', 1)[0]); + + // Versions newer than the maximum supported major (e.g. Node 27, 28+) + if (processMajor > maxMajor) { + return true; + } + + // Odd-numbered interim releases between min and max (e.g. Node 23, 25) + if (processMajor > minMajor && processMajor % 2 === 1) { + return true; + } + + return false; +} + module.exports = { VERSION: VERSION, SUPPORTED_NODE_VERSIONS: SUPPORTED_NODE_VERSIONS, supportedNodeVersions: supportedNodeVersions, isNodeVersionSupported: isNodeVersionSupported, + isNodeVersionRunnable: isNodeVersionRunnable, }; diff --git a/packages/angular/cli/src/typings.d.ts b/packages/angular/cli/src/typings.d.ts index 948d7b1d2280..43b81c1b0229 100644 --- a/packages/angular/cli/src/typings.d.ts +++ b/packages/angular/cli/src/typings.d.ts @@ -15,5 +15,12 @@ declare module '#version' { export const VERSION: string; export const SUPPORTED_NODE_VERSIONS: string; export const supportedNodeVersions: string[]; - export function isNodeVersionSupported(): boolean; + export function isNodeVersionSupported( + currentVersion?: string, + supportedVersions?: string[], + ): boolean; + export function isNodeVersionRunnable( + currentVersion?: string, + supportedVersions?: string[], + ): boolean; } diff --git a/packages/angular/cli/src/utilities/node-version.ts b/packages/angular/cli/src/utilities/node-version.ts index 18f06b960369..87eb4fa377b6 100644 --- a/packages/angular/cli/src/utilities/node-version.ts +++ b/packages/angular/cli/src/utilities/node-version.ts @@ -13,22 +13,32 @@ import { SUPPORTED_NODE_VERSIONS, supportedNodeVersions } from '#version'; -export { SUPPORTED_NODE_VERSIONS, supportedNodeVersions, isNodeVersionSupported } from '#version'; +export { + SUPPORTED_NODE_VERSIONS, + supportedNodeVersions, + isNodeVersionSupported, + isNodeVersionRunnable, +} from '#version'; /** * Checks if the current Node.js version is the minimum supported version. + * @param currentVersion Optional Node.js version string to check. Defaults to `process.versions.node`. + * @param supportedVersions Optional supported versions array. Defaults to `supportedNodeVersions`. * @returns `true` if the current Node.js version is the minimum supported version, `false` otherwise. */ -export function isNodeVersionMinSupported(): boolean { - if (SUPPORTED_NODE_VERSIONS.charAt(0) === '0') { +export function isNodeVersionMinSupported( + currentVersion = process.versions.node, + supportedVersions = supportedNodeVersions, +): boolean { + if (SUPPORTED_NODE_VERSIONS.charAt(0) === '0' && currentVersion === process.versions.node) { // Unlike `pkg_npm`, `ts_library` which is used to run unit tests does not support substitutions. return true; } - const [processMajor, processMinor, processPatch] = process.versions.node + const [processMajor, processMinor, processPatch] = currentVersion .split('.', 3) .map((part) => Number(part)); - const [major, minor, patch] = supportedNodeVersions[0].split('.', 3).map((part) => Number(part)); + const [major, minor, patch] = supportedVersions[0].split('.', 3).map((part) => Number(part)); return ( processMajor > major || diff --git a/packages/angular/cli/src/utilities/node-version_spec.ts b/packages/angular/cli/src/utilities/node-version_spec.ts new file mode 100644 index 000000000000..9426521faf65 --- /dev/null +++ b/packages/angular/cli/src/utilities/node-version_spec.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + isNodeVersionMinSupported, + isNodeVersionRunnable, + isNodeVersionSupported, +} from './node-version'; + +describe('node-version', () => { + const supportedVersions = ['22.22.3', '24.15.0', '26.0.0']; + + describe('isNodeVersionSupported', () => { + it('should accept valid LTS versions', () => { + expect(isNodeVersionSupported('22.22.3', supportedVersions)).toBeTrue(); + expect(isNodeVersionSupported('22.23.0', supportedVersions)).toBeTrue(); + expect(isNodeVersionSupported('24.15.0', supportedVersions)).toBeTrue(); + expect(isNodeVersionSupported('24.16.0', supportedVersions)).toBeTrue(); + expect(isNodeVersionSupported('26.0.0', supportedVersions)).toBeTrue(); + expect(isNodeVersionSupported('26.1.0', supportedVersions)).toBeTrue(); + }); + + it('should reject versions below the minimum for a supported major', () => { + expect(isNodeVersionSupported('22.22.2', supportedVersions)).toBeFalse(); + expect(isNodeVersionSupported('22.0.0', supportedVersions)).toBeFalse(); + expect(isNodeVersionSupported('24.14.9', supportedVersions)).toBeFalse(); + expect(isNodeVersionSupported('20.18.0', supportedVersions)).toBeFalse(); + }); + + it('should reject odd-numbered major versions', () => { + expect(isNodeVersionSupported('23.0.0', supportedVersions)).toBeFalse(); + expect(isNodeVersionSupported('25.0.0', supportedVersions)).toBeFalse(); + }); + + it('should reject versions higher than the maximum supported LTS version', () => { + expect(isNodeVersionSupported('27.0.0', supportedVersions)).toBeFalse(); + expect(isNodeVersionSupported('28.0.0', supportedVersions)).toBeFalse(); + expect(isNodeVersionSupported('29.1.2', supportedVersions)).toBeFalse(); + }); + + it('should return true when unstamped (0.0.0-ENGINES-NODE)', () => { + expect(isNodeVersionSupported()).toBeTrue(); + }); + }); + + describe('isNodeVersionRunnable', () => { + it('should allow odd-numbered interim releases between min and max major', () => { + expect(isNodeVersionRunnable('23.0.0', supportedVersions)).toBeTrue(); + expect(isNodeVersionRunnable('25.0.0', supportedVersions)).toBeTrue(); + }); + + it('should allow versions higher than the maximum supported major', () => { + expect(isNodeVersionRunnable('27.0.0', supportedVersions)).toBeTrue(); + expect(isNodeVersionRunnable('28.0.0', supportedVersions)).toBeTrue(); + expect(isNodeVersionRunnable('29.1.2', supportedVersions)).toBeTrue(); + }); + + it('should return false for supported LTS versions', () => { + expect(isNodeVersionRunnable('22.22.3', supportedVersions)).toBeFalse(); + expect(isNodeVersionRunnable('24.15.0', supportedVersions)).toBeFalse(); + expect(isNodeVersionRunnable('26.0.0', supportedVersions)).toBeFalse(); + }); + + it('should return false for versions below the minimum major', () => { + expect(isNodeVersionRunnable('18.0.0', supportedVersions)).toBeFalse(); + expect(isNodeVersionRunnable('20.0.0', supportedVersions)).toBeFalse(); + expect(isNodeVersionRunnable('21.0.0', supportedVersions)).toBeFalse(); + }); + + it('should return false for outdated minors of supported majors', () => { + expect(isNodeVersionRunnable('22.0.0', supportedVersions)).toBeFalse(); + expect(isNodeVersionRunnable('24.10.0', supportedVersions)).toBeFalse(); + }); + }); + + describe('isNodeVersionMinSupported', () => { + it('should accept versions at or above the minimum supported version', () => { + expect(isNodeVersionMinSupported('22.22.3', supportedVersions)).toBeTrue(); + expect(isNodeVersionMinSupported('22.25.0', supportedVersions)).toBeTrue(); + expect(isNodeVersionMinSupported('23.0.0', supportedVersions)).toBeTrue(); + expect(isNodeVersionMinSupported('24.0.0', supportedVersions)).toBeTrue(); + expect(isNodeVersionMinSupported('28.0.0', supportedVersions)).toBeTrue(); + }); + + it('should reject versions below the minimum supported version', () => { + expect(isNodeVersionMinSupported('22.22.2', supportedVersions)).toBeFalse(); + expect(isNodeVersionMinSupported('20.0.0', supportedVersions)).toBeFalse(); + expect(isNodeVersionMinSupported('18.19.0', supportedVersions)).toBeFalse(); + }); + }); +});