Skip to content

Commit 2fa37c6

Browse files
clydinalexeagle
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 Fixes #12914
1 parent 8533613 commit 2fa37c6

File tree

20 files changed

+859
-41
lines changed

20 files changed

+859
-41
lines changed

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

Lines changed: 201 additions & 15 deletions
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-
98
// tslint:disable:no-global-tslint-disable no-any
109
import { tags, terminal } from '@angular-devkit/core';
10+
import { ModuleNotFoundException, resolve } from '@angular-devkit/core/node';
1111
import { NodePackageDoesNotSupportSchematics } from '@angular-devkit/schematics/tools';
12+
import { dirname } from 'path';
13+
import { intersects, prerelease, rcompare, satisfies, valid, validRange } from 'semver';
1214
import { parseOptions } from '../models/command-runner';
1315
import { SchematicCommand } from '../models/schematic-command';
1416
import { NpmInstall } from '../tasks/npm-install';
1517
import { getPackageManager } from '../utilities/config';
18+
import {
19+
PackageManifest,
20+
fetchPackageManifest,
21+
fetchPackageMetadata,
22+
} from '../utilities/package-metadata';
1623

24+
const npa = require('npm-package-arg');
1725

1826
export class AddCommand extends SchematicCommand {
1927
readonly allowPrivateSchematics = true;
28+
readonly packageManager = getPackageManager();
2029

2130
private async _parseSchematicOptions(collectionName: string): Promise<any> {
2231
const schematicOptions = await this.getOptions({
@@ -55,35 +64,137 @@ export class AddCommand extends SchematicCommand {
5564
return 1;
5665
}
5766

58-
const packageManager = getPackageManager();
67+
let packageIdentifier;
68+
try {
69+
packageIdentifier = npa(options.collection);
70+
} catch (e) {
71+
this.logger.error(e.message);
5972

60-
const npmInstall: NpmInstall = require('../tasks/npm-install').default;
73+
return 1;
74+
}
75+
76+
if (packageIdentifier.registry && this.isPackageInstalled(packageIdentifier.name)) {
77+
// Already installed so just run schematic
78+
this.logger.info('Skipping installation: Package already installed');
79+
80+
// Reparse the options with the new schematic accessible.
81+
options = await this._parseSchematicOptions(packageIdentifier.name);
82+
83+
return this.executeSchematic(packageIdentifier.name, options);
84+
}
85+
86+
const usingYarn = this.packageManager === 'yarn';
87+
88+
if (packageIdentifier.type === 'tag' && !packageIdentifier.rawSpec) {
89+
// only package name provided; search for viable version
90+
// plus special cases for packages that did not have peer deps setup
91+
let packageMetadata;
92+
try {
93+
packageMetadata = await fetchPackageMetadata(
94+
packageIdentifier.name,
95+
this.logger,
96+
{ usingYarn },
97+
);
98+
} catch (e) {
99+
this.logger.error('Unable to fetch package metadata: ' + e.message);
100+
101+
return 1;
102+
}
103+
104+
const latestManifest = packageMetadata.tags['latest'];
105+
if (latestManifest && Object.keys(latestManifest.peerDependencies).length === 0) {
106+
if (latestManifest.name === '@angular/pwa') {
107+
const version = await this.findProjectVersion('@angular/cli');
108+
// tslint:disable-next-line:no-any
109+
const semverOptions = { includePrerelease: true } as any;
61110

62-
const packageName = firstArg.startsWith('@')
63-
? firstArg.split('/', 2).join('/')
64-
: firstArg.split('/', 1)[0];
111+
if (version
112+
&& ((validRange(version) && intersects(version, '6', semverOptions))
113+
|| (valid(version) && satisfies(version, '6', semverOptions)))) {
114+
packageIdentifier = npa.resolve('@angular/pwa', 'v6-lts');
115+
}
116+
}
117+
} else if (!latestManifest || (await this.hasMismatchedPeer(latestManifest))) {
118+
// 'latest' is invalid so search for most recent matching package
119+
const versionManifests = Array.from(packageMetadata.versions.values())
120+
.filter(value => !prerelease(value.version));
65121

66-
// Remove the tag/version from the package name.
67-
const collectionName = (
68-
packageName.startsWith('@')
69-
? packageName.split('@', 2).join('@')
70-
: packageName.split('@', 1).join('@')
71-
) + firstArg.slice(packageName.length);
122+
versionManifests.sort((a, b) => rcompare(a.version, b.version, true));
123+
124+
let newIdentifier;
125+
for (const versionManifest of versionManifests) {
126+
if (!(await this.hasMismatchedPeer(versionManifest))) {
127+
newIdentifier = npa.resolve(packageIdentifier.name, versionManifest.version);
128+
break;
129+
}
130+
}
131+
132+
if (!newIdentifier) {
133+
this.logger.warn('Unable to find compatible package. Using \'latest\'.');
134+
} else {
135+
packageIdentifier = newIdentifier;
136+
}
137+
}
138+
}
139+
140+
let collectionName = packageIdentifier.name;
141+
if (!packageIdentifier.registry) {
142+
try {
143+
const manifest = await fetchPackageManifest(
144+
packageIdentifier,
145+
this.logger,
146+
{ usingYarn },
147+
);
148+
149+
collectionName = manifest.name;
150+
151+
if (await this.hasMismatchedPeer(manifest)) {
152+
console.warn('Package has unmet peer dependencies. Adding the package may not succeed.');
153+
}
154+
} catch (e) {
155+
this.logger.error('Unable to fetch package manifest: ' + e.message);
156+
157+
return 1;
158+
}
159+
}
160+
161+
const npmInstall: NpmInstall = require('../tasks/npm-install').default;
72162

73163
// We don't actually add the package to package.json, that would be the work of the package
74164
// itself.
75165
await npmInstall(
76-
packageName,
166+
packageIdentifier.raw,
77167
this.logger,
78-
packageManager,
168+
this.packageManager,
79169
this.project.root,
80170
);
81171

82172
// Reparse the options with the new schematic accessible.
83173
options = await this._parseSchematicOptions(collectionName);
84174

175+
return this.executeSchematic(collectionName, options);
176+
}
177+
178+
private isPackageInstalled(name: string): boolean {
179+
try {
180+
resolve(name, { checkLocal: true, basedir: this.project.root });
181+
182+
return true;
183+
} catch (e) {
184+
if (!(e instanceof ModuleNotFoundException)) {
185+
throw e;
186+
}
187+
}
188+
189+
return false;
190+
}
191+
192+
private async executeSchematic(
193+
collectionName: string,
194+
options?: string[],
195+
): Promise<number | void> {
85196
const runOptions = {
86-
schematicOptions: options,
197+
schematicOptions: options || [],
87198
workingDir: this.project.root,
88199
collectionName,
89200
schematicName: 'ng-add',
@@ -107,4 +218,79 @@ export class AddCommand extends SchematicCommand {
107218
throw e;
108219
}
109220
}
221+
222+
private async findProjectVersion(name: string): Promise<string | null> {
223+
let installedPackage;
224+
try {
225+
installedPackage = resolve(
226+
name,
227+
{ checkLocal: true, basedir: this.project.root, resolvePackageJson: true },
228+
);
229+
} catch { }
230+
231+
if (installedPackage) {
232+
try {
233+
const installed = await fetchPackageManifest(dirname(installedPackage), this.logger);
234+
235+
return installed.version;
236+
} catch {}
237+
}
238+
239+
let projectManifest;
240+
try {
241+
projectManifest = await fetchPackageManifest(this.project.root, this.logger);
242+
} catch {}
243+
244+
if (projectManifest) {
245+
let version = projectManifest.dependencies[name];
246+
if (version) {
247+
return version;
248+
}
249+
250+
version = projectManifest.devDependencies[name];
251+
if (version) {
252+
return version;
253+
}
254+
}
255+
256+
return null;
257+
}
258+
259+
private async hasMismatchedPeer(manifest: PackageManifest): Promise<boolean> {
260+
for (const peer in manifest.peerDependencies) {
261+
let peerIdentifier;
262+
try {
263+
peerIdentifier = npa.resolve(peer, manifest.peerDependencies[peer]);
264+
} catch {
265+
this.logger.warn(`Invalid peer dependency ${peer} found in package.`);
266+
continue;
267+
}
268+
269+
if (peerIdentifier.type === 'version' || peerIdentifier.type === 'range') {
270+
try {
271+
const version = await this.findProjectVersion(peer);
272+
if (!version) {
273+
continue;
274+
}
275+
276+
// tslint:disable-next-line:no-any
277+
const options = { includePrerelease: true } as any;
278+
279+
if (!intersects(version, peerIdentifier.rawSpec, options)
280+
&& !satisfies(version, peerIdentifier.rawSpec, options)) {
281+
return true;
282+
}
283+
} catch {
284+
// Not found or invalid so ignore
285+
continue;
286+
}
287+
} else {
288+
// type === 'tag' | 'file' | 'directory' | 'remote' | 'git'
289+
// Cannot accurately compare these as the tag/location may have changed since install
290+
}
291+
292+
}
293+
294+
return false;
295+
}
110296
}

packages/angular/cli/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,12 @@
3535
"@angular-devkit/schematics": "0.0.0",
3636
"@schematics/angular": "0.0.0",
3737
"@schematics/update": "0.0.0",
38+
"@yarnpkg/lockfile": "1.1.0",
39+
"ini": "1.3.5",
3840
"json-schema-traverse": "0.4.1",
41+
"npm-package-arg": "6.1.0",
3942
"opn": "5.4.0",
43+
"pacote": "9.2.3",
4044
"rxjs": "6.2.2",
4145
"semver": "5.6.0",
4246
"symbol-observable": "1.2.0",

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

Lines changed: 0 additions & 12 deletions
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)