From e6ffa0f000c71fabf7b449833396a1f108c118af Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 30 Dec 2025 13:09:08 -0500 Subject: [PATCH 1/5] test: skip unscoped auth E2E update tests on unsupported package managers This commit updates the `update-secure-registry` E2E test to skip unscoped authentication test cases when the active package manager is not Yarn, as other package managers may not support or correctly handle this setup in the test environment. --- .../tests/update/update-secure-registry.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/tests/e2e/tests/update/update-secure-registry.ts b/tests/e2e/tests/update/update-secure-registry.ts index 27b772799566..b52d311a622f 100644 --- a/tests/e2e/tests/update/update-secure-registry.ts +++ b/tests/e2e/tests/update/update-secure-registry.ts @@ -6,6 +6,9 @@ import { getActivePackageManager } from '../../utils/packages'; import assert from 'node:assert'; export default async function () { + const packageManager = getActivePackageManager(); + const supportsUnscopedAuth = packageManager === 'yarn'; + // The environment variable has priority over the .npmrc delete process.env['NPM_CONFIG_REGISTRY']; const worksMessage = 'We analyzed your package.json'; @@ -16,10 +19,13 @@ export default async function () { } // Valid authentication token - await createNpmConfigForAuthentication(false); - const { stdout: stdout1 } = await ng('update', ...extraArgs); - if (!stdout1.includes(worksMessage)) { - throw new Error(`Expected stdout to contain "${worksMessage}"`); + + if (supportsUnscopedAuth) { + await createNpmConfigForAuthentication(false); + const { stdout: stdout1 } = await ng('update', ...extraArgs); + if (!stdout1.includes(worksMessage)) { + throw new Error(`Expected stdout to contain "${worksMessage}"`); + } } await createNpmConfigForAuthentication(true); @@ -29,8 +35,11 @@ export default async function () { } // Invalid authentication token - await createNpmConfigForAuthentication(false, true); - await expectToFail(() => ng('update', ...extraArgs)); + + if (supportsUnscopedAuth) { + await createNpmConfigForAuthentication(false, true); + await expectToFail(() => ng('update', ...extraArgs)); + } await createNpmConfigForAuthentication(true, true); await expectToFail(() => ng('update', ...extraArgs)); From c8d469859ccd36e7fb051e6eba38d85a3cfc3778 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 30 Dec 2025 12:13:52 -0500 Subject: [PATCH 2/5] refactor(@angular/cli): add custom parser for bun pm ls Bun does not support JSON output for the `pm ls` command. This commit introduces a custom parser to interpret Bun's tree-like output format, allowing the package manager abstraction to correctly discover installed dependencies when using Bun. --- .../package-manager-descriptor.ts | 5 +- .../cli/src/package-managers/parsers.ts | 54 +++++++++++++++++++ .../cli/src/package-managers/parsers_spec.ts | 44 ++++++++++++++- 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/packages/angular/cli/src/package-managers/package-manager-descriptor.ts b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts index 4bcc2f6afeed..ae49fe0c0f3e 100644 --- a/packages/angular/cli/src/package-managers/package-manager-descriptor.ts +++ b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts @@ -17,6 +17,7 @@ import { Logger } from './logger'; import { PackageManifest, PackageMetadata } from './package-metadata'; import { InstalledPackage } from './package-tree'; import { + parseBunDependencies, parseNpmLikeDependencies, parseNpmLikeError, parseNpmLikeManifest, @@ -261,10 +262,10 @@ export const SUPPORTED_PACKAGE_MANAGERS = { copyConfigFromProject: true, getRegistryOptions: (registry: string) => ({ args: ['--registry', registry] }), versionCommand: ['--version'], - listDependenciesCommand: ['pm', 'ls', '--json'], + listDependenciesCommand: ['pm', 'ls'], getManifestCommand: ['pm', 'view', '--json'], outputParsers: { - listDependencies: parseNpmLikeDependencies, + listDependencies: parseBunDependencies, getRegistryManifest: parseNpmLikeManifest, getRegistryMetadata: parseNpmLikeMetadata, getError: parseNpmLikeError, diff --git a/packages/angular/cli/src/package-managers/parsers.ts b/packages/angular/cli/src/package-managers/parsers.ts index ca52fd49d817..fb7084edbc4e 100644 --- a/packages/angular/cli/src/package-managers/parsers.ts +++ b/packages/angular/cli/src/package-managers/parsers.ts @@ -521,3 +521,57 @@ export function parseYarnClassicError(output: string, logger?: Logger): ErrorInf return null; } + +/** + * Parses the output of `bun pm ls`. + * + * Bun does not support JSON output for `pm ls`. The output is a tree structure: + * ``` + * /path/to/project node_modules (1084) + * ├── @angular/core@20.3.15 + * ├── rxjs @7.8.2 + * └── zone.js @0.15.1 + * ``` + * + * @param stdout The standard output of the command. + * @param logger An optional logger instance. + * @returns A map of package names to their installed package details. + */ +export function parseBunDependencies( + stdout: string, + logger?: Logger, +): Map { + logger?.debug('Parsing Bun dependency list...'); + logStdout(stdout, logger); + + const dependencies = new Map(); + if (!stdout) { + return dependencies; + } + + const lines = stdout.split('\n'); + // Skip the first line (project info) + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) { + continue; + } + + // Remove tree structure characters + const cleanLine = line.replace(/^[└├]──\s*/, ''); + + // Parse name and version + // Scoped: @angular/core@20.3.15 + // Unscoped: rxjs @7.8.2 + const match = cleanLine.match(/^(.+?)\s?@([^@\s]+)$/); + if (match) { + const name = match[1]; + const version = match[2]; + dependencies.set(name, { name, version }); + } + } + + logger?.debug(` Found ${dependencies.size} dependencies.`); + + return dependencies; +} diff --git a/packages/angular/cli/src/package-managers/parsers_spec.ts b/packages/angular/cli/src/package-managers/parsers_spec.ts index 3b831d71a286..524f376a8846 100644 --- a/packages/angular/cli/src/package-managers/parsers_spec.ts +++ b/packages/angular/cli/src/package-managers/parsers_spec.ts @@ -6,7 +6,12 @@ * found in the LICENSE file at https://angular.dev/license */ -import { parseNpmLikeError, parseNpmLikeManifest, parseYarnClassicError } from './parsers'; +import { + parseBunDependencies, + parseNpmLikeError, + parseNpmLikeManifest, + parseYarnClassicError, +} from './parsers'; describe('parsers', () => { describe('parseNpmLikeError', () => { @@ -128,4 +133,41 @@ describe('parsers', () => { expect(error).toBeNull(); }); }); + + describe('parseBunDependencies', () => { + it('should parse bun pm ls output', () => { + const stdout = ` +/tmp/angular-cli-e2e-PiL5n3/e2e-test/assets/19.0-project-1767113081927 node_modules (1084) +├── @angular-devkit/build-angular@20.3.13 +├── @angular/cli@20.3.13 +├── jasmine-core @5.6.0 +├── rxjs @7.8.2 +└── zone.js @0.15.1 +`.trim(); + + const deps = parseBunDependencies(stdout); + expect(deps.size).toBe(5); + expect(deps.get('@angular-devkit/build-angular')).toEqual({ + name: '@angular-devkit/build-angular', + version: '20.3.13', + }); + expect(deps.get('@angular/cli')).toEqual({ name: '@angular/cli', version: '20.3.13' }); + expect(deps.get('jasmine-core')).toEqual({ name: 'jasmine-core', version: '5.6.0' }); + expect(deps.get('rxjs')).toEqual({ name: 'rxjs', version: '7.8.2' }); + expect(deps.get('zone.js')).toEqual({ name: 'zone.js', version: '0.15.1' }); + }); + + it('should return empty map for empty stdout', () => { + expect(parseBunDependencies('').size).toBe(0); + }); + + it('should skip lines that do not match the pattern', () => { + const stdout = ` +project node_modules +├── invalid-line +└── another-invalid +`.trim(); + expect(parseBunDependencies(stdout).size).toBe(0); + }); + }); }); From d751e1eeea3665aae5688691a49a690795fa8277 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 30 Dec 2025 10:55:26 -0500 Subject: [PATCH 3/5] refactor(@angular/cli): use package manager abstraction for update command dependencies This commit refactors the `ng update` command to use the `PackageManager` abstraction for discovering installed dependencies, replacing the previous file-system-based resolution logic. This change improves correctness (respecting package manager resolution strategies like PnP and workspaces) and performance (reducing initial file I/O). --- .../angular/cli/src/commands/update/cli.ts | 86 ++++++++++++------- 1 file changed, 56 insertions(+), 30 deletions(-) diff --git a/packages/angular/cli/src/commands/update/cli.ts b/packages/angular/cli/src/commands/update/cli.ts index 9b926cc079a2..b5f0be73539e 100644 --- a/packages/angular/cli/src/commands/update/cli.ts +++ b/packages/angular/cli/src/commands/update/cli.ts @@ -20,16 +20,16 @@ import { Options, } from '../../command-builder/command-module'; import { SchematicEngineHost } from '../../command-builder/utilities/schematic-engine-host'; -import { PackageManager, PackageManifest, createPackageManager } from '../../package-managers'; +import { + InstalledPackage, + PackageManager, + PackageManifest, + createPackageManager, +} from '../../package-managers'; import { colors } from '../../utilities/color'; import { disableVersionCheck } from '../../utilities/environment-options'; import { assertIsError } from '../../utilities/error'; -import { - PackageTreeNode, - findPackageJson, - getProjectDependencies, - readPackageJson, -} from '../../utilities/package-tree'; +import { findPackageJson } from '../../utilities/package-tree'; import { checkCLIVersion, coerceVersionNumber, @@ -242,7 +242,7 @@ export default class UpdateCommandModule extends CommandModule, + rootDependencies: Map, options: Options, + packageManager: PackageManager, ): Promise { const { logger } = this.context; - const packageDependency = rootDependencies.get(packageName); + let packageDependency = rootDependencies.get(packageName); let packagePath = packageDependency?.path; - let packageNode = packageDependency?.package; - if (packageDependency && !packageNode) { - logger.error('Package found in package.json but is not installed.'); + let packageNode: PackageManifest | undefined; - return 1; - } else if (!packageDependency) { - // Allow running migrations on transitively installed dependencies - // There can technically be nested multiple versions - // TODO: If multiple, this should find all versions and ask which one to use - const packageJson = findPackageJson(this.context.root, packageName); - if (packageJson) { - packagePath = path.dirname(packageJson); - packageNode = await readPackageJson(packageJson); + if (!packageDependency) { + const installed = await packageManager.getInstalledPackage(packageName); + if (installed) { + packageDependency = installed; + packagePath = installed.path; + } + } + + if (packagePath) { + packageNode = await readPackageManifest(path.join(packagePath, 'package.json')); + } + + if (!packageNode) { + const jsonPath = findPackageJson(this.context.root, packageName); + if (jsonPath) { + packageNode = await readPackageManifest(jsonPath); + + if (!packagePath) { + packagePath = path.dirname(jsonPath); + } } } @@ -399,7 +415,7 @@ export default class UpdateCommandModule extends CommandModule, + rootDependencies: Map, options: Options, packages: npa.Result[], packageManager: PackageManager, @@ -414,21 +430,21 @@ export default class UpdateCommandModule extends CommandModule { + try { + const content = await fs.readFile(manifestPath, 'utf8'); + + return JSON.parse(content) as PackageManifest; + } catch { + return undefined; + } +} From 7e993b7d06dd22dfc3d236c2efb93ca5c6126598 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 30 Dec 2025 14:11:28 -0500 Subject: [PATCH 4/5] refactor(@angular/cli): update yarn modern dependency parsing This commit updates the dependency discovery logic for Yarn Modern (Berry) to use the `yarn info --name-only --json` command, as the `list` command is not available in newer versions of Yarn. --- .../package-manager-descriptor.ts | 2 +- .../cli/src/package-managers/parsers.ts | 124 ++++++++---------- .../cli/src/package-managers/parsers_spec.ts | 35 +++++ 3 files changed, 92 insertions(+), 69 deletions(-) diff --git a/packages/angular/cli/src/package-managers/package-manager-descriptor.ts b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts index ae49fe0c0f3e..34db06b64c99 100644 --- a/packages/angular/cli/src/package-managers/package-manager-descriptor.ts +++ b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts @@ -182,7 +182,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = { configFiles: ['.yarnrc.yml', '.yarnrc.yaml'], getRegistryOptions: (registry: string) => ({ env: { YARN_NPM_REGISTRY_SERVER: registry } }), versionCommand: ['--version'], - listDependenciesCommand: ['list', '--depth=0', '--json', '--recursive=false'], + listDependenciesCommand: ['info', '--name-only', '--json'], getManifestCommand: ['npm', 'info', '--json'], viewCommandFieldArgFormatter: (fields) => ['--fields', fields.join(',')], outputParsers: { diff --git a/packages/angular/cli/src/package-managers/parsers.ts b/packages/angular/cli/src/package-managers/parsers.ts index fb7084edbc4e..3c64cb4a7856 100644 --- a/packages/angular/cli/src/package-managers/parsers.ts +++ b/packages/angular/cli/src/package-managers/parsers.ts @@ -170,74 +170,6 @@ export function parseYarnClassicDependencies( return dependencies; } -/** - * Parses the output of `yarn list` (modern). - * - * The expected JSON structure is a single object. - * Yarn modern does not provide a path, so the `path` property will be `undefined`. - * - * ```json - * { - * "trees": [ - * { "name": "@angular/cli@18.0.0", "children": [] } - * ] - * } - * ``` - * - * @param stdout The standard output of the command. - * @param logger An optional logger instance. - * @returns A map of package names to their installed package details. - */ -export function parseYarnModernDependencies( - stdout: string, - logger?: Logger, -): Map { - logger?.debug(`Parsing yarn modern dependency list...`); - logStdout(stdout, logger); - - const dependencies = new Map(); - if (!stdout) { - logger?.debug(' stdout is empty. No dependencies found.'); - - return dependencies; - } - - // Modern yarn `list` command outputs a single JSON object with a `trees` property. - // Each line is not a separate JSON object. - try { - const data = JSON.parse(stdout); - for (const info of data.trees) { - const name = info.name.split('@')[0]; - const version = info.name.split('@').pop(); - dependencies.set(name, { - name, - version, - }); - } - } catch (e) { - logger?.debug( - ` Failed to parse as single JSON object: ${e}. Falling back to line-by-line parsing.`, - ); - // Fallback for older versions of yarn berry that might still output json lines - for (const json of parseJsonLines(stdout, logger)) { - if (json.type === 'tree' && json.data?.trees) { - for (const info of json.data.trees) { - const name = info.name.split('@')[0]; - const version = info.name.split('@').pop(); - dependencies.set(name, { - name, - version, - }); - } - } - } - } - - logger?.debug(` Found ${dependencies.size} dependencies.`); - - return dependencies; -} - /** * Parses the output of `npm view` or a compatible command to get a package manifest. * @param stdout The standard output of the command. @@ -575,3 +507,59 @@ export function parseBunDependencies( return dependencies; } + +/** + * Parses the output of `yarn info --name-only --json`. + * + * The expected output is a JSON stream (JSONL) of strings. + * Each string represents a package locator. + * + * ``` + * "karma@npm:6.4.4" + * "@angular/core@npm:20.3.15" + * ``` + * + * @param stdout The standard output of the command. + * @param logger An optional logger instance. + * @returns A map of package names to their installed package details. + */ +export function parseYarnModernDependencies( + stdout: string, + logger?: Logger, +): Map { + logger?.debug('Parsing Yarn Berry dependency list...'); + logStdout(stdout, logger); + + const dependencies = new Map(); + if (!stdout) { + return dependencies; + } + + for (const json of parseJsonLines(stdout, logger)) { + if (typeof json === 'string') { + const match = json.match(/^(@?[^@]+)@(.+)$/); + if (match) { + const name = match[1]; + let version = match[2]; + + // Handle "npm:" prefix + if (version.startsWith('npm:')) { + version = version.slice(4); + } + + // Handle complex locators with embedded version metadata (e.g., "patch:...", "virtual:...") + // Yarn Berry often appends metadata like "::version=x.y.z" + const versionParamMatch = version.match(/::version=([^&]+)/); + if (versionParamMatch) { + version = versionParamMatch[1]; + } + + dependencies.set(name, { name, version }); + } + } + } + + logger?.debug(` Found ${dependencies.size} dependencies.`); + + return dependencies; +} diff --git a/packages/angular/cli/src/package-managers/parsers_spec.ts b/packages/angular/cli/src/package-managers/parsers_spec.ts index 524f376a8846..aec708c00360 100644 --- a/packages/angular/cli/src/package-managers/parsers_spec.ts +++ b/packages/angular/cli/src/package-managers/parsers_spec.ts @@ -11,6 +11,7 @@ import { parseNpmLikeError, parseNpmLikeManifest, parseYarnClassicError, + parseYarnModernDependencies, } from './parsers'; describe('parsers', () => { @@ -170,4 +171,38 @@ project node_modules expect(parseBunDependencies(stdout).size).toBe(0); }); }); + + describe('parseYarnModernDependencies', () => { + it('should parse yarn info --name-only --json output', () => { + const stdout = ` +"karma@npm:6.4.4" +"rxjs@npm:7.8.2" +"tslib@npm:2.8.1" +"typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" +`.trim(); + + const deps = parseYarnModernDependencies(stdout); + expect(deps.size).toBe(4); + expect(deps.get('karma')).toEqual({ name: 'karma', version: '6.4.4' }); + expect(deps.get('rxjs')).toEqual({ name: 'rxjs', version: '7.8.2' }); + expect(deps.get('tslib')).toEqual({ name: 'tslib', version: '2.8.1' }); + expect(deps.get('typescript')).toEqual({ + name: 'typescript', + version: '5.9.3', + }); + }); + + it('should handle scoped packages', () => { + const stdout = '"@angular/core@npm:20.3.15"'; + const deps = parseYarnModernDependencies(stdout); + expect(deps.get('@angular/core')).toEqual({ + name: '@angular/core', + version: '20.3.15', + }); + }); + + it('should return empty map for empty stdout', () => { + expect(parseYarnModernDependencies('').size).toBe(0); + }); + }); }); From 286cf5c5f8bdf4570b1e3c6ce01b8c7f789a9d0a Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 30 Dec 2025 14:25:47 -0500 Subject: [PATCH 5/5] fix(@angular/cli): correctly parse scoped packages in yarn classic list output The `parseYarnClassicDependencies` function incorrectly parsed scoped packages (e.g., `@angular/core@18.0.0`) by splitting at the first `@`, resulting in an empty name. This commit updates the logic to split at the last `@`, ensuring scoped package names are correctly identified. --- .../cli/src/package-managers/parsers.ts | 6 ++-- .../cli/src/package-managers/parsers_spec.ts | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/angular/cli/src/package-managers/parsers.ts b/packages/angular/cli/src/package-managers/parsers.ts index 3c64cb4a7856..c9f7fb235087 100644 --- a/packages/angular/cli/src/package-managers/parsers.ts +++ b/packages/angular/cli/src/package-managers/parsers.ts @@ -155,8 +155,10 @@ export function parseYarnClassicDependencies( for (const json of parseJsonLines(stdout, logger)) { if (json.type === 'tree' && json.data?.trees) { for (const info of json.data.trees) { - const name = info.name.split('@')[0]; - const version = info.name.split('@').pop(); + const lastAtIndex = info.name.lastIndexOf('@'); + const name = info.name.slice(0, lastAtIndex); + const version = info.name.slice(lastAtIndex + 1); + dependencies.set(name, { name, version, diff --git a/packages/angular/cli/src/package-managers/parsers_spec.ts b/packages/angular/cli/src/package-managers/parsers_spec.ts index aec708c00360..2fa8abdc1e32 100644 --- a/packages/angular/cli/src/package-managers/parsers_spec.ts +++ b/packages/angular/cli/src/package-managers/parsers_spec.ts @@ -10,6 +10,7 @@ import { parseBunDependencies, parseNpmLikeError, parseNpmLikeManifest, + parseYarnClassicDependencies, parseYarnClassicError, parseYarnModernDependencies, } from './parsers'; @@ -135,6 +136,38 @@ describe('parsers', () => { }); }); + describe('parseYarnClassicDependencies', () => { + it('should parse yarn classic list output', () => { + const stdout = JSON.stringify({ + type: 'tree', + data: { + trees: [{ name: 'rxjs@7.8.2', children: [] }], + }, + }); + + const deps = parseYarnClassicDependencies(stdout); + expect(deps.size).toBe(1); + expect(deps.get('rxjs')).toEqual({ name: 'rxjs', version: '7.8.2' }); + }); + + it('should handle scoped packages', () => { + const stdout = JSON.stringify({ + type: 'tree', + data: { + trees: [{ name: '@angular/core@18.0.0', children: [] }], + }, + }); + + const deps = parseYarnClassicDependencies(stdout); + expect(deps.size).toBe(1); + expect(deps.get('@angular/core')).toEqual({ name: '@angular/core', version: '18.0.0' }); + }); + + it('should return empty map for empty stdout', () => { + expect(parseYarnClassicDependencies('').size).toBe(0); + }); + }); + describe('parseBunDependencies', () => { it('should parse bun pm ls output', () => { const stdout = `