Skip to content

Commit 01af29a

Browse files
committed
fix(@angular/cli): improve error handling of update command
This adds extensive option checking and error handling with the goal to catch user errors as early in the process as possible. Bad option combinations and/or values will result in actionable information and stop the process before network access and processing occurs.
1 parent 87d425b commit 01af29a

File tree

6 files changed

+366
-36
lines changed

6 files changed

+366
-36
lines changed

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

+234-36
Original file line numberDiff line numberDiff line change
@@ -5,62 +5,260 @@
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-
import { normalize } from '@angular-devkit/core';
8+
import * as path from 'path';
9+
import * as semver from 'semver';
910
import { Arguments, Option } from '../models/interface';
1011
import { SchematicCommand } from '../models/schematic-command';
1112
import { findUp } from '../utilities/find-up';
1213
import { getPackageManager } from '../utilities/package-manager';
14+
import {
15+
PackageIdentifier,
16+
PackageManifest,
17+
fetchPackageMetadata,
18+
} from '../utilities/package-metadata';
19+
import { findNodeDependencies, readPackageTree } from '../utilities/package-tree';
1320
import { Schema as UpdateCommandSchema } from './update';
1421

22+
const npa = require('npm-package-arg');
23+
24+
const oldConfigFileNames = [
25+
'.angular-cli.json',
26+
'angular-cli.json',
27+
];
28+
1529
export class UpdateCommand extends SchematicCommand<UpdateCommandSchema> {
1630
public readonly allowMissingWorkspace = true;
1731

18-
collectionName = '@schematics/update';
19-
schematicName = 'update';
20-
21-
async parseArguments(schematicOptions: string[], schema: Option[]): Promise<Arguments> {
22-
const args = await super.parseArguments(schematicOptions, schema);
23-
const maybeArgsLeftovers = args['--'];
24-
25-
if (maybeArgsLeftovers
26-
&& maybeArgsLeftovers.length == 1
27-
&& maybeArgsLeftovers[0] == '@angular/cli'
28-
&& args.migrateOnly === undefined
29-
&& args.from === undefined) {
30-
// Check for a 1.7 angular-cli.json file.
31-
const oldConfigFileNames = [
32-
normalize('.angular-cli.json'),
33-
normalize('angular-cli.json'),
34-
];
35-
const oldConfigFilePath = findUp(oldConfigFileNames, process.cwd())
36-
|| findUp(oldConfigFileNames, __dirname);
37-
38-
if (oldConfigFilePath) {
39-
args.migrateOnly = true;
40-
args.from = '1.0.0';
32+
async parseArguments(_schematicOptions: string[], _schema: Option[]): Promise<Arguments> {
33+
return {};
34+
}
35+
36+
// tslint:disable-next-line:no-big-function
37+
async run(options: UpdateCommandSchema & Arguments) {
38+
const packages: PackageIdentifier[] = [];
39+
for (const request of options['--'] || []) {
40+
try {
41+
const packageIdentifier: PackageIdentifier = npa(request);
42+
43+
// only registry identifiers are supported
44+
if (!packageIdentifier.registry) {
45+
this.logger.error(`Package '${request}' is not a registry package identifer.`);
46+
47+
return 1;
48+
}
49+
50+
if (packages.some(v => v.name === packageIdentifier.name)) {
51+
this.logger.error(`Duplicate package '${packageIdentifier.name}' specified.`);
52+
53+
return 1;
54+
}
55+
56+
// If next option is used and no specifier supplied, use next tag
57+
if (options.next && !packageIdentifier.rawSpec) {
58+
packageIdentifier.fetchSpec = 'next';
59+
}
60+
61+
packages.push(packageIdentifier);
62+
} catch (e) {
63+
this.logger.error(e.message);
64+
65+
return 1;
4166
}
4267
}
4368

44-
// Move `--` to packages.
45-
if (args.packages == undefined && args['--']) {
46-
args.packages = args['--'];
47-
delete args['--'];
48-
}
69+
if (options.all && packages.length > 0) {
70+
this.logger.error('Cannot specify packages when using the "all" option.');
4971

50-
return args;
51-
}
72+
return 1;
73+
} else if (options.all && options.migrateOnly) {
74+
this.logger.error('Cannot use "all" option with "migrate-only" option.');
75+
76+
return 1;
77+
} else if (!options.migrateOnly && (options.from || options.to)) {
78+
this.logger.error('Can only use "from" or "to" options with "migrate-only" option.');
79+
80+
return 1;
81+
}
5282

53-
async run(options: UpdateCommandSchema & Arguments) {
5483
const packageManager = getPackageManager(this.workspace.root);
84+
this.logger.info(`Using package manager: '${packageManager}'`);
85+
86+
if (options.all || packages.length === 0) {
87+
return this.runSchematic({
88+
collectionName: '@schematics/update',
89+
schematicName: 'update',
90+
dryRun: !!options.dryRun,
91+
force: false,
92+
showNothingDone: false,
93+
additionalOptions: {
94+
all: options.all || false,
95+
force: options.force || false,
96+
next: options.next || false,
97+
packageManager,
98+
},
99+
});
100+
}
101+
102+
// Special handling for Angular CLI 1.x migrations
103+
if (options.migrateOnly === undefined && options.from === undefined) {
104+
if (!options.all && packages.length === 1 && packages[0].name === '@angular/cli') {
105+
const oldConfigFilePath = findUp(oldConfigFileNames, process.cwd());
106+
if (oldConfigFilePath) {
107+
options.migrateOnly = true;
108+
options.from = '1.0.0';
109+
}
110+
}
111+
}
112+
113+
this.logger.info('Collecting installed dependencies...');
114+
115+
const packageTree = await readPackageTree(this.workspace.root);
116+
const rootDependencies = findNodeDependencies(packageTree);
117+
118+
this.logger.info(`Found ${Object.keys(rootDependencies).length} dependencies.`);
119+
120+
if (options.migrateOnly) {
121+
if (!options.from) {
122+
this.logger.error('"from" option is required when using the "migrate-only" option.');
123+
124+
return 1;
125+
} else if (packages.length !== 1) {
126+
this.logger.error(
127+
'A single package must be specified when using the "migrate-only" option.',
128+
);
129+
130+
return 1;
131+
}
132+
133+
if (options.next) {
134+
this.logger.warn('"next" option has no effect when using "migrate-only" option.');
135+
}
136+
137+
const packageName = packages[0].name;
138+
let packageNode = rootDependencies[packageName];
139+
if (typeof packageNode === 'string') {
140+
this.logger.error('Package found in package.json but is not installed.');
141+
142+
return 1;
143+
} else if (!packageNode) {
144+
// TODO: If multiple, this should find all versions and ask which one to use
145+
const child = packageTree.children.find(c => c.name === packageName);
146+
if (child) {
147+
packageNode = child.isLink ? child.target : child;
148+
}
149+
150+
if (!packageNode) {
151+
this.logger.error('Package is not installed.');
152+
153+
return 1;
154+
}
155+
}
156+
157+
const updateMetadata = packageNode.package['ng-update'];
158+
let migrations = updateMetadata && updateMetadata.migrations;
159+
if (migrations === undefined) {
160+
this.logger.error('Package does not provide migrations.');
161+
162+
return 1;
163+
} else if (typeof migrations !== 'string') {
164+
this.logger.error('Package contains a malformed migrations field.');
165+
166+
return 1;
167+
}
168+
169+
// if not non-relative, add package name
170+
if (migrations.startsWith('.') || migrations.startsWith('/')) {
171+
migrations = path.join(packageName, migrations);
172+
}
173+
174+
return this.runSchematic({
175+
collectionName: '@schematics/update',
176+
schematicName: 'migrate',
177+
dryRun: !!options.dryRun,
178+
force: false,
179+
showNothingDone: false,
180+
additionalOptions: {
181+
package: packageName,
182+
collection: migrations,
183+
from: options.from,
184+
to: options.to || packageNode.package.version,
185+
},
186+
});
187+
}
188+
189+
const requests: PackageIdentifier[] = [];
190+
191+
// Validate packages actually are part of the workspace
192+
for (const pkg of packages) {
193+
const node = rootDependencies[pkg.name];
194+
if (!node) {
195+
this.logger.error(`Package '${pkg.name}' is not a dependency.`);
196+
197+
return 1;
198+
}
199+
200+
// If a specific version is requested and matches the installed version, skip.
201+
if (pkg.type === 'version' &&
202+
typeof node === 'object' &&
203+
node.package.version === pkg.fetchSpec) {
204+
this.logger.info(`Package '${pkg.name}' is already at '${pkg.fetchSpec}'.`);
205+
continue;
206+
}
207+
208+
requests.push(pkg);
209+
}
210+
211+
if (requests.length === 0) {
212+
return 0;
213+
}
214+
215+
this.logger.info('Fetching dependency metadata from registry...');
216+
for (const requestIdentifier of requests) {
217+
let metadata;
218+
try {
219+
metadata = await fetchPackageMetadata(requestIdentifier.name, this.logger);
220+
} catch (e) {
221+
this.logger.error(`Error fetching metadata for '${requestIdentifier.name}': ` + e.message);
222+
223+
return 1;
224+
}
225+
226+
let manifest: PackageManifest | undefined;
227+
if (requestIdentifier.type === 'version') {
228+
manifest = metadata.versions.get(requestIdentifier.fetchSpec);
229+
} else if (requestIdentifier.type === 'range') {
230+
const maxVersion = semver.maxSatisfying(
231+
Array.from(metadata.versions.keys()),
232+
requestIdentifier.fetchSpec,
233+
);
234+
if (maxVersion) {
235+
manifest = metadata.versions.get(maxVersion);
236+
}
237+
} else if (requestIdentifier.type === 'tag') {
238+
manifest = metadata.tags[requestIdentifier.fetchSpec];
239+
}
240+
241+
if (!manifest) {
242+
this.logger.error(
243+
`Package specified by '${requestIdentifier.raw}' does not exist within the registry.`,
244+
);
245+
246+
return 1;
247+
}
248+
}
55249

56250
return this.runSchematic({
57-
collectionName: this.collectionName,
58-
schematicName: this.schematicName,
59-
schematicOptions: options['--'],
251+
collectionName: '@schematics/update',
252+
schematicName: 'update',
60253
dryRun: !!options.dryRun,
61254
force: false,
62255
showNothingDone: false,
63-
additionalOptions: { packageManager },
256+
additionalOptions: {
257+
force: options.force || false,
258+
next: options.next || false,
259+
packageManager,
260+
packages: packages.map(p => p.raw),
261+
},
64262
});
65263
}
66264
}

packages/angular/cli/commands/update.json

+42
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,48 @@
1313
"allOf": [
1414
{
1515
"$ref": "./definitions.json#/definitions/base"
16+
},
17+
{
18+
"type": "object",
19+
"properties": {
20+
"packages": {
21+
"description": "The names of package(s) to update.",
22+
"type": "array",
23+
"items": {
24+
"type": "string"
25+
},
26+
"$default": {
27+
"$source": "argv"
28+
}
29+
},
30+
"force": {
31+
"description": "If false, will error out if installed packages are incompatible with the update.",
32+
"default": false,
33+
"type": "boolean"
34+
},
35+
"all": {
36+
"description": "Whether to update all packages in package.json.",
37+
"default": false,
38+
"type": "boolean"
39+
},
40+
"next": {
41+
"description": "Use the largest version, including beta and RCs.",
42+
"default": false,
43+
"type": "boolean"
44+
},
45+
"migrateOnly": {
46+
"description": "Only perform a migration, does not update the installed version.",
47+
"type": "boolean"
48+
},
49+
"from": {
50+
"description": "Version from which to migrate from. Only available with a single package being updated, and only on migration only.",
51+
"type": "string"
52+
},
53+
"to": {
54+
"description": "Version up to which to apply migrations. Only available with a single package being updated, and only on migrations only. Requires from to be specified. Default to the installed version detected.",
55+
"type": "string"
56+
}
57+
}
1658
}
1759
]
1860
}

packages/angular/cli/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"npm-package-arg": "6.1.0",
3838
"open": "6.2.0",
3939
"pacote": "9.5.0",
40+
"read-package-tree": "5.2.2",
4041
"semver": "6.0.0",
4142
"symbol-observable": "1.2.0",
4243
"universal-analytics": "^0.4.20",

packages/angular/cli/utilities/package-metadata.ts

+2
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export interface PackageIdentifier {
2424
scope: string | null;
2525
registry: boolean;
2626
raw: string;
27+
fetchSpec: string;
28+
rawSpec: string;
2729
}
2830

2931
export interface PackageManifest {

0 commit comments

Comments
 (0)