diff --git a/packages/angular/cli/commands/add-impl.ts b/packages/angular/cli/commands/add-impl.ts index 400b7c05aef7..1e915ec4460b 100644 --- a/packages/angular/cli/commands/add-impl.ts +++ b/packages/angular/cli/commands/add-impl.ts @@ -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 { readonly allowPrivateSchematics = true; + readonly packageManager = getPackageManager(this.workspace.root); async run(options: AddCommandSchema & Arguments) { if (!options.collection) { @@ -28,32 +37,127 @@ export class AddCommand extends SchematicCommand { 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 { const runOptions = { - schematicOptions: options['--'] || [], + schematicOptions: options, workingDir: this.workspace.root, collectionName, schematicName: 'ng-add', @@ -77,4 +181,74 @@ export class AddCommand extends SchematicCommand { throw e; } } + + private async findProjectVersion(name: string): Promise { + 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 { + 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; + } } diff --git a/packages/angular/cli/package.json b/packages/angular/cli/package.json index 4594d7435e90..4afada26f829 100644 --- a/packages/angular/cli/package.json +++ b/packages/angular/cli/package.json @@ -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" }, diff --git a/packages/angular/cli/tasks/npm-install.ts b/packages/angular/cli/tasks/npm-install.ts index 243587054b88..70cbbd8a06ad 100644 --- a/packages/angular/cli/tasks/npm-install.ts +++ b/packages/angular/cli/tasks/npm-install.ts @@ -7,7 +7,6 @@ */ import { logging, terminal } from '@angular-devkit/core'; -import { ModuleNotFoundException, resolve } from '@angular-devkit/core/node'; import { spawn } from 'child_process'; @@ -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); } diff --git a/packages/angular/cli/utilities/package-metadata.ts b/packages/angular/cli/utilities/package-metadata.ts new file mode 100644 index 000000000000..aabe530ca10f --- /dev/null +++ b/packages/angular/cli/utilities/package-metadata.ts @@ -0,0 +1,238 @@ +/** + * @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 { logging } from '@angular-devkit/core'; +import { existsSync, readFileSync } from 'fs'; +import { homedir } from 'os'; +import * as path from 'path'; + +const ini = require('ini'); +const lockfile = require('@yarnpkg/lockfile'); +const pacote = require('pacote'); + +export interface PackageDependencies { + [dependency: string]: string; +} + +export interface PackageIdentifier { + type: 'git' | 'tag' | 'version' | 'range' | 'file' | 'directory' | 'remote'; + name: string; + scope: string | null; + registry: boolean; + raw: string; +} + +export interface PackageManifest { + name: string; + version: string; + license?: string; + private?: boolean; + deprecated?: boolean; + + dependencies: PackageDependencies; + devDependencies: PackageDependencies; + peerDependencies: PackageDependencies; + optionalDependencies: PackageDependencies; + + 'ng-add'?: { + + }; + 'ng-update'?: { + migrations: string, + packageGroup: { [name: string]: string }, + }; +} + +export interface PackageMetadata { + name: string; + tags: { [tag: string]: PackageManifest | undefined }; + versions: Map; +} + +let npmrc: { [key: string]: string }; + +function ensureNpmrc(logger: logging.LoggerApi, usingYarn: boolean, verbose: boolean): void { + if (!npmrc) { + try { + npmrc = readOptions(logger, false, verbose); + } catch { } + + if (usingYarn) { + try { + npmrc = { ...npmrc, ...readOptions(logger, true, verbose) }; + } catch { } + } + } +} + +function readOptions( + logger: logging.LoggerApi, + yarn = false, + showPotentials = false, +): Record { + const cwd = process.cwd(); + const baseFilename = yarn ? 'yarnrc' : 'npmrc'; + const dotFilename = '.' + baseFilename; + + let globalPrefix: string; + if (process.env.PREFIX) { + globalPrefix = process.env.PREFIX; + } else { + globalPrefix = path.dirname(process.execPath); + if (process.platform !== 'win32') { + globalPrefix = path.dirname(globalPrefix); + } + } + + const defaultConfigLocations = [ + path.join(globalPrefix, 'etc', baseFilename), + path.join(homedir(), dotFilename), + ]; + + const projectConfigLocations: string[] = [ + path.join(cwd, dotFilename), + ]; + const root = path.parse(cwd).root; + for (let curDir = path.dirname(cwd); curDir && curDir !== root; curDir = path.dirname(curDir)) { + projectConfigLocations.unshift(path.join(curDir, dotFilename)); + } + + if (showPotentials) { + logger.info(`Locating potential ${baseFilename} files:`); + } + + let options: { [key: string]: string } = {}; + for (const location of [...defaultConfigLocations, ...projectConfigLocations]) { + if (existsSync(location)) { + if (showPotentials) { + logger.info(`Trying '${location}'...found.`); + } + + const data = readFileSync(location, 'utf8'); + options = { + ...options, + ...(yarn ? lockfile.parse(data) : ini.parse(data)), + }; + + if (options.cafile) { + const cafile = path.resolve(path.dirname(location), options.cafile); + delete options.cafile; + try { + options.ca = readFileSync(cafile, 'utf8').replace(/\r?\n/, '\\n'); + } catch { } + } + } else if (showPotentials) { + logger.info(`Trying '${location}'...not found.`); + } + } + + // Substitute any environment variable references + for (const key in options) { + if (typeof options[key] === 'string') { + options[key] = options[key].replace(/\$\{([^\}]+)\}/, (_, name) => process.env[name] || ''); + } + } + + return options; +} + +function normalizeManifest(rawManifest: {}): PackageManifest { + // TODO: Fully normalize and sanitize + + return { + dependencies: {}, + devDependencies: {}, + peerDependencies: {}, + optionalDependencies: {}, + // tslint:disable-next-line:no-any + ...rawManifest as any, + }; +} + +export async function fetchPackageMetadata( + name: string, + logger: logging.LoggerApi, + options?: { + registry?: string; + usingYarn?: boolean; + verbose?: boolean; + }, +): Promise { + const { usingYarn, verbose, registry } = { + registry: undefined, + usingYarn: false, + verbose: false, + ...options, + }; + + ensureNpmrc(logger, usingYarn, verbose); + + const response = await pacote.packument( + name, + { + 'full-metadata': true, + ...npmrc, + ...(registry ? { registry } : {}), + }, + ); + + // Normalize the response + const metadata: PackageMetadata = { + name: response.name, + tags: {}, + versions: new Map(), + }; + + if (response.versions) { + for (const [version, manifest] of Object.entries(response.versions)) { + metadata.versions.set(version, normalizeManifest(manifest)); + } + } + + if (response['dist-tags']) { + for (const [tag, version] of Object.entries(response['dist-tags'])) { + const manifest = metadata.versions.get(version as string); + if (manifest) { + metadata.tags[tag] = manifest; + } else if (verbose) { + logger.warn(`Package ${metadata.name} has invalid version metadata for '${tag}'.`); + } + } + } + + return metadata; +} + +export async function fetchPackageManifest( + name: string, + logger: logging.LoggerApi, + options?: { + registry?: string; + usingYarn?: boolean; + verbose?: boolean; + }, +): Promise { + const { usingYarn, verbose, registry } = { + registry: undefined, + usingYarn: false, + verbose: false, + ...options, + }; + + ensureNpmrc(logger, usingYarn, verbose); + + const response = await pacote.manifest( + name, + { + 'full-metadata': true, + ...npmrc, + ...(registry ? { registry } : {}), + }, + ); + + return normalizeManifest(response); +} diff --git a/tests/legacy-cli/e2e/assets/add-collection-peer-bad/collection.json b/tests/legacy-cli/e2e/assets/add-collection-peer-bad/collection.json new file mode 100644 index 000000000000..94de98d0f82c --- /dev/null +++ b/tests/legacy-cli/e2e/assets/add-collection-peer-bad/collection.json @@ -0,0 +1,8 @@ +{ + "schematics": { + "ng-add": { + "factory": "./index.js", + "description": "Add empty file to your application." + } + } +} diff --git a/tests/legacy-cli/e2e/assets/add-collection-peer-bad/index.js b/tests/legacy-cli/e2e/assets/add-collection-peer-bad/index.js new file mode 100644 index 000000000000..867b3a4eb6fd --- /dev/null +++ b/tests/legacy-cli/e2e/assets/add-collection-peer-bad/index.js @@ -0,0 +1 @@ +exports.default = (options) => tree => tree.create(options.name || 'empty-file-peer-bad', ''); diff --git a/tests/legacy-cli/e2e/assets/add-collection-peer-bad/package.json b/tests/legacy-cli/e2e/assets/add-collection-peer-bad/package.json new file mode 100644 index 000000000000..9a3f5aca40a8 --- /dev/null +++ b/tests/legacy-cli/e2e/assets/add-collection-peer-bad/package.json @@ -0,0 +1,8 @@ +{ + "name": "add-collection-peer-bad", + "version": "0.0.1", + "schematics": "./collection.json", + "peerDependencies": { + "typescript": "2.x" + } +} diff --git a/tests/legacy-cli/e2e/assets/add-collection-peer-good/collection.json b/tests/legacy-cli/e2e/assets/add-collection-peer-good/collection.json new file mode 100644 index 000000000000..94de98d0f82c --- /dev/null +++ b/tests/legacy-cli/e2e/assets/add-collection-peer-good/collection.json @@ -0,0 +1,8 @@ +{ + "schematics": { + "ng-add": { + "factory": "./index.js", + "description": "Add empty file to your application." + } + } +} diff --git a/tests/legacy-cli/e2e/assets/add-collection-peer-good/index.js b/tests/legacy-cli/e2e/assets/add-collection-peer-good/index.js new file mode 100644 index 000000000000..4d5dbdca2f69 --- /dev/null +++ b/tests/legacy-cli/e2e/assets/add-collection-peer-good/index.js @@ -0,0 +1 @@ +exports.default = (options) => tree => tree.create(options.name || 'empty-file-peer-good', ''); diff --git a/tests/legacy-cli/e2e/assets/add-collection-peer-good/package.json b/tests/legacy-cli/e2e/assets/add-collection-peer-good/package.json new file mode 100644 index 000000000000..db3d21ce2353 --- /dev/null +++ b/tests/legacy-cli/e2e/assets/add-collection-peer-good/package.json @@ -0,0 +1,8 @@ +{ + "name": "add-collection-peer-good", + "version": "0.0.1", + "schematics": "./collection.json", + "peerDependencies": { + "@angular/cli": "*" + } +} diff --git a/tests/legacy-cli/e2e/assets/add-collection.tgz b/tests/legacy-cli/e2e/assets/add-collection.tgz new file mode 100644 index 000000000000..4714044f562c Binary files /dev/null and b/tests/legacy-cli/e2e/assets/add-collection.tgz differ diff --git a/tests/legacy-cli/e2e/assets/add-collection/package.json b/tests/legacy-cli/e2e/assets/add-collection/package.json index 1ded34bbece6..fe33ea6c6f95 100644 --- a/tests/legacy-cli/e2e/assets/add-collection/package.json +++ b/tests/legacy-cli/e2e/assets/add-collection/package.json @@ -1,4 +1,5 @@ { "name": "empty-app", + "version": "0.0.1", "schematics": "./collection.json" } diff --git a/tests/legacy-cli/e2e/tests/commands/add/add-material.ts b/tests/legacy-cli/e2e/tests/commands/add/add-material.ts new file mode 100644 index 000000000000..e9249a37f596 --- /dev/null +++ b/tests/legacy-cli/e2e/tests/commands/add/add-material.ts @@ -0,0 +1,8 @@ +import { expectFileToMatch } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; + + +export default async function () { + await ng('add', '@angular/material'); + await expectFileToMatch('package.json', /@angular\/material/); +} diff --git a/tests/legacy-cli/e2e/tests/commands/add/base.ts b/tests/legacy-cli/e2e/tests/commands/add/base.ts index 08a1f098f6a6..b22583909076 100644 --- a/tests/legacy-cli/e2e/tests/commands/add/base.ts +++ b/tests/legacy-cli/e2e/tests/commands/add/base.ts @@ -1,5 +1,5 @@ import { assetDir } from '../../../utils/assets'; -import { expectFileToExist, symlinkFile } from '../../../utils/fs'; +import { expectFileToExist, rimraf, symlinkFile } from '../../../utils/fs'; import { ng } from '../../../utils/process'; import { expectToFail } from '../../../utils/utils'; @@ -13,6 +13,8 @@ export default async function () { await ng('add', 'add-collection', '--name=blah'); await expectFileToExist('blah'); - // TODO: reenable this check when schematics fail the CLI command. await expectToFail(() => ng('add', 'add-collection')); // File already exists. + + // Cleanup the package + await rimraf('node_modules/add-collection'); } diff --git a/tests/legacy-cli/e2e/tests/commands/add/dir.ts b/tests/legacy-cli/e2e/tests/commands/add/dir.ts new file mode 100644 index 000000000000..d3c4b01d82e7 --- /dev/null +++ b/tests/legacy-cli/e2e/tests/commands/add/dir.ts @@ -0,0 +1,9 @@ +import { assetDir } from '../../../utils/assets'; +import { expectFileToExist } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; + + +export default async function () { + await ng('add', assetDir('add-collection'), '--name=blah'); + await expectFileToExist('blah'); +} diff --git a/tests/legacy-cli/e2e/tests/commands/add/file.ts b/tests/legacy-cli/e2e/tests/commands/add/file.ts new file mode 100644 index 000000000000..7cd61da0374b --- /dev/null +++ b/tests/legacy-cli/e2e/tests/commands/add/file.ts @@ -0,0 +1,9 @@ +import { assetDir } from '../../../utils/assets'; +import { expectFileToExist } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; + + +export default async function () { + await ng('add', assetDir('add-collection.tgz'), '--name=blah'); + await expectFileToExist('blah'); +} diff --git a/tests/legacy-cli/e2e/tests/commands/add/peer.ts b/tests/legacy-cli/e2e/tests/commands/add/peer.ts new file mode 100644 index 000000000000..ee1ece6dab6a --- /dev/null +++ b/tests/legacy-cli/e2e/tests/commands/add/peer.ts @@ -0,0 +1,21 @@ +import { assetDir } from '../../../utils/assets'; +import { ng } from '../../../utils/process'; + +const warning = 'Adding the package may not succeed.'; + +export default async function () { + const { stderr: bad } = await ng('add', assetDir('add-collection-peer-bad')); + if (!bad.includes(warning)) { + throw new Error('peer warning not shown on bad package'); + } + + const { stderr: base } = await ng('add', assetDir('add-collection')); + if (base.includes(warning)) { + throw new Error('peer warning shown on base package'); + } + + const { stderr: good } = await ng('add', assetDir('add-collection-peer-good')); + if (good.includes(warning)) { + throw new Error('peer warning shown on good package'); + } +} diff --git a/yarn.lock b/yarn.lock index 8663e8608dee..8f89644621e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6901,7 +6901,7 @@ npm-bundled@^1.0.1: resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.5.tgz#3c1732b7ba936b3a10325aef616467c0ccbcc979" integrity sha512-m/e6jgWu8/v5niCUKQi9qQl8QdeEduFA96xHDDzFGqly0OOjI7c+60KM/2sppfnUU9JJagf+zs+yGhqSOFj71g== -"npm-package-arg@^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", npm-package-arg@^6.0.0, npm-package-arg@^6.1.0: +npm-package-arg@6.1.0, "npm-package-arg@^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", npm-package-arg@^6.0.0, npm-package-arg@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/npm-package-arg/-/npm-package-arg-6.1.0.tgz#15ae1e2758a5027efb4c250554b85a737db7fcc1" integrity sha512-zYbhP2k9DbJhA0Z3HKUePUgdB1x7MfIfKssC+WLPFMKTBZKpZh5m13PgexJjCq6KW7j17r0jHWcCpxEqnnncSA==