Skip to content

Commit

Permalink
fix(@angular/cli): 'ng add' selects supported version via peer depend…
Browse files Browse the repository at this point in the history
…encies

If no version specifier is supplied `ng add` will now try to find the most recent version of the package that has peer dependencies that match the package versions supplied in the project's package.json
  • Loading branch information
clydin authored and mgechev committed Jan 22, 2019
1 parent 1a3ba03 commit b956db6
Show file tree
Hide file tree
Showing 18 changed files with 522 additions and 34 deletions.
212 changes: 193 additions & 19 deletions packages/angular/cli/commands/add-impl.ts
Expand Up @@ -5,18 +5,27 @@
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

// tslint:disable:no-global-tslint-disable no-any
import { tags, terminal } from '@angular-devkit/core';
import { ModuleNotFoundException, resolve } from '@angular-devkit/core/node';
import { NodePackageDoesNotSupportSchematics } from '@angular-devkit/schematics/tools';
import { dirname } from 'path';
import { intersects, prerelease, rcompare, satisfies, valid, validRange } from 'semver';
import { Arguments } from '../models/interface';
import { SchematicCommand } from '../models/schematic-command';
import { NpmInstall } from '../tasks/npm-install';
import npmInstall from '../tasks/npm-install';
import { getPackageManager } from '../utilities/package-manager';
import {
PackageManifest,
fetchPackageManifest,
fetchPackageMetadata,
} from '../utilities/package-metadata';
import { Schema as AddCommandSchema } from './add';

const npa = require('npm-package-arg');

export class AddCommand extends SchematicCommand<AddCommandSchema> {
readonly allowPrivateSchematics = true;
readonly packageManager = getPackageManager(this.workspace.root);

async run(options: AddCommandSchema & Arguments) {
if (!options.collection) {
Expand All @@ -28,32 +37,127 @@ export class AddCommand extends SchematicCommand<AddCommandSchema> {
return 1;
}

const packageManager = getPackageManager(this.workspace.root);
let packageIdentifier;
try {
packageIdentifier = npa(options.collection);
} catch (e) {
this.logger.error(e.message);

return 1;
}

if (packageIdentifier.registry && this.isPackageInstalled(packageIdentifier.name)) {
// Already installed so just run schematic
this.logger.info('Skipping installation: Package already installed');

return this.executeSchematic(packageIdentifier.name, options['--']);
}

const usingYarn = this.packageManager === 'yarn';

if (packageIdentifier.type === 'tag' && !packageIdentifier.rawSpec) {
// only package name provided; search for viable version
// plus special cases for packages that did not have peer deps setup
let packageMetadata;
try {
packageMetadata = await fetchPackageMetadata(
packageIdentifier.name,
this.logger,
{ usingYarn },
);
} catch (e) {
this.logger.error('Unable to fetch package metadata: ' + e.message);

return 1;
}

const latestManifest = packageMetadata.tags['latest'];
if (latestManifest && Object.keys(latestManifest.peerDependencies).length === 0) {
if (latestManifest.name === '@angular/pwa') {
const version = await this.findProjectVersion('@angular/cli');
// tslint:disable-next-line:no-any
const semverOptions = { includePrerelease: true } as any;

if (version
&& ((validRange(version) && intersects(version, '7', semverOptions))
|| (valid(version) && satisfies(version, '7', semverOptions)))) {
packageIdentifier = npa.resolve('@angular/pwa', '0.12');
}
}
} else if (!latestManifest || (await this.hasMismatchedPeer(latestManifest))) {
// 'latest' is invalid so search for most recent matching package
const versionManifests = Array.from(packageMetadata.versions.values())
.filter(value => !prerelease(value.version));

versionManifests.sort((a, b) => rcompare(a.version, b.version, true));

let newIdentifier;
for (const versionManifest of versionManifests) {
if (!(await this.hasMismatchedPeer(versionManifest))) {
newIdentifier = npa.resolve(packageIdentifier.name, versionManifest.version);
break;
}
}

if (!newIdentifier) {
this.logger.warn('Unable to find compatible package. Using \'latest\'.');
} else {
packageIdentifier = newIdentifier;
}
}
}

let collectionName = packageIdentifier.name;
if (!packageIdentifier.registry) {
try {
const manifest = await fetchPackageManifest(
packageIdentifier,
this.logger,
{ usingYarn },
);

const npmInstall: NpmInstall = require('../tasks/npm-install').default;
collectionName = manifest.name;

const packageName = options.collection.startsWith('@')
? options.collection.split('/', 2).join('/')
: options.collection.split('/', 1)[0];
if (await this.hasMismatchedPeer(manifest)) {
console.warn('Package has unmet peer dependencies. Adding the package may not succeed.');
}
} catch (e) {
this.logger.error('Unable to fetch package manifest: ' + e.message);

// Remove the tag/version from the package name.
const collectionName = (
packageName.startsWith('@')
? packageName.split('@', 2).join('@')
: packageName.split('@', 1).join('@')
) + options.collection.slice(packageName.length);
return 1;
}
}

// We don't actually add the package to package.json, that would be the work of the package
// itself.
await npmInstall(
packageName,
packageIdentifier.raw,
this.logger,
packageManager,
this.packageManager,
this.workspace.root,
);

return this.executeSchematic(collectionName, options['--']);
}

private isPackageInstalled(name: string): boolean {
try {
resolve(name, { checkLocal: true, basedir: this.workspace.root });

return true;
} catch (e) {
if (!(e instanceof ModuleNotFoundException)) {
throw e;
}
}

return false;
}

private async executeSchematic(
collectionName: string,
options: string[] = [],
): Promise<number | void> {
const runOptions = {
schematicOptions: options['--'] || [],
schematicOptions: options,
workingDir: this.workspace.root,
collectionName,
schematicName: 'ng-add',
Expand All @@ -77,4 +181,74 @@ export class AddCommand extends SchematicCommand<AddCommandSchema> {
throw e;
}
}

private async findProjectVersion(name: string): Promise<string | null> {
let installedPackage;
try {
installedPackage = resolve(
name,
{ checkLocal: true, basedir: this.workspace.root, resolvePackageJson: true },
);
} catch { }

if (installedPackage) {
try {
const installed = await fetchPackageManifest(dirname(installedPackage), this.logger);

return installed.version;
} catch {}
}

let projectManifest;
try {
projectManifest = await fetchPackageManifest(this.workspace.root, this.logger);
} catch {}

if (projectManifest) {
const version = projectManifest.dependencies[name] || projectManifest.devDependencies[name];
if (version) {
return version;
}
}

return null;
}

private async hasMismatchedPeer(manifest: PackageManifest): Promise<boolean> {
for (const peer in manifest.peerDependencies) {
let peerIdentifier;
try {
peerIdentifier = npa.resolve(peer, manifest.peerDependencies[peer]);
} catch {
this.logger.warn(`Invalid peer dependency ${peer} found in package.`);
continue;
}

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

// tslint:disable-next-line:no-any
const options = { includePrerelease: true } as any;

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

}

return false;
}
}
4 changes: 4 additions & 0 deletions packages/angular/cli/package.json
Expand Up @@ -31,8 +31,12 @@
"@angular-devkit/schematics": "0.0.0",
"@schematics/angular": "0.0.0",
"@schematics/update": "0.0.0",
"@yarnpkg/lockfile": "1.1.0",
"ini": "1.3.5",
"inquirer": "6.2.1",
"npm-package-arg": "6.1.0",
"opn": "5.4.0",
"pacote": "9.4.0",
"semver": "5.6.0",
"symbol-observable": "1.2.0"
},
Expand Down
12 changes: 0 additions & 12 deletions packages/angular/cli/tasks/npm-install.ts
Expand Up @@ -7,7 +7,6 @@
*/

import { logging, terminal } from '@angular-devkit/core';
import { ModuleNotFoundException, resolve } from '@angular-devkit/core/node';
import { spawn } from 'child_process';


Expand Down Expand Up @@ -42,17 +41,6 @@ export default async function (packageName: string,
logger.info(terminal.green(`Installing packages for tooling via ${packageManager}.`));

if (packageName) {
try {
// Verify if we need to install the package (it might already be there).
// If it's available and we shouldn't save, simply return. Nothing to be done.
resolve(packageName, { checkLocal: true, basedir: projectRoot });

return;
} catch (e) {
if (!(e instanceof ModuleNotFoundException)) {
throw e;
}
}
installArgs.push(packageName);
}

Expand Down

0 comments on commit b956db6

Please sign in to comment.