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

Fixes #12914
  • Loading branch information
clydin authored and alexeagle committed Jan 8, 2019
1 parent 8533613 commit 2fa37c6
Show file tree
Hide file tree
Showing 20 changed files with 859 additions and 41 deletions.
216 changes: 201 additions & 15 deletions packages/angular/cli/commands/add-impl.ts
Original file line number Diff line number Diff line change
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 { parseOptions } from '../models/command-runner';
import { SchematicCommand } from '../models/schematic-command';
import { NpmInstall } from '../tasks/npm-install';
import { getPackageManager } from '../utilities/config';
import {
PackageManifest,
fetchPackageManifest,
fetchPackageMetadata,
} from '../utilities/package-metadata';

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

export class AddCommand extends SchematicCommand {
readonly allowPrivateSchematics = true;
readonly packageManager = getPackageManager();

private async _parseSchematicOptions(collectionName: string): Promise<any> {
const schematicOptions = await this.getOptions({
Expand Down Expand Up @@ -55,35 +64,137 @@ export class AddCommand extends SchematicCommand {
return 1;
}

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

const npmInstall: NpmInstall = require('../tasks/npm-install').default;
return 1;
}

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

// Reparse the options with the new schematic accessible.
options = await this._parseSchematicOptions(packageIdentifier.name);

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;

const packageName = firstArg.startsWith('@')
? firstArg.split('/', 2).join('/')
: firstArg.split('/', 1)[0];
if (version
&& ((validRange(version) && intersects(version, '6', semverOptions))
|| (valid(version) && satisfies(version, '6', semverOptions)))) {
packageIdentifier = npa.resolve('@angular/pwa', 'v6-lts');
}
}
} 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));

// Remove the tag/version from the package name.
const collectionName = (
packageName.startsWith('@')
? packageName.split('@', 2).join('@')
: packageName.split('@', 1).join('@')
) + firstArg.slice(packageName.length);
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 },
);

collectionName = manifest.name;

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);

return 1;
}
}

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

// 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.project.root,
);

// Reparse the options with the new schematic accessible.
options = await this._parseSchematicOptions(collectionName);

return this.executeSchematic(collectionName, options);
}

private isPackageInstalled(name: string): boolean {
try {
resolve(name, { checkLocal: true, basedir: this.project.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.project.root,
collectionName,
schematicName: 'ng-add',
Expand All @@ -107,4 +218,79 @@ export class AddCommand extends SchematicCommand {
throw e;
}
}

private async findProjectVersion(name: string): Promise<string | null> {
let installedPackage;
try {
installedPackage = resolve(
name,
{ checkLocal: true, basedir: this.project.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.project.root, this.logger);
} catch {}

if (projectManifest) {
let version = projectManifest.dependencies[name];
if (version) {
return version;
}

version = 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
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,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",
"json-schema-traverse": "0.4.1",
"npm-package-arg": "6.1.0",
"opn": "5.4.0",
"pacote": "9.2.3",
"rxjs": "6.2.2",
"semver": "5.6.0",
"symbol-observable": "1.2.0",
Expand Down
12 changes: 0 additions & 12 deletions packages/angular/cli/tasks/npm-install.ts
Original file line number Diff line number Diff line change
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
Loading

0 comments on commit 2fa37c6

Please sign in to comment.