Skip to content

Commit e406f00

Browse files
clydinKeen Yee Liau
authored and
Keen Yee Liau
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 3df0a54 commit e406f00

File tree

6 files changed

+372
-37
lines changed

6 files changed

+372
-37
lines changed

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

+240-37
Original file line numberDiff line numberDiff line change
@@ -5,62 +5,265 @@
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+
// Special handling for Angular CLI 1.x migrations
87+
if (options.migrateOnly === undefined && options.from === undefined) {
88+
if (!options.all && packages.length === 1 && packages[0].name === '@angular/cli') {
89+
const oldConfigFilePath = findUp(oldConfigFileNames, process.cwd());
90+
if (oldConfigFilePath) {
91+
options.migrateOnly = true;
92+
options.from = '1.0.0';
93+
}
94+
}
95+
}
96+
97+
this.logger.info('Collecting installed dependencies...');
98+
99+
const packageTree = await readPackageTree(this.workspace.root);
100+
const rootDependencies = findNodeDependencies(packageTree);
101+
102+
this.logger.info(`Found ${Object.keys(rootDependencies).length} dependencies.`);
103+
104+
if (options.all || packages.length === 0) {
105+
// Either update all packages or show status
106+
return this.runSchematic({
107+
collectionName: '@schematics/update',
108+
schematicName: 'update',
109+
dryRun: !!options.dryRun,
110+
showNothingDone: false,
111+
additionalOptions: {
112+
force: options.force || false,
113+
next: options.next || false,
114+
packageManager,
115+
packages: options.all ? Object.keys(rootDependencies) : [],
116+
},
117+
});
118+
}
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+
// Allow running migrations on transitively installed dependencies
145+
// There can technically be nested multiple versions
146+
// TODO: If multiple, this should find all versions and ask which one to use
147+
const child = packageTree.children.find(c => c.name === packageName);
148+
if (child) {
149+
// A link represents a symlinked package so use the actual in this case
150+
packageNode = child.isLink ? child.target : child;
151+
}
152+
153+
if (!packageNode) {
154+
this.logger.error('Package is not installed.');
155+
156+
return 1;
157+
}
158+
}
159+
160+
const updateMetadata = packageNode.package['ng-update'];
161+
let migrations = updateMetadata && updateMetadata.migrations;
162+
if (migrations === undefined) {
163+
this.logger.error('Package does not provide migrations.');
164+
165+
return 1;
166+
} else if (typeof migrations !== 'string') {
167+
this.logger.error('Package contains a malformed migrations field.');
168+
169+
return 1;
170+
}
171+
172+
// if not non-relative, add package name
173+
if (migrations.startsWith('.') || migrations.startsWith('/')) {
174+
migrations = path.join(packageName, migrations);
175+
}
176+
177+
return this.runSchematic({
178+
collectionName: '@schematics/update',
179+
schematicName: 'migrate',
180+
dryRun: !!options.dryRun,
181+
force: false,
182+
showNothingDone: false,
183+
additionalOptions: {
184+
package: packageName,
185+
collection: migrations,
186+
from: options.from,
187+
to: options.to || packageNode.package.version,
188+
},
189+
});
190+
}
191+
192+
const requests: PackageIdentifier[] = [];
193+
194+
// Validate packages actually are part of the workspace
195+
for (const pkg of packages) {
196+
const node = rootDependencies[pkg.name];
197+
if (!node) {
198+
this.logger.error(`Package '${pkg.name}' is not a dependency.`);
199+
200+
return 1;
201+
}
202+
203+
// If a specific version is requested and matches the installed version, skip.
204+
if (pkg.type === 'version' &&
205+
typeof node === 'object' &&
206+
node.package.version === pkg.fetchSpec) {
207+
this.logger.info(`Package '${pkg.name}' is already at '${pkg.fetchSpec}'.`);
208+
continue;
209+
}
210+
211+
requests.push(pkg);
212+
}
213+
214+
if (requests.length === 0) {
215+
return 0;
216+
}
217+
218+
this.logger.info('Fetching dependency metadata from registry...');
219+
for (const requestIdentifier of requests) {
220+
let metadata;
221+
try {
222+
// Metadata requests are internally cached; multiple requests for same name
223+
// does not result in additional network traffic
224+
metadata = await fetchPackageMetadata(requestIdentifier.name, this.logger);
225+
} catch (e) {
226+
this.logger.error(`Error fetching metadata for '${requestIdentifier.name}': ` + e.message);
227+
228+
return 1;
229+
}
230+
231+
// Try to find a package version based on the user requested package specifier
232+
// registry specifier types are either version, range, or tag
233+
let manifest: PackageManifest | undefined;
234+
if (requestIdentifier.type === 'version') {
235+
manifest = metadata.versions.get(requestIdentifier.fetchSpec);
236+
} else if (requestIdentifier.type === 'range') {
237+
const maxVersion = semver.maxSatisfying(
238+
Array.from(metadata.versions.keys()),
239+
requestIdentifier.fetchSpec,
240+
);
241+
if (maxVersion) {
242+
manifest = metadata.versions.get(maxVersion);
243+
}
244+
} else if (requestIdentifier.type === 'tag') {
245+
manifest = metadata.tags[requestIdentifier.fetchSpec];
246+
}
247+
248+
if (!manifest) {
249+
this.logger.error(
250+
`Package specified by '${requestIdentifier.raw}' does not exist within the registry.`,
251+
);
252+
253+
return 1;
254+
}
255+
}
55256

56257
return this.runSchematic({
57-
collectionName: this.collectionName,
58-
schematicName: this.schematicName,
59-
schematicOptions: options['--'],
258+
collectionName: '@schematics/update',
259+
schematicName: 'update',
60260
dryRun: !!options.dryRun,
61-
force: false,
62261
showNothingDone: false,
63-
additionalOptions: { packageManager },
262+
additionalOptions: {
263+
force: options.force || false,
264+
packageManager,
265+
packages: requests.map(p => p.toString()),
266+
},
64267
});
65268
}
66269
}

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)