Skip to content

Commit 0316dea

Browse files
alan-agius4clydin
authored andcommitted
feat(@angular/cli): add prompts on missing builder targets
With this change we add prompts to `ng deploy` and `ng e2e` to facilitate adding packages that offer these capabalities. We also add back `ng lint` prompt to add ESLint which was removed by mistake during the commands refactoring.
1 parent 67144b9 commit 0316dea

File tree

7 files changed

+191
-46
lines changed

7 files changed

+191
-46
lines changed

packages/angular/cli/src/command-builder/architect-base-command-module.ts

+83-2
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@
99
import { Architect, Target } from '@angular-devkit/architect';
1010
import { WorkspaceNodeModulesArchitectHost } from '@angular-devkit/architect/node';
1111
import { json } from '@angular-devkit/core';
12+
import { spawnSync } from 'child_process';
1213
import { existsSync } from 'fs';
1314
import { resolve } from 'path';
1415
import { isPackageNameSafeForAnalytics } from '../analytics/analytics';
16+
import { askConfirmation, askQuestion } from '../utilities/prompt';
17+
import { isTTY } from '../utilities/tty';
1518
import {
1619
CommandModule,
1720
CommandModuleError,
@@ -21,13 +24,18 @@ import {
2124
} from './command-module';
2225
import { Option, parseJsonSchemaToOptions } from './utilities/json-schema';
2326

27+
export interface MissingTargetChoice {
28+
name: string;
29+
value: string;
30+
}
31+
2432
export abstract class ArchitectBaseCommandModule<T>
2533
extends CommandModule<T>
2634
implements CommandModuleImplementation<T>
2735
{
2836
static override scope = CommandScope.In;
2937
protected override shouldReportAnalytics = false;
30-
protected readonly missingErrorTarget: string | undefined;
38+
protected readonly missingTargetChoices: MissingTargetChoice[] | undefined;
3139

3240
protected async runSingleTarget(target: Target, options: OtherOptions): Promise<number> {
3341
const architectHost = await this.getArchitectHost();
@@ -36,7 +44,7 @@ export abstract class ArchitectBaseCommandModule<T>
3644
try {
3745
builderName = await architectHost.getBuilderNameForTarget(target);
3846
} catch (e) {
39-
throw new CommandModuleError(this.missingErrorTarget ?? e.message);
47+
return this.onMissingTarget(e.message);
4048
}
4149

4250
await this.reportAnalytics({
@@ -137,4 +145,77 @@ export abstract class ArchitectBaseCommandModule<T>
137145
`Node packages may not be installed. Try installing with '${this.context.packageManager} install'.`,
138146
);
139147
}
148+
149+
protected getArchitectTarget(): string {
150+
return this.commandName;
151+
}
152+
153+
protected async onMissingTarget(defaultMessage: string): Promise<1> {
154+
const { logger } = this.context;
155+
const choices = this.missingTargetChoices;
156+
157+
if (!choices?.length) {
158+
logger.error(defaultMessage);
159+
160+
return 1;
161+
}
162+
163+
const missingTargetMessage =
164+
`Cannot find "${this.getArchitectTarget()}" target for the specified project.\n` +
165+
`You can add a package that implements these capabilities.\n\n` +
166+
`For example:\n` +
167+
choices.map(({ name, value }) => ` ${name}: ng add ${value}`).join('\n') +
168+
'\n';
169+
170+
if (isTTY()) {
171+
// Use prompts to ask the user if they'd like to install a package.
172+
logger.warn(missingTargetMessage);
173+
174+
const packageToInstall = await this.getMissingTargetPackageToInstall(choices);
175+
if (packageToInstall) {
176+
// Example run: `ng add @angular-eslint/schematics`.
177+
const binPath = resolve(__dirname, '../../bin/ng.js');
178+
const { error } = spawnSync(process.execPath, [binPath, 'add', packageToInstall], {
179+
stdio: 'inherit',
180+
});
181+
182+
if (error) {
183+
throw error;
184+
}
185+
}
186+
} else {
187+
// Non TTY display error message.
188+
logger.error(missingTargetMessage);
189+
}
190+
191+
return 1;
192+
}
193+
194+
private async getMissingTargetPackageToInstall(
195+
choices: MissingTargetChoice[],
196+
): Promise<string | null> {
197+
if (choices.length === 1) {
198+
// Single choice
199+
const { name, value } = choices[0];
200+
if (await askConfirmation(`Would you like to add ${name} now?`, true, false)) {
201+
return value;
202+
}
203+
204+
return null;
205+
}
206+
207+
// Multiple choice
208+
return askQuestion(
209+
`Would you like to add a package with "${this.getArchitectTarget()}" capabilities now?`,
210+
[
211+
{
212+
name: 'No',
213+
value: null,
214+
},
215+
...choices,
216+
],
217+
0,
218+
null,
219+
);
220+
}
140221
}

packages/angular/cli/src/command-builder/architect-command-module.ts

+1-7
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,7 @@ export abstract class ArchitectCommandModule
7070
let result = 0;
7171
const projectNames = this.getProjectNamesByTarget(target);
7272
if (!projectNames) {
73-
throw new CommandModuleError(
74-
this.missingErrorTarget ?? 'Cannot determine project or target for command.',
75-
);
73+
return this.onMissingTarget('Cannot determine project or target for command.');
7674
}
7775

7876
for (const project of projectNames) {
@@ -107,10 +105,6 @@ export abstract class ArchitectCommandModule
107105
return projectFromTarget?.length ? projectFromTarget[0] : undefined;
108106
}
109107

110-
private getArchitectTarget(): string {
111-
return this.commandName;
112-
}
113-
114108
@memoize
115109
private getProjectNamesByTarget(target: string): string[] | undefined {
116110
const workspace = this.getWorkspaceOrThrow();

packages/angular/cli/src/commands/deploy/cli.ts

+28-13
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,42 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import { tags } from '@angular-devkit/core';
109
import { join } from 'path';
10+
import { MissingTargetChoice } from '../../command-builder/architect-base-command-module';
1111
import { ArchitectCommandModule } from '../../command-builder/architect-command-module';
1212
import { CommandModuleImplementation } from '../../command-builder/command-module';
1313

1414
export class DeployCommandModule
1515
extends ArchitectCommandModule
1616
implements CommandModuleImplementation
1717
{
18-
override missingErrorTarget = tags.stripIndents`
19-
Cannot find "deploy" target for the specified project.
20-
21-
You should add a package that implements deployment capabilities for your
22-
favorite platform.
23-
24-
For example:
25-
ng add @angular/fire
26-
ng add @azure/ng-deploy
27-
28-
Find more packages on npm https://www.npmjs.com/search?q=ng%20deploy
29-
`;
18+
// The below choices should be kept in sync with the list in https://angular.io/guide/deployment
19+
override missingTargetChoices: MissingTargetChoice[] = [
20+
{
21+
name: 'Amazon S3',
22+
value: '@jefiozie/ngx-aws-deploy',
23+
},
24+
{
25+
name: 'Azure',
26+
value: '@azure/ng-deploy',
27+
},
28+
{
29+
name: 'Firebase',
30+
value: '@angular/fire',
31+
},
32+
{
33+
name: 'Netlify',
34+
value: '@netlify-builder/deploy',
35+
},
36+
{
37+
name: 'NPM',
38+
value: 'ngx-deploy-npm',
39+
},
40+
{
41+
name: 'GitHub Pages',
42+
value: 'angular-cli-ghpages',
43+
},
44+
];
3045

3146
multiTarget = false;
3247
command = 'deploy [project]';

packages/angular/cli/src/commands/e2e/cli.ts

+16-14
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,30 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import { tags } from '@angular-devkit/core';
9+
import { MissingTargetChoice } from '../../command-builder/architect-base-command-module';
1010
import { ArchitectCommandModule } from '../../command-builder/architect-command-module';
1111
import { CommandModuleImplementation } from '../../command-builder/command-module';
1212

1313
export class E2eCommandModule
1414
extends ArchitectCommandModule
1515
implements CommandModuleImplementation
1616
{
17-
multiTarget = true;
18-
override missingErrorTarget = tags.stripIndents`
19-
Cannot find "e2e" target for the specified project.
20-
21-
You should add a package that implements end-to-end testing capabilities.
22-
23-
For example:
24-
Cypress: ng add @cypress/schematic
25-
Nightwatch: ng add @nightwatch/schematics
26-
WebdriverIO: ng add @wdio/schematics
27-
28-
More options will be added to the list as they become available.
29-
`;
17+
override missingTargetChoices: MissingTargetChoice[] = [
18+
{
19+
name: 'Cypress',
20+
value: '@cypress/schematic',
21+
},
22+
{
23+
name: 'Nightwatch',
24+
value: '@nightwatch/schematics',
25+
},
26+
{
27+
name: 'WebdriverIO',
28+
value: '@wdio/schematics',
29+
},
30+
];
3031

32+
multiTarget = true;
3133
command = 'e2e [project]';
3234
aliases = ['e'];
3335
describe = 'Builds and serves an Angular application, then runs end-to-end tests.';

packages/angular/cli/src/commands/lint/cli.ts

+7-9
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,21 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import { tags } from '@angular-devkit/core';
109
import { join } from 'path';
10+
import { MissingTargetChoice } from '../../command-builder/architect-base-command-module';
1111
import { ArchitectCommandModule } from '../../command-builder/architect-command-module';
1212
import { CommandModuleImplementation } from '../../command-builder/command-module';
1313

1414
export class LintCommandModule
1515
extends ArchitectCommandModule
1616
implements CommandModuleImplementation
1717
{
18-
override missingErrorTarget = tags.stripIndents`
19-
Cannot find "lint" target for the specified project.
20-
21-
You should add a package that implements linting capabilities.
22-
23-
For example:
24-
ng add @angular-eslint/schematics
25-
`;
18+
override missingTargetChoices: MissingTargetChoice[] = [
19+
{
20+
name: 'ESLint',
21+
value: '@angular-eslint/schematics',
22+
},
23+
];
2624

2725
multiTarget = true;
2826
command = 'lint [project]';

packages/angular/cli/src/utilities/prompt.ts

+25-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import type { Question } from 'inquirer';
9+
import type { ListChoiceOptions, ListQuestion, Question } from 'inquirer';
1010
import { isTTY } from './tty';
1111

1212
export async function askConfirmation(
@@ -30,3 +30,27 @@ export async function askConfirmation(
3030

3131
return answers['confirmation'];
3232
}
33+
34+
export async function askQuestion(
35+
message: string,
36+
choices: ListChoiceOptions[],
37+
defaultResponseIndex: number,
38+
noTTYResponse: null | string,
39+
): Promise<string | null> {
40+
if (!isTTY()) {
41+
return noTTYResponse;
42+
}
43+
const question: ListQuestion = {
44+
type: 'list',
45+
name: 'answer',
46+
prefix: '',
47+
message,
48+
choices,
49+
default: defaultResponseIndex,
50+
};
51+
52+
const { prompt } = await import('inquirer');
53+
const answers = await prompt([question]);
54+
55+
return answers['answer'];
56+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { execWithEnv, killAllProcesses, waitForAnyProcessOutputToMatch } from '../../utils/process';
2+
3+
export default async function () {
4+
try {
5+
// Execute a command with TTY force enabled
6+
execWithEnv('ng', ['deploy'], {
7+
...process.env,
8+
NG_FORCE_TTY: '1',
9+
NG_CLI_ANALYTICS: 'false',
10+
});
11+
12+
// Check if the prompt is shown
13+
await waitForAnyProcessOutputToMatch(
14+
/Would you like to add a package with "deploy" capabilities/,
15+
);
16+
17+
killAllProcesses();
18+
19+
// Execute a command with TTY force enabled
20+
execWithEnv('ng', ['lint'], {
21+
...process.env,
22+
NG_FORCE_TTY: '1',
23+
NG_CLI_ANALYTICS: 'false',
24+
});
25+
26+
// Check if the prompt is shown
27+
await waitForAnyProcessOutputToMatch(/Would you like to add ESLint now/);
28+
} finally {
29+
killAllProcesses();
30+
}
31+
}

0 commit comments

Comments
 (0)