Command
update
Is this a regression?
The previous version in which this bug was not present was
No response
Description
When ng update (or ng add) needs to install a temporary CLI, it resolves the
target version by querying the registry with a non-exact range (e.g. just the
major: @angular/cli@21). The CLI then picks the manifest to install via:
https://github.com/angular/angular-cli/blob/main/packages/angular/cli/src/package-managers/parsers.ts#L237
export function parseNpmLikeManifest(stdout: string, logger?: Logger): PackageManifest | null {
// ...
const result = JSON.parse(stdout);
return Array.isArray(result) ? result[result.length - 1] : result;
}
The implicit assumption is "the last entry in the array is the highest matching
version". That assumption is not guaranteed.
npm view --json <pkg>@<range> <fields...> returns one manifest object per
matching version. The order of that array is the order the versions appear in
the registry document, which is essentially publish order, not semver
order. Publish order matches semver order only as long as no version is ever
published "out of band". As soon as that assumption breaks — e.g. a security
patch is back-ported to an older minor after a newer minor has already shipped,
or an artifact mirror filters/reorders entries — the last element is no longer
the highest version, and the CLI silently downgrades the temporary CLI it
installs.
Affected call path
ng update @angular/core@<major> (or any other range-based invocation)
commands/update/utilities/cli-version.ts → getCLIUpdateRunnerVersion() returns the major (e.g. 21)
packageManager.getManifest('@angular/cli@21')
- Descriptor in
package-manager-descriptor.ts builds:
npm view --json "@angular/cli@21" name version deprecated dependencies peerDependencies devDependencies homepage schematics ng-add ng-update
- Result is an array →
parseNpmLikeManifest returns result[result.length - 1]
The same code path is also used by ng add whenever a range/tag is passed, and
by parseNpmLikeManifest for every package manager that delegates to it
(npm, yarn modern, pnpm, bun — see package-manager-descriptor.ts).
Why this matters in practice
A real-world scenario where this surfaces today, even when the public npm
registry happens to be in publish-order = semver-order:
- Enterprise setups proxying npm through Sonatype Nexus + IQ Server (or
comparable tools like JFrog Artifactory with Xray, etc.) routinely filter
or hold back specific versions while CVE/policy scans are pending or while
a version is quarantined.
- The proxy still returns a valid
npm view array, but the highest available
version is no longer guaranteed to be at the end. Example registry response
for @angular/cli@21 after the proxy filters out 21.2.7 and 21.2.8 while
scanning, but already exposes 21.2.9:
[..., 21.2.5, 21.2.6, 21.2.9, 21.2.7, 21.2.8] — depending on internal
ordering rules of the proxy.
- Even without filtering: any registry mirror is free to reorder, and
npm view makes no documented guarantee on array ordering.
- Independently, packages occasionally back-port a patch to an older minor
after a newer minor has shipped. In that case the back-ported patch is
appended to the registry document, so it ends up last in the array, and
parseNpmLikeManifest picks the older minor.
In both cases, the user requested @21 expecting the highest 21.x, but the CLI
installs an older 21.x as the temporary runner. Symptoms range from "update
runs against an outdated runner and misses migrations" to confusing version
mismatches in logs ("installing temporary Angular CLI versioned 21.2.6" while
21.2.9 is published).
Test that codifies the assumption
packages/angular/cli/src/package-managers/parsers_spec.ts (around L131):
it('should return the last manifest from an array', () => {
const stdout = JSON.stringify([
{ name: 'foo', version: '1.0.0' },
{ name: 'foo', version: '1.1.0' },
]);
expect(parseNpmLikeManifest(stdout)).toEqual({ name: 'foo', version: '1.1.0' });
});
The test asserts "last" but the documented intent of the function (getManifest
is supposed to return the manifest the CLI will actually install — i.e. the
highest matching version) is "max-by-semver". The two only coincide when
the registry's array order matches semver order, which is not a guarantee npm
gives.
Suggested fix
Sort by semver and pick the highest, instead of trusting array order. semver
is already a dependency (see commands/update/utilities/cli-version.ts).
import * as semver from 'semver';
export function parseNpmLikeManifest(stdout: string, logger?: Logger): PackageManifest | null {
logger?.debug(`Parsing npm-like manifest...`);
logStdout(stdout, logger);
if (!stdout) {
logger?.debug(' stdout is empty. No manifest found.');
return null;
}
const result = JSON.parse(stdout);
if (!Array.isArray(result)) {
return result;
}
if (result.length === 0) {
return null;
}
// Pick the manifest with the highest semver-valid version.
// Falls back to the last entry if no version is parseable, preserving
// existing behavior for malformed data.
return result.reduce((best, current) => {
if (!best?.version || !semver.valid(best.version)) return current;
if (!current?.version || !semver.valid(current.version)) return best;
return semver.gt(current.version, best.version) ? current : best;
});
}
The corresponding spec should be updated to assert "highest by semver" rather
than "last", and a regression test added with a deliberately out-of-order array
(e.g. [1.1.0, 1.0.0, 1.2.0, 1.1.5] → expect 1.2.0).
Minimal Reproduction
I do not currently have a public reproduction. Locally we hit this through a
Sonatype Nexus proxy in front of npm, where Nexus IQ holds back specific
versions during policy/CVE scans, so the array returned by npm view is not
guaranteed to be in ascending semver order. The bug, however, is independent
of the registry: it lives entirely in parseNpmLikeManifest's assumption
about array ordering, and is provable by code review alone — there is no point
in the CLI's logic where the array is sorted before result[result.length - 1]
is taken.
If a test-level reproduction is desired, the existing spec at
parsers_spec.ts:131 can simply be inverted:
it('should return the highest manifest from an array regardless of order', () => {
const stdout = JSON.stringify([
{ name: 'foo', version: '1.1.0' },
{ name: 'foo', version: '1.0.0' }, // out of order — happens with proxies/backports
]);
expect(parseNpmLikeManifest(stdout)).toEqual({ name: 'foo', version: '1.1.0' });
});
Exception or Error
An unhandled exception occurred: Process exited with code 1.
See "PATH\TO\angular-errors.log" for further details.
Content of `angular-errors.log`:
[error] Error: Process exited with code 1.
at ChildProcess.<anonymous> (PATH\TO\MY\PROJECT\node_modules\@angular\cli\src\package-managers\host.js:65:28)
at ChildProcess.emit (node:events:519:28)
at maybeClose (node:internal/child_process:1101:16)
at ChildProcess._handle.onexit (node:internal/child_process:304:5)
Your Environment
_ _ ____ _ ___
/ \ _ __ __ _ _ _| | __ _ _ __ / ___| | |_ _|
/ △ \ | '_ \ / _` | | | | |/ _` | '__| | | | | | |
/ ___ \| | | | (_| | |_| | | (_| | | | |___| |___ | |
/_/ \_\_| |_|\__, |\__,_|_|\__,_|_| \____|_____|___|
|___/
Angular CLI : 21.2.9
Angular : 21.2.11
Node.js : 22.22.2
Package Manager : npm 10.9.7
Operating System : win32 x64
┌───────────────────────────────────┬───────────────────┬───────────────────┐
│ Package │ Installed Version │ Requested Version │
├───────────────────────────────────┼───────────────────┼───────────────────┤
│ @angular/build │ 21.2.9 │ ^21.2.3 │
│ @angular/cli │ 21.2.9 │ ^21.2.3 │
│ @angular/common │ 21.2.11 │ ^21.2.5 │
│ @angular/compiler │ 21.2.11 │ ^21.2.5 │
│ @angular/compiler-cli │ 21.2.11 │ ^21.2.5 │
│ @angular/core │ 21.2.11 │ ^21.2.5 │
│ @angular/forms │ 21.2.11 │ ^21.2.5 │
│ @angular/language-service │ 21.2.11 │ ^21.2.5 │
│ @angular/platform-browser │ 21.2.11 │ ^21.2.5 │
│ @angular/platform-browser-dynamic │ 21.2.11 │ ^21.2.5 │
│ @angular/router │ 21.2.11 │ ^21.2.5 │
│ @schematics/angular │ 21.2.9 │ ^21.2.0 │
│ ng-packagr │ 21.2.3 │ ^21.2.1 │
│ rxjs │ 7.8.2 │ ~7.8.0 │
│ typescript │ 5.9.3 │ ~5.9.3 │
│ vitest │ 4.1.5 │ ^4.0.18 │
│ zone.js │ 0.15.1 │ ~0.15.0 │
└───────────────────────────────────┴───────────────────┴───────────────────┘
Anything else relevant?
No response
Command
update
Is this a regression?
The previous version in which this bug was not present was
No response
Description
When
ng update(orng add) needs to install a temporary CLI, it resolves thetarget version by querying the registry with a non-exact range (e.g. just the
major:
@angular/cli@21). The CLI then picks the manifest to install via:https://github.com/angular/angular-cli/blob/main/packages/angular/cli/src/package-managers/parsers.ts#L237
The implicit assumption is "the last entry in the array is the highest matching
version". That assumption is not guaranteed.
npm view --json <pkg>@<range> <fields...>returns one manifest object permatching version. The order of that array is the order the versions appear in
the registry document, which is essentially publish order, not semver
order. Publish order matches semver order only as long as no version is ever
published "out of band". As soon as that assumption breaks — e.g. a security
patch is back-ported to an older minor after a newer minor has already shipped,
or an artifact mirror filters/reorders entries — the last element is no longer
the highest version, and the CLI silently downgrades the temporary CLI it
installs.
Affected call path
ng update @angular/core@<major>(or any other range-based invocation)commands/update/utilities/cli-version.ts→getCLIUpdateRunnerVersion()returns the major (e.g.21)packageManager.getManifest('@angular/cli@21')package-manager-descriptor.tsbuilds:npm view --json "@angular/cli@21" name version deprecated dependencies peerDependencies devDependencies homepage schematics ng-add ng-updateparseNpmLikeManifestreturnsresult[result.length - 1]The same code path is also used by
ng addwhenever a range/tag is passed, andby
parseNpmLikeManifestfor every package manager that delegates to it(
npm,yarnmodern,pnpm,bun— seepackage-manager-descriptor.ts).Why this matters in practice
A real-world scenario where this surfaces today, even when the public npm
registry happens to be in publish-order = semver-order:
comparable tools like JFrog Artifactory with Xray, etc.) routinely filter
or hold back specific versions while CVE/policy scans are pending or while
a version is quarantined.
npm viewarray, but the highest availableversion is no longer guaranteed to be at the end. Example registry response
for
@angular/cli@21after the proxy filters out21.2.7and21.2.8whilescanning, but already exposes
21.2.9:[..., 21.2.5, 21.2.6, 21.2.9, 21.2.7, 21.2.8]— depending on internalordering rules of the proxy.
npm viewmakes no documented guarantee on array ordering.after a newer minor has shipped. In that case the back-ported patch is
appended to the registry document, so it ends up last in the array, and
parseNpmLikeManifestpicks the older minor.In both cases, the user requested
@21expecting the highest 21.x, but the CLIinstalls an older 21.x as the temporary runner. Symptoms range from "update
runs against an outdated runner and misses migrations" to confusing version
mismatches in logs ("installing temporary Angular CLI versioned 21.2.6" while
21.2.9 is published).
Test that codifies the assumption
packages/angular/cli/src/package-managers/parsers_spec.ts(around L131):The test asserts "last" but the documented intent of the function (
getManifestis supposed to return the manifest the CLI will actually install — i.e. the
highest matching version) is "max-by-semver". The two only coincide when
the registry's array order matches semver order, which is not a guarantee npm
gives.
Suggested fix
Sort by semver and pick the highest, instead of trusting array order.
semveris already a dependency (see
commands/update/utilities/cli-version.ts).The corresponding spec should be updated to assert "highest by semver" rather
than "last", and a regression test added with a deliberately out-of-order array
(e.g.
[1.1.0, 1.0.0, 1.2.0, 1.1.5]→ expect1.2.0).Minimal Reproduction
I do not currently have a public reproduction. Locally we hit this through a
Sonatype Nexus proxy in front of npm, where Nexus IQ holds back specific
versions during policy/CVE scans, so the array returned by
npm viewis notguaranteed to be in ascending semver order. The bug, however, is independent
of the registry: it lives entirely in
parseNpmLikeManifest's assumptionabout array ordering, and is provable by code review alone — there is no point
in the CLI's logic where the array is sorted before
result[result.length - 1]is taken.
If a test-level reproduction is desired, the existing spec at
parsers_spec.ts:131can simply be inverted:Exception or Error
Your Environment
Anything else relevant?
No response