From bbd7cf282759c739a5f478e158e80cedb15bb7d8 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 90217f30555a57148ee321978bd1777aeb2398eb 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 | 39 +++++++++++++- 3 files changed, 95 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 f48ed1e32ed7..a7b1e29f64cf 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, @@ -241,10 +242,10 @@ export const SUPPORTED_PACKAGE_MANAGERS = { ignoreScriptsFlag: '--ignore-scripts', 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 0e12fd5f0cfb..49d396eb9d58 100644 --- a/packages/angular/cli/src/package-managers/parsers.ts +++ b/packages/angular/cli/src/package-managers/parsers.ts @@ -519,3 +519,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 8717a6d1a5a1..b8ee75da6e7a 100644 --- a/packages/angular/cli/src/package-managers/parsers_spec.ts +++ b/packages/angular/cli/src/package-managers/parsers_spec.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import { parseNpmLikeError, parseYarnClassicError } from './parsers'; +import { parseBunDependencies, parseNpmLikeError, parseYarnClassicError } from './parsers'; describe('parsers', () => { describe('parseNpmLikeError', () => { @@ -109,4 +109,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 f9bd3b8f99de10da08cea90012d5d9a2e4e33d85 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 3de979b481e6..614b35fe548b 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 b6d5789a7f22908ff76f241c5ebbfe3bb4c74fb5 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 | 41 +++++- 3 files changed, 97 insertions(+), 70 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 a7b1e29f64cf..1a073f13bf5e 100644 --- a/packages/angular/cli/src/package-managers/package-manager-descriptor.ts +++ b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts @@ -167,7 +167,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = { ignoreScriptsFlag: '--ignore-scripts', getRegistryOptions: (registry: string) => ({ env: { NPM_CONFIG_REGISTRY: 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 49d396eb9d58..c2b4c09f2d1e 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. @@ -573,3 +505,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 b8ee75da6e7a..db488864ce8f 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 { parseBunDependencies, parseNpmLikeError, parseYarnClassicError } from './parsers'; +import { + parseBunDependencies, + parseNpmLikeError, + parseYarnClassicError, + parseYarnModernDependencies, +} from './parsers'; describe('parsers', () => { describe('parseNpmLikeError', () => { @@ -146,4 +151,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 ed31134d0fd0137400d31882f801dd7753135aa7 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 c2b4c09f2d1e..b3d23003c1f3 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 db488864ce8f..e6220d50c4c8 100644 --- a/packages/angular/cli/src/package-managers/parsers_spec.ts +++ b/packages/angular/cli/src/package-managers/parsers_spec.ts @@ -9,6 +9,7 @@ import { parseBunDependencies, parseNpmLikeError, + parseYarnClassicDependencies, parseYarnClassicError, parseYarnModernDependencies, } from './parsers'; @@ -115,6 +116,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 = `