Skip to content

Commit cfe930b

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` is already set up for the user's shell 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`, 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. The one awkward edge case users who run `ng completion init`. This triggers the prompt to unnecessarily ask "Would you like to setup autocompletion?" and does so if the user accepts. Then it moves on to the actual `ng completion init` execution, which then sets up autocompletion again. Once the CLI is updated to only show this prompt once, this case would only happen if `ng completion init` is the first command executed, which is pretty unlikely and unnecessary given the prompt, so this should be ok for now. Refs #23003.
1 parent a85cd69 commit cfe930b

File tree

4 files changed

+312
-0
lines changed

4 files changed

+312
-0
lines changed

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

Lines changed: 8 additions & 0 deletions
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';
@@ -129,6 +130,13 @@ export abstract class CommandModule<T extends {} = {}> implements CommandModuleI
129130
await this.reportAnalytics(camelCasedOptions);
130131
}
131132

133+
// Set up autocompletion if appropriate.
134+
const autocompletionExitCode = await considerSettingUpAutocompletion(this.context);
135+
if (autocompletionExitCode !== undefined) {
136+
process.exitCode = autocompletionExitCode;
137+
return;
138+
}
139+
132140
let exitCode: number | void | undefined;
133141
try {
134142
// Run and time command.

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

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,116 @@
77
*/
88

99
import { promises as fs } from 'fs';
10+
import { CommandContext } from '../command-builder/command-module';
11+
import { colors } from '../utilities/color';
12+
import { isTTY } from '../utilities/tty';
1013
import * as path from 'path';
1114
import { env } from 'process';
1215

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

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

Lines changed: 3 additions & 0 deletions
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)) {
Lines changed: 194 additions & 0 deletions
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)')) {
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)`')) {
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)`')) {
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)
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)