Skip to content

Commit 4212fb8

Browse files
committed
feat(@angular/cli): add prompt to set up CLI autocompletion
When the CLI is executed with any command, it will check if `ng completion script` is already included in the user's `~/.bashrc` file (or similar) and if not, ask the user if they would like it to be configured for them. The CLI checks any existing `~/.bashrc`, `~/.zshrc`, `~/.bash_profile`, `~/.zsh_profile`, and `~/.profile` files for `ng completion script`, and if that string is found for the current shell's configuration files, this prompt is skipped. If the user refuses the prompt, no action is taken and the CLI continues on the command the user originally requested. Refs #23003.
1 parent 022d8c7 commit 4212fb8

File tree

5 files changed

+323
-0
lines changed

5 files changed

+323
-0
lines changed

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

+9
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from 'yargs';
2020
import { Parser as yargsParser } from 'yargs/helpers';
2121
import { createAnalytics } from '../analytics/analytics';
22+
import { considerSettingUpAutocompletion } from '../utilities/completion';
2223
import { AngularWorkspace } from '../utilities/config';
2324
import { memoize } from '../utilities/memoize';
2425
import { PackageManagerUtils } from '../utilities/package-manager';
@@ -123,6 +124,14 @@ export abstract class CommandModule<T extends {} = {}> implements CommandModuleI
123124
camelCasedOptions[yargsParser.camelCase(key)] = value;
124125
}
125126

127+
// Set up autocompletion if appropriate.
128+
const autocompletionExitCode = await considerSettingUpAutocompletion(this.context.logger);
129+
if (autocompletionExitCode !== undefined) {
130+
process.exitCode = autocompletionExitCode;
131+
132+
return;
133+
}
134+
126135
// Gather and report analytics.
127136
const analytics = await this.getAnalytics();
128137
if (this.shouldReportAnalytics) {

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

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

9+
import { logging } from '@angular-devkit/core';
910
import { promises as fs } from 'fs';
1011
import * as path from 'path';
1112
import { env } from 'process';
13+
import { colors } from '../utilities/color';
14+
import { forceAutocomplete } from '../utilities/environment-options';
15+
import { isTTY } from '../utilities/tty';
16+
17+
/**
18+
* Checks if it is appropriate to prompt the user to setup autocompletion. If not, does nothing. If
19+
* so prompts and sets up autocompletion for the user. Returns an exit code if the program should
20+
* terminate, otherwise returns `undefined`.
21+
* @returns an exit code if the program should terminate, undefined otherwise.
22+
*/
23+
export async function considerSettingUpAutocompletion(
24+
logger: logging.Logger,
25+
): Promise<number | undefined> {
26+
// Check if we should prompt the user to setup autocompletion.
27+
if (!(await shouldPromptForAutocompletionSetup())) {
28+
return undefined; // Already set up, nothing to do.
29+
}
30+
31+
// Prompt the user and record their response.
32+
const shouldSetupAutocompletion = await promptForAutocompletion();
33+
if (!shouldSetupAutocompletion) {
34+
return undefined; // User rejected the prompt and doesn't want autocompletion.
35+
}
36+
37+
// User accepted the prompt, set up autocompletion.
38+
let rcFile: string;
39+
try {
40+
rcFile = await initializeAutocomplete();
41+
} catch (err) {
42+
// Failed to set up autocompeletion, log the error and abort.
43+
logger.error(err.message);
44+
45+
return 1;
46+
}
47+
48+
// Notify the user autocompletion was set up successfully.
49+
logger.info(
50+
`
51+
Appended \`source <(ng completion script)\` to \`${rcFile}\`. Restart your terminal or run the following to autocomplete \`ng\` commands:
52+
53+
${colors.yellow(`source <(ng completion script)`)}
54+
`.trim(),
55+
);
56+
57+
return undefined;
58+
}
59+
60+
async function shouldPromptForAutocompletionSetup(): Promise<boolean> {
61+
// Force whether or not to prompt for autocomplete to give an easy path for e2e testing to skip.
62+
if (forceAutocomplete !== undefined) {
63+
return forceAutocomplete;
64+
}
65+
66+
// Non-interactive and continuous integration systems don't care about autocompletion.
67+
if (!isTTY()) {
68+
return false;
69+
}
70+
71+
// `$HOME` variable is necessary to find RC files to modify.
72+
const home = env['HOME'];
73+
if (!home) {
74+
return false;
75+
}
76+
77+
// Get possible RC files for the current shell.
78+
const shell = env['SHELL'];
79+
if (!shell) {
80+
return false;
81+
}
82+
const rcFiles = getShellRunCommandCandidates(shell, home);
83+
if (!rcFiles) {
84+
return false; // Unknown shell.
85+
}
86+
87+
// Check each RC file if they already use `ng completion script` in any capacity and don't prompt.
88+
for (const rcFile of rcFiles) {
89+
const contents = await fs.readFile(rcFile, 'utf-8').catch(() => undefined);
90+
if (contents?.includes('ng completion script')) {
91+
return false;
92+
}
93+
}
94+
95+
return true;
96+
}
97+
98+
async function promptForAutocompletion(): Promise<boolean> {
99+
// Dynamically load `inquirer` so users don't have to pay the cost of parsing and executing it for
100+
// the 99% of builds that *don't* prompt for autocompletion.
101+
const { prompt } = await import('inquirer');
102+
const { autocomplete } = await prompt<{ autocomplete: boolean }>([
103+
{
104+
name: 'autocomplete',
105+
type: 'confirm',
106+
message: `
107+
Would you like to enable autocompletion? This will set up your terminal so pressing TAB while typing
108+
Angular CLI commands will show possible options and autocomplete arguments. (Enabling autocompletion
109+
will modify configuration files in your home directory.)
110+
`
111+
.split('\n')
112+
.join(' ')
113+
.trim(),
114+
default: true,
115+
},
116+
]);
117+
118+
return autocomplete;
119+
}
12120

13121
/**
14122
* Sets up autocompletion for the user's terminal. This attempts to find the configuration file for

packages/angular/cli/src/utilities/environment-options.ts

+9
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,17 @@ function isEnabled(variable: string | undefined): boolean {
1818
return isPresent(variable) && (variable === '1' || variable.toLowerCase() === 'true');
1919
}
2020

21+
function optional(variable: string | undefined): boolean | undefined {
22+
if (!isPresent(variable)) {
23+
return undefined;
24+
}
25+
26+
return isEnabled(variable);
27+
}
28+
2129
export const analyticsDisabled = isDisabled(process.env['NG_CLI_ANALYTICS']);
2230
export const analyticsShareDisabled = isDisabled(process.env['NG_CLI_ANALYTICS_SHARE']);
2331
export const isCI = isEnabled(process.env['CI']);
2432
export const disableVersionCheck = isEnabled(process.env['NG_DISABLE_VERSION_CHECK']);
2533
export const ngDebug = isEnabled(process.env['NG_DEBUG']);
34+
export const forceAutocomplete = optional(process.env['NG_FORCE_AUTOCOMPLETE']);

tests/legacy-cli/e2e/tests/misc/ask-analytics-command.ts

+3
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export default async function () {
1313
...process.env,
1414
HOME: home,
1515
NG_FORCE_TTY: '1',
16+
NG_FORCE_AUTOCOMPLETE: 'false',
1617
},
1718
'y' /* stdin */,
1819
);
@@ -29,6 +30,7 @@ export default async function () {
2930
HOME: home,
3031
NG_FORCE_TTY: '1',
3132
NG_CLI_ANALYTICS: 'false',
33+
NG_FORCE_AUTOCOMPLETE: 'false',
3234
});
3335

3436
if (ANALYTICS_PROMPT.test(stdout)) {
@@ -42,6 +44,7 @@ export default async function () {
4244
...process.env,
4345
HOME: home,
4446
NG_FORCE_TTY: '1',
47+
NG_FORCE_AUTOCOMPLETE: 'false',
4548
});
4649

4750
if (ANALYTICS_PROMPT.test(stdout)) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { promises as fs } from 'fs';
2+
import * as os from 'os';
3+
import * as path from 'path';
4+
import { env } from 'process';
5+
import { execWithEnv } from '../../utils/process';
6+
7+
const AUTOCOMPLETION_PROMPT = /Would you like to enable autocompletion\?/;
8+
const DEFAULT_ENV = Object.freeze({
9+
...env,
10+
// Shell should be mocked for each test that cares about it.
11+
SHELL: '/bin/bash',
12+
// Even if the actual test process is run on CI, we're testing user flows which aren't on CI.
13+
CI: undefined,
14+
// Tests run on CI technically don't have a TTY, but the autocompletion prompt requires it, so we
15+
// force a TTY by default.
16+
NG_FORCE_TTY: '1',
17+
// Analytics wants to prompt for a first command as well, but we don't care about that here.
18+
NG_CLI_ANALYTICS: 'false',
19+
});
20+
21+
export default async function () {
22+
// Sets up autocompletion after user accepts a prompt from any command.
23+
await mockHome(async (home) => {
24+
const bashrc = path.join(home, '.bashrc');
25+
await fs.writeFile(bashrc, `# Other content...`);
26+
27+
const { stdout } = await execWithEnv(
28+
'ng',
29+
['version'],
30+
{
31+
...DEFAULT_ENV,
32+
SHELL: '/bin/bash',
33+
HOME: home,
34+
},
35+
'y' /* stdin: accept prompt */,
36+
);
37+
38+
if (!AUTOCOMPLETION_PROMPT.test(stdout)) {
39+
throw new Error('CLI execution did not prompt for autocompletion setup when it should have.');
40+
}
41+
42+
const bashrcContents = await fs.readFile(bashrc, 'utf-8');
43+
if (!bashrcContents.includes('source <(ng completion script)')) {
44+
throw new Error(
45+
'Autocompletion was *not* added to `~/.bashrc` after accepting the setup' + ' prompt.',
46+
);
47+
}
48+
49+
if (!stdout.includes('Appended `source <(ng completion script)`')) {
50+
throw new Error('CLI did not print that it successfully set up autocompletion.');
51+
}
52+
});
53+
54+
// Does nothing if the user rejects the autocompletion prompt.
55+
await mockHome(async (home) => {
56+
const bashrc = path.join(home, '.bashrc');
57+
await fs.writeFile(bashrc, `# Other content...`);
58+
59+
const { stdout } = await execWithEnv(
60+
'ng',
61+
['version'],
62+
{
63+
...DEFAULT_ENV,
64+
SHELL: '/bin/bash',
65+
HOME: home,
66+
},
67+
'n' /* stdin: reject prompt */,
68+
);
69+
70+
if (!AUTOCOMPLETION_PROMPT.test(stdout)) {
71+
throw new Error('CLI execution did not prompt for autocompletion setup when it should have.');
72+
}
73+
74+
const bashrcContents = await fs.readFile(bashrc, 'utf-8');
75+
if (bashrcContents.includes('ng completion')) {
76+
throw new Error(
77+
'Autocompletion was incorrectly added to `~/.bashrc` after refusing the setup' + ' prompt.',
78+
);
79+
}
80+
81+
if (stdout.includes('Appended `source <(ng completion script)`')) {
82+
throw new Error(
83+
'CLI printed that it successfully set up autocompletion when it actually' + " didn't.",
84+
);
85+
}
86+
});
87+
88+
// Does *not* prompt user for CI executions.
89+
{
90+
const { stdout } = await execWithEnv('ng', ['version'], {
91+
...DEFAULT_ENV,
92+
CI: 'true',
93+
NG_FORCE_TTY: undefined,
94+
});
95+
96+
if (AUTOCOMPLETION_PROMPT.test(stdout)) {
97+
throw new Error('CI execution prompted for autocompletion setup but should not have.');
98+
}
99+
}
100+
101+
// Does *not* prompt user for non-TTY executions.
102+
{
103+
const { stdout } = await execWithEnv('ng', ['version'], {
104+
...DEFAULT_ENV,
105+
NG_FORCE_TTY: 'false',
106+
});
107+
108+
if (AUTOCOMPLETION_PROMPT.test(stdout)) {
109+
throw new Error('Non-TTY execution prompted for autocompletion setup but should not have.');
110+
}
111+
}
112+
113+
// Does *not* prompt user for executions without a `$HOME`.
114+
{
115+
const { stdout } = await execWithEnv('ng', ['version'], {
116+
...DEFAULT_ENV,
117+
HOME: undefined,
118+
});
119+
120+
if (AUTOCOMPLETION_PROMPT.test(stdout)) {
121+
throw new Error(
122+
'Execution without a `$HOME` value prompted for autocompletion setup but' +
123+
' should not have.',
124+
);
125+
}
126+
}
127+
128+
// Does *not* prompt user for executions without a `$SHELL`.
129+
{
130+
const { stdout } = await execWithEnv('ng', ['version'], {
131+
...DEFAULT_ENV,
132+
SHELL: undefined,
133+
});
134+
135+
if (AUTOCOMPLETION_PROMPT.test(stdout)) {
136+
throw new Error(
137+
'Execution without a `$SHELL` value prompted for autocompletion setup but' +
138+
' should not have.',
139+
);
140+
}
141+
}
142+
143+
// Does *not* prompt user for executions from unknown shells.
144+
{
145+
const { stdout } = await execWithEnv('ng', ['version'], {
146+
...DEFAULT_ENV,
147+
SHELL: '/usr/bin/unknown',
148+
});
149+
150+
if (AUTOCOMPLETION_PROMPT.test(stdout)) {
151+
throw new Error(
152+
'Execution with an unknown `$SHELL` value prompted for autocompletion setup' +
153+
' but should not have.',
154+
);
155+
}
156+
}
157+
158+
// Does *not* prompt user when an RC file already uses `ng completion`.
159+
await mockHome(async (home) => {
160+
await fs.writeFile(
161+
path.join(home, '.bashrc'),
162+
`
163+
# Some stuff...
164+
165+
source <(ng completion script)
166+
167+
# Some other stuff...
168+
`.trim(),
169+
);
170+
171+
const { stdout } = await execWithEnv('ng', ['version'], {
172+
...DEFAULT_ENV,
173+
SHELL: '/bin/bash',
174+
HOME: home,
175+
});
176+
177+
if (AUTOCOMPLETION_PROMPT.test(stdout)) {
178+
throw new Error(
179+
"Execution with an existing `ng completion` line in the user's RC file" +
180+
' prompted for autocompletion setup but should not have.',
181+
);
182+
}
183+
});
184+
}
185+
186+
async function mockHome(cb: (home: string) => Promise<void>): Promise<void> {
187+
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'angular-cli-e2e-home-'));
188+
189+
try {
190+
await cb(tempHome);
191+
} finally {
192+
await fs.rm(tempHome, { recursive: true, force: true });
193+
}
194+
}

0 commit comments

Comments
 (0)