Charles Lyding b2b536789b refactor(@angular/cli): discover/load workspace on startup
Previously, the workspace configuration file was found and loaded by individual commands potentially multiple times.  This change moves the initial workspace location discovery and loading of the workspace to the CLI startup.  It also provides the workspace to each command so that the commands can reuse the already loaded and parsed workspace configuration.
2020-10-02 07:53:07 -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.context.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.context.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.context.root],
});
} catch {}
if (installedPackage) {
try {
const installed = await fetchPackageManifest(dirname(installedPackage), this.logger);
return installed.version;
} catch {}
}
let projectManifest;
try {
projectManifest = await fetchPackageManifest(this.context.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;
}
}