Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 63 additions & 10 deletions packages/angular/cli/src/commands/update/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,17 @@ export default class UpdateCommandModule extends CommandModule<UpdateCommandArgs
logger.info('Collecting installed dependencies...');

const rootDependencies = await packageManager.getProjectDependencies();

// In npm/pnpm/yarn workspace setups the package manager's `list` command is
// executed against the workspace root, so it may only surface the root
// workspace's direct dependencies. When the Angular project lives inside a
// workspace member its own `package.json` entries (e.g. `@angular/core`) will
// be absent from that list. To preserve the pre-v21 behaviour we supplement
// the map with any packages declared in the Angular project root's
// `package.json` that are resolvable from `node_modules` but were not already
// returned by the package manager.
await supplementWithLocalDependencies(rootDependencies, this.context.root);

logger.info(`Found ${rootDependencies.size} dependencies.`);

const workflow = new NodeWorkflow(this.context.root, {
Expand Down Expand Up @@ -675,22 +686,64 @@ export default class UpdateCommandModule extends CommandModule<UpdateCommandArgs
}
}

async function readPackageManifest(manifestPath: string): Promise<PackageManifest | undefined> {
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<string, InstalledPackage>,
projectRoot: string,
): Promise<void> {
const localManifest = await readPackageManifest(path.join(projectRoot, 'package.json'));
if (!localManifest) {
return;
}

return JSON.parse(content) as PackageManifest;
} catch {
return undefined;
const localDeps: Record<string, string> = {
...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<PackageManifest | undefined> {
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;
}
Expand Down
150 changes: 150 additions & 0 deletions packages/angular/cli/src/commands/update/cli_spec.ts
Original file line number Diff line number Diff line change
@@ -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:
*
* <projectRoot>/
* package.json ← Angular project manifest (workspace member)
* node_modules/
* <depName>/
* package.json ← installed package manifest
*/
async function createWorkspaceMemberFixture(options: {
projectDeps: Record<string, string>;
installedPackages: Array<{ name: string; version: string }>;
}): Promise<string> {
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<string, InstalledPackage>();

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<string, InstalledPackage>([['@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<string, InstalledPackage>();

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<string, InstalledPackage>();

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<string, InstalledPackage>();

// Should resolve without throwing.
await expectAsync(supplementWithLocalDependencies(deps, tmpDir)).toBeResolved();
expect(deps.size).toBe(0);
});
});
Loading