Skip to content
Merged
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
231 changes: 148 additions & 83 deletions packages/angular/cli/src/commands/add/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { assertIsError } from '../../utilities/error';
import {
NgAddSaveDependency,
PackageManifest,
PackageMetadata,
fetchPackageManifest,
fetchPackageMetadata,
} from '../../utilities/package-metadata';
Expand All @@ -49,7 +50,8 @@ interface AddCommandTaskContext {
savePackage?: NgAddSaveDependency;
collectionName?: string;
executeSchematic: AddCommandModule['executeSchematic'];
hasMismatchedPeer: AddCommandModule['hasMismatchedPeer'];
getPeerDependencyConflicts: AddCommandModule['getPeerDependencyConflicts'];
dryRun?: boolean;
}

type AddCommandTaskWrapper = ListrTaskWrapper<
Expand All @@ -70,6 +72,8 @@ const packageVersionExclusions: Record<string, string | Range> = {
'@angular/material': '7.x',
};

const DEFAULT_CONFLICT_DISPLAY_LIMIT = 5;

export default class AddCommandModule
extends SchematicsCommandModule
implements CommandModuleImplementation<AddCommandArgs>
Expand All @@ -80,6 +84,7 @@ export default class AddCommandModule
protected override allowPrivateSchematics = true;
private readonly schematicName = 'ng-add';
private rootRequire = createRequire(this.context.root + '/');
#projectVersionCache = new Map<string, string | null>();

override async builder(argv: Argv): Promise<Argv<AddCommandArgs>> {
const localYargs = (await super.builder(argv))
Expand Down Expand Up @@ -128,6 +133,7 @@ export default class AddCommandModule
}

async run(options: Options<AddCommandArgs> & OtherOptions): Promise<number | void> {
this.#projectVersionCache.clear();
const { logger } = this.context;
const { collection, skipConfirmation } = options;

Expand Down Expand Up @@ -158,7 +164,8 @@ export default class AddCommandModule
const taskContext: AddCommandTaskContext = {
packageIdentifier,
executeSchematic: this.executeSchematic.bind(this),
hasMismatchedPeer: this.hasMismatchedPeer.bind(this),
getPeerDependencyConflicts: this.getPeerDependencyConflicts.bind(this),
dryRun: options.dryRun,
};

const tasks = new Listr<AddCommandTaskContext>(
Expand All @@ -181,11 +188,21 @@ export default class AddCommandModule
},
{
title: 'Confirming installation',
enabled: !skipConfirmation,
enabled: !skipConfirmation && !options.dryRun,
task: (context, task) => this.confirmInstallationTask(context, task),
rendererOptions: { persistentOutput: true },
},
{
title: 'Installing package',
skip: (context) => {
if (context.dryRun) {
return `Skipping package installation. Would install package ${color.blue(
context.packageIdentifier.toString(),
)}.`;
}

return false;
},
task: (context, task) => this.installPackageTask(context, task, options),
rendererOptions: { bottomBar: Infinity },
},
Expand All @@ -200,6 +217,12 @@ export default class AddCommandModule
const result = await tasks.run(taskContext);
assert(result.collectionName, 'Collection name should always be available');

if (options.dryRun) {
logger.info('The package schematic would be executed next.');

return;
}

return this.executeSchematic({ ...options, collection: result.collectionName });
} catch (e) {
if (e instanceof CommandError) {
Expand Down Expand Up @@ -248,76 +271,97 @@ export default class AddCommandModule
throw new CommandError(`Unable to load package information from registry: ${e.message}`);
}

const rejectionReasons: string[] = [];

// Start with the version tagged as `latest` if it exists
const latestManifest = packageMetadata.tags['latest'];
if (latestManifest) {
context.packageIdentifier = npa.resolve(latestManifest.name, latestManifest.version);
}
const latestConflicts = await this.getPeerDependencyConflicts(latestManifest);
if (latestConflicts) {
// 'latest' is invalid so search for most recent matching package
rejectionReasons.push(...latestConflicts);
} else {
context.packageIdentifier = npa.resolve(latestManifest.name, latestManifest.version);
task.output = `Found compatible package version: ${color.blue(latestManifest.version)}.`;

// Adjust the version based on name and peer dependencies
if (
latestManifest?.peerDependencies &&
Object.keys(latestManifest.peerDependencies).length === 0
) {
task.output = `Found compatible package version: ${color.blue(latestManifest.version)}.`;
} else if (!latestManifest || (await context.hasMismatchedPeer(latestManifest))) {
// 'latest' is invalid so search for most recent matching package

// Allow prelease versions if the CLI itself is a prerelease
const allowPrereleases = prerelease(VERSION.full);

const versionExclusions = packageVersionExclusions[packageMetadata.name];
const versionManifests = Object.values(packageMetadata.versions).filter(
(value: PackageManifest) => {
// Prerelease versions are not stable and should not be considered by default
if (!allowPrereleases && prerelease(value.version)) {
return false;
}
// Deprecated versions should not be used or considered
if (value.deprecated) {
return false;
}
// Excluded package versions should not be considered
if (
versionExclusions &&
satisfies(value.version, versionExclusions, { includePrerelease: true })
) {
return false;
}
return;
}
}

return true;
},
);
// Allow prelease versions if the CLI itself is a prerelease
const allowPrereleases = !!prerelease(VERSION.full);
const versionManifests = this.#getPotentialVersionManifests(packageMetadata, allowPrereleases);

// Sort in reverse SemVer order so that the newest compatible version is chosen
versionManifests.sort((a, b) => compare(b.version, a.version, true));
let found = false;
for (const versionManifest of versionManifests) {
// Already checked the 'latest' version
if (latestManifest?.version === versionManifest.version) {
continue;
}

let found = false;
for (const versionManifest of versionManifests) {
const mismatch = await context.hasMismatchedPeer(versionManifest);
if (mismatch) {
continue;
const conflicts = await this.getPeerDependencyConflicts(versionManifest);
if (conflicts) {
if (options.verbose || rejectionReasons.length < DEFAULT_CONFLICT_DISPLAY_LIMIT) {
rejectionReasons.push(...conflicts);
}

context.packageIdentifier = npa.resolve(versionManifest.name, versionManifest.version);
found = true;
break;
continue;
}

if (!found) {
task.output = "Unable to find compatible package. Using 'latest' tag.";
} else {
task.output = `Found compatible package version: ${color.blue(
context.packageIdentifier.toString(),
)}.`;
context.packageIdentifier = npa.resolve(versionManifest.name, versionManifest.version);
found = true;
break;
}

if (!found) {
let message = `Unable to find compatible package. Using 'latest' tag.`;
if (rejectionReasons.length > 0) {
message +=
'\nThis is often because of incompatible peer dependencies.\n' +
'These versions were rejected due to the following conflicts:\n' +
rejectionReasons
.slice(0, options.verbose ? undefined : DEFAULT_CONFLICT_DISPLAY_LIMIT)
.map((r) => ` - ${r}`)
.join('\n');
}
task.output = message;
} else {
task.output = `Found compatible package version: ${color.blue(
context.packageIdentifier.toString(),
)}.`;
}
}

#getPotentialVersionManifests(
packageMetadata: PackageMetadata,
allowPrereleases: boolean,
): PackageManifest[] {
const versionExclusions = packageVersionExclusions[packageMetadata.name];
const versionManifests = Object.values(packageMetadata.versions).filter(
(value: PackageManifest) => {
// Prerelease versions are not stable and should not be considered by default
if (!allowPrereleases && prerelease(value.version)) {
return false;
}
// Deprecated versions should not be used or considered
if (value.deprecated) {
return false;
}
// Excluded package versions should not be considered
if (
versionExclusions &&
satisfies(value.version, versionExclusions, { includePrerelease: true })
) {
return false;
}

return true;
},
);

// Sort in reverse SemVer order so that the newest compatible version is chosen
return versionManifests.sort((a, b) => compare(b.version, a.version, true));
}

private async loadPackageInfoTask(
context: AddCommandTaskContext,
task: AddCommandTaskWrapper,
Expand All @@ -343,7 +387,7 @@ export default class AddCommandModule
context.savePackage = manifest['ng-add']?.save;
context.collectionName = manifest.name;

if (await context.hasMismatchedPeer(manifest)) {
if (await this.getPeerDependencyConflicts(manifest)) {
task.output = color.yellow(
figures.warning +
' Package has unmet peer dependencies. Adding the package may not succeed.',
Expand Down Expand Up @@ -533,6 +577,11 @@ export default class AddCommandModule
}

private async findProjectVersion(name: string): Promise<string | null> {
const cachedVersion = this.#projectVersionCache.get(name);
if (cachedVersion !== undefined) {
return cachedVersion;
}

const { logger, root } = this.context;
let installedPackage;
try {
Expand All @@ -542,6 +591,7 @@ export default class AddCommandModule
if (installedPackage) {
try {
const installed = await fetchPackageManifest(dirname(installedPackage), logger);
this.#projectVersionCache.set(name, installed.version);

return installed.version;
} catch {}
Expand All @@ -556,48 +606,63 @@ export default class AddCommandModule
const version =
projectManifest.dependencies?.[name] || projectManifest.devDependencies?.[name];
if (version) {
this.#projectVersionCache.set(name, version);

return version;
}
}

this.#projectVersionCache.set(name, null);

return null;
}

private async hasMismatchedPeer(manifest: PackageManifest): Promise<boolean> {
for (const peer in manifest.peerDependencies) {
private async getPeerDependencyConflicts(manifest: PackageManifest): Promise<string[] | false> {
if (!manifest.peerDependencies) {
return false;
}

const checks = Object.entries(manifest.peerDependencies).map(async ([peer, range]) => {
let peerIdentifier;
try {
peerIdentifier = npa.resolve(peer, manifest.peerDependencies[peer]);
peerIdentifier = npa.resolve(peer, range);
} catch {
this.context.logger.warn(`Invalid peer dependency ${peer} found in package.`);
continue;

return null;
}

if (peerIdentifier.type === 'version' || peerIdentifier.type === 'range') {
try {
const version = await this.findProjectVersion(peer);
if (!version) {
continue;
}

const options = { includePrerelease: true };

if (
!intersects(version, peerIdentifier.rawSpec, options) &&
!satisfies(version, peerIdentifier.rawSpec, options)
) {
return true;
}
} catch {
// Not found or invalid so ignore
continue;
}
} else {
if (peerIdentifier.type !== 'version' && peerIdentifier.type !== 'range') {
// type === 'tag' | 'file' | 'directory' | 'remote' | 'git'
// Cannot accurately compare these as the tag/location may have changed since install
// Cannot accurately compare these as the tag/location may have changed since install.
return null;
}
}

return false;
try {
const version = await this.findProjectVersion(peer);
if (!version) {
return null;
}

const options = { includePrerelease: true };
if (
!intersects(version, peerIdentifier.rawSpec, options) &&
!satisfies(version, peerIdentifier.rawSpec, options)
) {
return (
`Package "${manifest.name}@${manifest.version}" has an incompatible peer dependency to "` +
`${peer}@${peerIdentifier.rawSpec}" (requires "${version}" in project).`
);
}
} catch {
// Not found or invalid so ignore
}

return null;
});

const conflicts = (await Promise.all(checks)).filter((result): result is string => !!result);

return conflicts.length > 0 && conflicts;
}
}
Loading