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; + } +} 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..1a073f13bf5e 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, @@ -166,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: { @@ -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..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, @@ -170,74 +172,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. @@ -519,3 +453,113 @@ 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; +} + +/** + * 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 8717a6d1a5a1..e6220d50c4c8 100644 --- a/packages/angular/cli/src/package-managers/parsers_spec.ts +++ b/packages/angular/cli/src/package-managers/parsers_spec.ts @@ -6,7 +6,13 @@ * found in the LICENSE file at https://angular.dev/license */ -import { parseNpmLikeError, parseYarnClassicError } from './parsers'; +import { + parseBunDependencies, + parseNpmLikeError, + parseYarnClassicDependencies, + parseYarnClassicError, + parseYarnModernDependencies, +} from './parsers'; describe('parsers', () => { describe('parseNpmLikeError', () => { @@ -109,4 +115,107 @@ describe('parsers', () => { expect(error).toBeNull(); }); }); + + 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 = ` +/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); + }); + }); + + 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); + }); + }); }); 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));