Keen Yee Liau c53e875df0 fix(@angular/cli): ng-add should resolve package.json
ng-add checks if a specified collection is installed, and if not it'd
proceed to install the package. However, `isPackageInstalled` would, by
default, resolve the main field or the index of the package. Not all NPM
packages specify the main field or provide an index file. It should
be sufficient to just check the presence of `package.json` to detect
whether a package is installed or not.

For example, `ng add @angular/bazel` should not install the package if
it's already installed locally. `@angular/bazel` does not specify a main
field not an index file in its `package.json`.
2019-01-29 14:13:44 -08:00

259 lines
7.8 KiB
TypeScript

/**
* @license
* Copyright Google Inc. 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.io/license
*/
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 { 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) {
this.logger.fatal(
`The "ng add" command requires a name argument to be specified eg. `
+ `${terminal.yellow('ng add [name] ')}. For more details, use "ng help".`,
);
return 1;
}
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 },
);
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;
}
}
await npmInstall(
packageIdentifier.raw,
this.logger,
this.packageManager,
this.workspace.root,
);
return this.executeSchematic(collectionName, options['--']);
}
private isPackageInstalled(name: string): boolean {
try {
resolve(name, {
checkLocal: true,
basedir: this.workspace.root,
resolvePackageJson: true,
});
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,
workingDir: this.workspace.root,
collectionName,
schematicName: 'ng-add',
allowPrivate: true,
dryRun: false,
force: false,
};
try {
return await this.runSchematic(runOptions);
} catch (e) {
if (e instanceof NodePackageDoesNotSupportSchematics) {
this.logger.error(tags.oneLine`
The package that you are trying to add does not support schematics. You can try using
a different version of the package or contact the package author to add ng-add support.
`);
return 1;
}
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;
}
}