Skip to content

Commit b956db6

Browse files
clydinmgechev
authored andcommitted
fix(@angular/cli): 'ng add' selects supported version via peer dependencies
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
1 parent 1a3ba03 commit b956db6

File tree

18 files changed

+522
-34
lines changed

18 files changed

+522
-34
lines changed

packages/angular/cli/commands/add-impl.ts

+193-19
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,27 @@
55
* Use of this source code is governed by an MIT-style license that can be
66
* found in the LICENSE file at https://angular.io/license
77
*/
8-
9-
// tslint:disable:no-global-tslint-disable no-any
108
import { tags, terminal } from '@angular-devkit/core';
9+
import { ModuleNotFoundException, resolve } from '@angular-devkit/core/node';
1110
import { NodePackageDoesNotSupportSchematics } from '@angular-devkit/schematics/tools';
11+
import { dirname } from 'path';
12+
import { intersects, prerelease, rcompare, satisfies, valid, validRange } from 'semver';
1213
import { Arguments } from '../models/interface';
1314
import { SchematicCommand } from '../models/schematic-command';
14-
import { NpmInstall } from '../tasks/npm-install';
15+
import npmInstall from '../tasks/npm-install';
1516
import { getPackageManager } from '../utilities/package-manager';
17+
import {
18+
PackageManifest,
19+
fetchPackageManifest,
20+
fetchPackageMetadata,
21+
} from '../utilities/package-metadata';
1622
import { Schema as AddCommandSchema } from './add';
1723

24+
const npa = require('npm-package-arg');
25+
1826
export class AddCommand extends SchematicCommand<AddCommandSchema> {
1927
readonly allowPrivateSchematics = true;
28+
readonly packageManager = getPackageManager(this.workspace.root);
2029

2130
async run(options: AddCommandSchema & Arguments) {
2231
if (!options.collection) {
@@ -28,32 +37,127 @@ export class AddCommand extends SchematicCommand<AddCommandSchema> {
2837
return 1;
2938
}
3039

31-
const packageManager = getPackageManager(this.workspace.root);
40+
let packageIdentifier;
41+
try {
42+
packageIdentifier = npa(options.collection);
43+
} catch (e) {
44+
this.logger.error(e.message);
45+
46+
return 1;
47+
}
48+
49+
if (packageIdentifier.registry && this.isPackageInstalled(packageIdentifier.name)) {
50+
// Already installed so just run schematic
51+
this.logger.info('Skipping installation: Package already installed');
52+
53+
return this.executeSchematic(packageIdentifier.name, options['--']);
54+
}
55+
56+
const usingYarn = this.packageManager === 'yarn';
57+
58+
if (packageIdentifier.type === 'tag' && !packageIdentifier.rawSpec) {
59+
// only package name provided; search for viable version
60+
// plus special cases for packages that did not have peer deps setup
61+
let packageMetadata;
62+
try {
63+
packageMetadata = await fetchPackageMetadata(
64+
packageIdentifier.name,
65+
this.logger,
66+
{ usingYarn },
67+
);
68+
} catch (e) {
69+
this.logger.error('Unable to fetch package metadata: ' + e.message);
70+
71+
return 1;
72+
}
73+
74+
const latestManifest = packageMetadata.tags['latest'];
75+
if (latestManifest && Object.keys(latestManifest.peerDependencies).length === 0) {
76+
if (latestManifest.name === '@angular/pwa') {
77+
const version = await this.findProjectVersion('@angular/cli');
78+
// tslint:disable-next-line:no-any
79+
const semverOptions = { includePrerelease: true } as any;
80+
81+
if (version
82+
&& ((validRange(version) && intersects(version, '7', semverOptions))
83+
|| (valid(version) && satisfies(version, '7', semverOptions)))) {
84+
packageIdentifier = npa.resolve('@angular/pwa', '0.12');
85+
}
86+
}
87+
} else if (!latestManifest || (await this.hasMismatchedPeer(latestManifest))) {
88+
// 'latest' is invalid so search for most recent matching package
89+
const versionManifests = Array.from(packageMetadata.versions.values())
90+
.filter(value => !prerelease(value.version));
91+
92+
versionManifests.sort((a, b) => rcompare(a.version, b.version, true));
93+
94+
let newIdentifier;
95+
for (const versionManifest of versionManifests) {
96+
if (!(await this.hasMismatchedPeer(versionManifest))) {
97+
newIdentifier = npa.resolve(packageIdentifier.name, versionManifest.version);
98+
break;
99+
}
100+
}
101+
102+
if (!newIdentifier) {
103+
this.logger.warn('Unable to find compatible package. Using \'latest\'.');
104+
} else {
105+
packageIdentifier = newIdentifier;
106+
}
107+
}
108+
}
109+
110+
let collectionName = packageIdentifier.name;
111+
if (!packageIdentifier.registry) {
112+
try {
113+
const manifest = await fetchPackageManifest(
114+
packageIdentifier,
115+
this.logger,
116+
{ usingYarn },
117+
);
32118

33-
const npmInstall: NpmInstall = require('../tasks/npm-install').default;
119+
collectionName = manifest.name;
34120

35-
const packageName = options.collection.startsWith('@')
36-
? options.collection.split('/', 2).join('/')
37-
: options.collection.split('/', 1)[0];
121+
if (await this.hasMismatchedPeer(manifest)) {
122+
console.warn('Package has unmet peer dependencies. Adding the package may not succeed.');
123+
}
124+
} catch (e) {
125+
this.logger.error('Unable to fetch package manifest: ' + e.message);
38126

39-
// Remove the tag/version from the package name.
40-
const collectionName = (
41-
packageName.startsWith('@')
42-
? packageName.split('@', 2).join('@')
43-
: packageName.split('@', 1).join('@')
44-
) + options.collection.slice(packageName.length);
127+
return 1;
128+
}
129+
}
45130

46-
// We don't actually add the package to package.json, that would be the work of the package
47-
// itself.
48131
await npmInstall(
49-
packageName,
132+
packageIdentifier.raw,
50133
this.logger,
51-
packageManager,
134+
this.packageManager,
52135
this.workspace.root,
53136
);
54137

138+
return this.executeSchematic(collectionName, options['--']);
139+
}
140+
141+
private isPackageInstalled(name: string): boolean {
142+
try {
143+
resolve(name, { checkLocal: true, basedir: this.workspace.root });
144+
145+
return true;
146+
} catch (e) {
147+
if (!(e instanceof ModuleNotFoundException)) {
148+
throw e;
149+
}
150+
}
151+
152+
return false;
153+
}
154+
155+
private async executeSchematic(
156+
collectionName: string,
157+
options: string[] = [],
158+
): Promise<number | void> {
55159
const runOptions = {
56-
schematicOptions: options['--'] || [],
160+
schematicOptions: options,
57161
workingDir: this.workspace.root,
58162
collectionName,
59163
schematicName: 'ng-add',
@@ -77,4 +181,74 @@ export class AddCommand extends SchematicCommand<AddCommandSchema> {
77181
throw e;
78182
}
79183
}
184+
185+
private async findProjectVersion(name: string): Promise<string | null> {
186+
let installedPackage;
187+
try {
188+
installedPackage = resolve(
189+
name,
190+
{ checkLocal: true, basedir: this.workspace.root, resolvePackageJson: true },
191+
);
192+
} catch { }
193+
194+
if (installedPackage) {
195+
try {
196+
const installed = await fetchPackageManifest(dirname(installedPackage), this.logger);
197+
198+
return installed.version;
199+
} catch {}
200+
}
201+
202+
let projectManifest;
203+
try {
204+
projectManifest = await fetchPackageManifest(this.workspace.root, this.logger);
205+
} catch {}
206+
207+
if (projectManifest) {
208+
const version = projectManifest.dependencies[name] || projectManifest.devDependencies[name];
209+
if (version) {
210+
return version;
211+
}
212+
}
213+
214+
return null;
215+
}
216+
217+
private async hasMismatchedPeer(manifest: PackageManifest): Promise<boolean> {
218+
for (const peer in manifest.peerDependencies) {
219+
let peerIdentifier;
220+
try {
221+
peerIdentifier = npa.resolve(peer, manifest.peerDependencies[peer]);
222+
} catch {
223+
this.logger.warn(`Invalid peer dependency ${peer} found in package.`);
224+
continue;
225+
}
226+
227+
if (peerIdentifier.type === 'version' || peerIdentifier.type === 'range') {
228+
try {
229+
const version = await this.findProjectVersion(peer);
230+
if (!version) {
231+
continue;
232+
}
233+
234+
// tslint:disable-next-line:no-any
235+
const options = { includePrerelease: true } as any;
236+
237+
if (!intersects(version, peerIdentifier.rawSpec, options)
238+
&& !satisfies(version, peerIdentifier.rawSpec, options)) {
239+
return true;
240+
}
241+
} catch {
242+
// Not found or invalid so ignore
243+
continue;
244+
}
245+
} else {
246+
// type === 'tag' | 'file' | 'directory' | 'remote' | 'git'
247+
// Cannot accurately compare these as the tag/location may have changed since install
248+
}
249+
250+
}
251+
252+
return false;
253+
}
80254
}

packages/angular/cli/package.json

+4
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,12 @@
3131
"@angular-devkit/schematics": "0.0.0",
3232
"@schematics/angular": "0.0.0",
3333
"@schematics/update": "0.0.0",
34+
"@yarnpkg/lockfile": "1.1.0",
35+
"ini": "1.3.5",
3436
"inquirer": "6.2.1",
37+
"npm-package-arg": "6.1.0",
3538
"opn": "5.4.0",
39+
"pacote": "9.4.0",
3640
"semver": "5.6.0",
3741
"symbol-observable": "1.2.0"
3842
},

packages/angular/cli/tasks/npm-install.ts

-12
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
*/
88

99
import { logging, terminal } from '@angular-devkit/core';
10-
import { ModuleNotFoundException, resolve } from '@angular-devkit/core/node';
1110
import { spawn } from 'child_process';
1211

1312

@@ -42,17 +41,6 @@ export default async function (packageName: string,
4241
logger.info(terminal.green(`Installing packages for tooling via ${packageManager}.`));
4342

4443
if (packageName) {
45-
try {
46-
// Verify if we need to install the package (it might already be there).
47-
// If it's available and we shouldn't save, simply return. Nothing to be done.
48-
resolve(packageName, { checkLocal: true, basedir: projectRoot });
49-
50-
return;
51-
} catch (e) {
52-
if (!(e instanceof ModuleNotFoundException)) {
53-
throw e;
54-
}
55-
}
5644
installArgs.push(packageName);
5745
}
5846

0 commit comments

Comments
 (0)