diff --git a/packages/angular/cli/src/commands/update/cli.ts b/packages/angular/cli/src/commands/update/cli.ts index de6d7f53fea0..fe4620870327 100644 --- a/packages/angular/cli/src/commands/update/cli.ts +++ b/packages/angular/cli/src/commands/update/cli.ts @@ -231,6 +231,17 @@ export default class UpdateCommandModule extends CommandModule { - try { - const content = await fs.readFile(manifestPath, 'utf8'); +/** + * Supplements the given dependency map with packages that are declared in the + * Angular project root's `package.json` but were not returned by the package + * manager's `list` command. + * + * In npm/pnpm/yarn workspace setups the package manager runs against the + * workspace root, which may not include dependencies that only appear in a + * workspace member's `package.json`. Reading the member's `package.json` + * directly and resolving the installed version from `node_modules` restores + * the behaviour that was present before the package-manager abstraction was + * introduced in v21. + * + * @param dependencies The map to supplement in place. + * @param projectRoot The root directory of the Angular project (workspace member). + */ +export async function supplementWithLocalDependencies( + dependencies: Map, + projectRoot: string, +): Promise { + const localManifest = await readPackageManifest(path.join(projectRoot, 'package.json')); + if (!localManifest) { + return; + } - return JSON.parse(content) as PackageManifest; - } catch { - return undefined; + const localDeps: Record = { + ...localManifest.dependencies, + ...localManifest.devDependencies, + ...localManifest.peerDependencies, + }; + + const projectRequire = createRequire(path.join(projectRoot, 'package.json')); + + for (const depName of Object.keys(localDeps)) { + if (dependencies.has(depName)) { + continue; + } + let pkgJsonPath: string; + try { + pkgJsonPath = projectRequire.resolve(`${depName}/package.json`); + } catch { + continue; + } + const installed = await readPackageManifest(pkgJsonPath); + if (installed?.version) { + dependencies.set(depName, { + name: depName, + version: installed.version, + path: path.dirname(pkgJsonPath), + }); + } } } -function findPackageJson(workspaceDir: string, packageName: string): string | undefined { +async function readPackageManifest(manifestPath: string): Promise { try { - const projectRequire = createRequire(path.join(workspaceDir, 'package.json')); - const packageJsonPath = projectRequire.resolve(`${packageName}/package.json`); + const content = await fs.readFile(manifestPath, 'utf8'); - return packageJsonPath; + return JSON.parse(content) as PackageManifest; } catch { return undefined; } diff --git a/packages/angular/cli/src/commands/update/cli_spec.ts b/packages/angular/cli/src/commands/update/cli_spec.ts new file mode 100644 index 000000000000..24719548576d --- /dev/null +++ b/packages/angular/cli/src/commands/update/cli_spec.ts @@ -0,0 +1,150 @@ +/** + * @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 * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { InstalledPackage } from '../../package-managers'; +import { supplementWithLocalDependencies } from './cli'; + +/** + * Creates a minimal on-disk fixture that simulates an npm workspace member: + * + * / + * package.json ← Angular project manifest (workspace member) + * node_modules/ + * / + * package.json ← installed package manifest + */ +async function createWorkspaceMemberFixture(options: { + projectDeps: Record; + installedPackages: Array<{ name: string; version: string }>; +}): Promise { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ng-update-spec-')); + + // Write the Angular project's package.json + await fs.writeFile( + path.join(projectRoot, 'package.json'), + JSON.stringify({ + name: 'test-app', + version: '0.0.0', + dependencies: options.projectDeps, + }), + ); + + // Write each installed package into node_modules + for (const pkg of options.installedPackages) { + // Support scoped packages like @angular/core + const pkgDir = path.join(projectRoot, 'node_modules', ...pkg.name.split('/')); + await fs.mkdir(pkgDir, { recursive: true }); + await fs.writeFile( + path.join(pkgDir, 'package.json'), + JSON.stringify({ name: pkg.name, version: pkg.version }), + ); + } + + return projectRoot; +} + +describe('supplementWithLocalDependencies', () => { + let tmpDir: string; + + afterEach(async () => { + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('should add packages from the local package.json that are missing from the dependency map', async () => { + // Simulates an npm workspace member where `npm list` (run against the + // workspace root) did not return `@angular/core`, even though it is + // declared in the member's package.json and installed in node_modules. + tmpDir = await createWorkspaceMemberFixture({ + projectDeps: { '@angular/core': '^21.0.0' }, + installedPackages: [{ name: '@angular/core', version: '21.2.4' }], + }); + + const deps = new Map(); + + await supplementWithLocalDependencies(deps, tmpDir); + + expect(deps.has('@angular/core')).toBeTrue(); + expect(deps.get('@angular/core')?.version).toBe('21.2.4'); + }); + + it('should not overwrite a package that is already present in the dependency map', async () => { + tmpDir = await createWorkspaceMemberFixture({ + projectDeps: { '@angular/core': '^21.0.0' }, + installedPackages: [{ name: '@angular/core', version: '21.2.4' }], + }); + + // The package manager already returned a version for @angular/core. + const existingEntry: InstalledPackage = { name: '@angular/core', version: '21.0.0' }; + const deps = new Map([['@angular/core', existingEntry]]); + + await supplementWithLocalDependencies(deps, tmpDir); + + // The existing entry must not be overwritten. + expect(deps.get('@angular/core')).toBe(existingEntry); + expect(deps.get('@angular/core')?.version).toBe('21.0.0'); + }); + + it('should skip packages that are declared in package.json but not installed in node_modules', async () => { + tmpDir = await createWorkspaceMemberFixture({ + projectDeps: { 'not-installed': '^1.0.0' }, + installedPackages: [], + }); + + const deps = new Map(); + + await supplementWithLocalDependencies(deps, tmpDir); + + // Package is not installed; should not be added. + expect(deps.has('not-installed')).toBeFalse(); + }); + + it('should handle devDependencies and peerDependencies in addition to dependencies', async () => { + tmpDir = await createWorkspaceMemberFixture({ + projectDeps: {}, + installedPackages: [ + { name: 'rxjs', version: '7.8.2' }, + { name: 'zone.js', version: '0.15.0' }, + ], + }); + + // Write a package.json that uses devDependencies and peerDependencies. + await fs.writeFile( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test-app', + version: '0.0.0', + devDependencies: { 'zone.js': '~0.15.0' }, + peerDependencies: { rxjs: '~7.8.0' }, + }), + ); + + const deps = new Map(); + + await supplementWithLocalDependencies(deps, tmpDir); + + expect(deps.has('zone.js')).toBeTrue(); + expect(deps.get('zone.js')?.version).toBe('0.15.0'); + expect(deps.has('rxjs')).toBeTrue(); + expect(deps.get('rxjs')?.version).toBe('7.8.2'); + }); + + it('should do nothing when the project root has no package.json', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ng-update-spec-')); + + const deps = new Map(); + + // Should resolve without throwing. + await expectAsync(supplementWithLocalDependencies(deps, tmpDir)).toBeResolved(); + expect(deps.size).toBe(0); + }); +});