2020-08-24 07:50:01 -04:00

322 lines
10 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 { analytics, tags } from '@angular-devkit/core';
import { NodePackageDoesNotSupportSchematics } from '@angular-devkit/schematics/tools';
import { dirname, join } from 'path';
import { intersects, prerelease, rcompare, satisfies, valid, validRange } from 'semver';
import { PackageManager } from '../lib/config/schema';
import { isPackageNameSafeForAnalytics } from '../models/analytics';
import { Arguments } from '../models/interface';
import { RunSchematicOptions, SchematicCommand } from '../models/schematic-command';
import { installPackage, installTempPackage } from '../tasks/install-package';
import { colors } from '../utilities/color';
import { getPackageManager } from '../utilities/package-manager';
import {
NgAddSaveDepedency,
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;
async initialize(options: AddCommandSchema & Arguments) {
if (options.registry) {
return super.initialize({ ...options, packageRegistry: options.registry });
} else {
return super.initialize(options);
}
}
async run(options: AddCommandSchema & Arguments) {
if (!options.collection) {
this.logger.fatal(
`The "ng add" command requires a name argument to be specified eg. ` +
`${colors.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)) {
let validVersion = false;
const installedVersion = await this.findProjectVersion(packageIdentifier.name);
if (installedVersion) {
if (packageIdentifier.type === 'range') {
validVersion = satisfies(installedVersion, packageIdentifier.fetchSpec);
} else if (packageIdentifier.type === 'version') {
const v1 = valid(packageIdentifier.fetchSpec);
const v2 = valid(installedVersion);
validVersion = v1 !== null && v1 === v2;
} else if (!packageIdentifier.rawSpec) {
validVersion = true;
}
}
if (validVersion) {
// Already installed so just run schematic
this.logger.info('Skipping installation: Package already installed');
return this.executeSchematic(packageIdentifier.name, options['--']);
}
}
const packageManager = await getPackageManager(this.workspace.root);
const usingYarn = packageManager === 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, {
registry: options.registry,
usingYarn,
verbose: options.verbose,
});
} 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');
const semverOptions = { includePrerelease: true };
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 = Object.values(packageMetadata.versions).filter(
(value: PackageManifest) => !prerelease(value.version),
) as PackageManifest[];
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;
let savePackage: NgAddSaveDepedency | undefined;
try {
const manifest = await fetchPackageManifest(packageIdentifier, this.logger, {
registry: options.registry,
verbose: options.verbose,
usingYarn,
});
savePackage = manifest['ng-add']?.save;
collectionName = manifest.name;
if (await this.hasMismatchedPeer(manifest)) {
this.logger.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;
}
if (savePackage === false) {
// Temporary packages are located in a different directory
// Hence we need to resolve them using the temp path
const tempPath = installTempPackage(
packageIdentifier.raw,
this.logger,
packageManager,
options.registry ? [`--registry="${options.registry}"`] : undefined,
);
const resolvedCollectionPath = require.resolve(
join(collectionName, 'package.json'),
{
paths: [tempPath],
},
);
collectionName = dirname(resolvedCollectionPath);
} else {
installPackage(
packageIdentifier.raw,
this.logger,
packageManager,
savePackage,
options.registry ? [`--registry="${options.registry}"`] : undefined,
);
}
return this.executeSchematic(collectionName, options['--']);
}
async reportAnalytics(
paths: string[],
options: AddCommandSchema & Arguments,
dimensions: (boolean | number | string)[] = [],
metrics: (boolean | number | string)[] = [],
): Promise<void> {
const collection = options.collection;
// Add the collection if it's safe listed.
if (collection && isPackageNameSafeForAnalytics(collection)) {
dimensions[analytics.NgCliAnalyticsDimensions.NgAddCollection] = collection;
} else {
delete dimensions[analytics.NgCliAnalyticsDimensions.NgAddCollection];
}
return super.reportAnalytics(paths, options, dimensions, metrics);
}
private isPackageInstalled(name: string): boolean {
try {
require.resolve(join(name, 'package.json'), { paths: [this.workspace.root] });
return true;
} catch (e) {
if (e.code !== 'MODULE_NOT_FOUND') {
throw e;
}
}
return false;
}
private async executeSchematic(
collectionName: string,
options: string[] = [],
): Promise<number | void> {
const runOptions: RunSchematicOptions = {
schematicOptions: options,
collectionName,
schematicName: 'ng-add',
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 = require.resolve(join(name, 'package.json'), {
paths: [this.workspace.root],
});
} 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;
}
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 {
// type === 'tag' | 'file' | 'directory' | 'remote' | 'git'
// Cannot accurately compare these as the tag/location may have changed since install
}
}
return false;
}
}