Skip to content

Commit 4b795b1

Browse files
committed
feat(@angular/cli): remember after prompting users to set up autocompletion and don't prompt again
After the user rejects the autocompletion prompt or accepts and is successfully configured, the state is saved into the Angular CLI's global configuration. Before displaying the autocompletion prompt, this state is checked and the prompt is skipped if it was already shown. If the user accepts the prompt but the setup process fails, then the CLI will prompt again on the next execution, this gives users an opportunity to fix whatever issue they are encountering and try again. Closes #23003.
1 parent 45e61d8 commit 4b795b1

File tree

2 files changed

+215
-6
lines changed

2 files changed

+215
-6
lines changed

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

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,23 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import { logging } from '@angular-devkit/core';
9+
import { json, logging } from '@angular-devkit/core';
1010
import { promises as fs } from 'fs';
1111
import * as path from 'path';
1212
import { env } from 'process';
1313
import { colors } from '../utilities/color';
14+
import { getWorkspace } from '../utilities/config';
1415
import { isTTY } from '../utilities/tty';
1516

17+
/** Interface for the autocompletion configuration stored in the global workspace. */
18+
interface CompletionConfig {
19+
/**
20+
* Whether or not the user has been prompted to set up autocompletion. If `true`, should *not*
21+
* prompt them again.
22+
*/
23+
prompted?: boolean;
24+
}
25+
1626
/**
1727
* Checks if it is appropriate to prompt the user to setup autocompletion. If not, does nothing. If
1828
* so prompts and sets up autocompletion for the user. Returns an exit code if the program should
@@ -23,14 +33,27 @@ export async function considerSettingUpAutocompletion(
2333
logger: logging.Logger,
2434
): Promise<number | undefined> {
2535
// Check if we should prompt the user to setup autocompletion.
26-
if (!(await shouldPromptForAutocompletionSetup())) {
27-
return undefined; // Already set up, nothing to do.
36+
const completionConfig = await getCompletionConfig();
37+
if (!(await shouldPromptForAutocompletionSetup(completionConfig))) {
38+
return undefined; // Already set up or prompted previously, nothing to do.
2839
}
2940

3041
// Prompt the user and record their response.
3142
const shouldSetupAutocompletion = await promptForAutocompletion();
3243
if (!shouldSetupAutocompletion) {
33-
return undefined; // User rejected the prompt and doesn't want autocompletion.
44+
// User rejected the prompt and doesn't want autocompletion.
45+
logger.info(
46+
`
47+
Ok, you won't be prompted again. Should you change your mind, the following command will set up autocompletion for you:
48+
49+
${colors.yellow(`ng completion init`)}
50+
`.trim(),
51+
);
52+
53+
// Save configuration to remember that the user was prompted and avoid prompting again.
54+
await setCompletionConfig({ ...completionConfig, prompted: true });
55+
56+
return undefined;
3457
}
3558

3659
// User accepted the prompt, set up autocompletion.
@@ -53,10 +76,36 @@ Appended \`source <(ng completion)\` to \`${rcFile}\`. Restart your terminal or
5376
`.trim(),
5477
);
5578

79+
// Save configuration to remember that the user was prompted.
80+
await setCompletionConfig({ ...completionConfig, prompted: true });
81+
5682
return undefined;
5783
}
5884

59-
async function shouldPromptForAutocompletionSetup(): Promise<boolean> {
85+
async function getCompletionConfig(): Promise<CompletionConfig | undefined> {
86+
const wksp = await getWorkspace('global');
87+
88+
return wksp?.getCli()?.['completion'];
89+
}
90+
91+
async function setCompletionConfig(config: CompletionConfig): Promise<void> {
92+
const wksp = await getWorkspace('global');
93+
if (!wksp) {
94+
throw new Error(`Could not find global workspace`);
95+
}
96+
97+
wksp.extensions['cli'] ??= {};
98+
const cli = wksp.extensions['cli'];
99+
if (!json.isJsonObject(cli)) {
100+
throw new Error(
101+
`Invalid config found at ${wksp.filePath}. \`extensions.cli\` should be an object.`,
102+
);
103+
}
104+
cli.completion ??= config as json.JsonObject;
105+
await wksp.save();
106+
}
107+
108+
async function shouldPromptForAutocompletionSetup(config?: CompletionConfig): Promise<boolean> {
60109
// Force whether or not to prompt for autocomplete to give an easy path for e2e testing to skip.
61110
const forceAutocomplete = env['NG_FORCE_AUTOCOMPLETE'];
62111
if (forceAutocomplete !== undefined) {
@@ -68,6 +117,11 @@ async function shouldPromptForAutocompletionSetup(): Promise<boolean> {
68117
return false;
69118
}
70119

120+
// Skip prompt if the user has already been prompted.
121+
if (config?.prompted) {
122+
return false;
123+
}
124+
71125
// `$HOME` variable is necessary to find RC files to modify.
72126
const home = env['HOME'];
73127
if (!home) {

tests/legacy-cli/e2e/tests/misc/completion-prompt.ts

Lines changed: 156 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { promises as fs } from 'fs';
22
import * as os from 'os';
33
import * as path from 'path';
44
import { env } from 'process';
5-
import { execWithEnv } from '../../utils/process';
5+
import { execAndCaptureError, execWithEnv } from '../../utils/process';
66

77
const AUTOCOMPLETION_PROMPT = /Would you like to enable autocompletion\?/;
88
const DEFAULT_ENV = Object.freeze({
@@ -83,6 +83,161 @@ export default async function () {
8383
'CLI printed that it successfully set up autocompletion when it actually' + " didn't.",
8484
);
8585
}
86+
87+
if (!stdout.includes("Ok, you won't be prompted again.")) {
88+
throw new Error('CLI did not inform the user they will not be prompted again.');
89+
}
90+
});
91+
92+
// Does *not* prompt if the user already accepted (even if they delete the completion config).
93+
await mockHome(async (home) => {
94+
const bashrc = path.join(home, '.bashrc');
95+
await fs.writeFile(bashrc, '# Other commands...');
96+
97+
const { stdout: stdout1 } = await execWithEnv(
98+
'ng',
99+
['version'],
100+
{
101+
...DEFAULT_ENV,
102+
SHELL: '/bin/bash',
103+
HOME: home,
104+
},
105+
'y' /* stdin: accept prompt */,
106+
);
107+
108+
if (!AUTOCOMPLETION_PROMPT.test(stdout1)) {
109+
throw new Error('First execution did not prompt for autocompletion setup.');
110+
}
111+
112+
const bashrcContents1 = await fs.readFile(bashrc, 'utf-8');
113+
if (!bashrcContents1.includes('source <(ng completion)')) {
114+
throw new Error(
115+
'`~/.bashrc` file was not updated after the user accepted the autocompletion' +
116+
` prompt. Contents:\n${bashrcContents1}`,
117+
);
118+
}
119+
120+
// User modifies their configuration and removes `ng completion`.
121+
await fs.writeFile(bashrc, '# Some new commands...');
122+
123+
const { stdout: stdout2 } = await execWithEnv('ng', ['version'], {
124+
...DEFAULT_ENV,
125+
SHELL: '/bin/bash',
126+
HOME: home,
127+
});
128+
129+
if (AUTOCOMPLETION_PROMPT.test(stdout2)) {
130+
throw new Error(
131+
'Subsequent execution after rejecting autocompletion setup prompted again' +
132+
' when it should not have.',
133+
);
134+
}
135+
136+
const bashrcContents2 = await fs.readFile(bashrc, 'utf-8');
137+
if (bashrcContents2 !== '# Some new commands...') {
138+
throw new Error(
139+
'`~/.bashrc` file was incorrectly modified when using a modified `~/.bashrc`' +
140+
` after previously accepting the autocompletion prompt. Contents:\n${bashrcContents2}`,
141+
);
142+
}
143+
});
144+
145+
// Does *not* prompt if the user already rejected.
146+
await mockHome(async (home) => {
147+
const bashrc = path.join(home, '.bashrc');
148+
await fs.writeFile(bashrc, '# Other commands...');
149+
150+
const { stdout: stdout1 } = await execWithEnv(
151+
'ng',
152+
['version'],
153+
{
154+
...DEFAULT_ENV,
155+
SHELL: '/bin/bash',
156+
HOME: home,
157+
},
158+
'n' /* stdin: reject prompt */,
159+
);
160+
161+
if (!AUTOCOMPLETION_PROMPT.test(stdout1)) {
162+
throw new Error('First execution did not prompt for autocompletion setup.');
163+
}
164+
165+
const { stdout: stdout2 } = await execWithEnv('ng', ['version'], {
166+
...DEFAULT_ENV,
167+
SHELL: '/bin/bash',
168+
HOME: home,
169+
});
170+
171+
if (AUTOCOMPLETION_PROMPT.test(stdout2)) {
172+
throw new Error(
173+
'Subsequent execution after rejecting autocompletion setup prompted again' +
174+
' when it should not have.',
175+
);
176+
}
177+
178+
const bashrcContents = await fs.readFile(bashrc, 'utf-8');
179+
if (bashrcContents !== '# Other commands...') {
180+
throw new Error(
181+
'`~/.bashrc` file was incorrectly modified when the user never accepted the' +
182+
` autocompletion prompt. Contents:\n${bashrcContents}`,
183+
);
184+
}
185+
});
186+
187+
// Prompts user again on subsequent execution after accepting prompt but failing to setup.
188+
await mockHome(async (home) => {
189+
const bashrc = path.join(home, '.bashrc');
190+
await fs.writeFile(bashrc, '# Other commands...');
191+
192+
// Make `~/.bashrc` readonly. This is enough for the CLI to verify that the file exists and
193+
// `ng completion` is not in it, but will fail when actually trying to modify the file.
194+
await fs.chmod(bashrc, 0o444);
195+
196+
const err = await execAndCaptureError(
197+
'ng',
198+
['version'],
199+
{
200+
...DEFAULT_ENV,
201+
SHELL: '/bin/bash',
202+
HOME: home,
203+
},
204+
'y' /* stdin: accept prompt */,
205+
);
206+
207+
if (!err.message.includes('Failed to append autocompletion setup')) {
208+
throw new Error(
209+
`Failed first execution did not print the expected error message. Actual:\n${err.message}`,
210+
);
211+
}
212+
213+
// User corrects file permissions between executions.
214+
await fs.chmod(bashrc, 0o777);
215+
216+
const { stdout: stdout2 } = await execWithEnv(
217+
'ng',
218+
['version'],
219+
{
220+
...DEFAULT_ENV,
221+
SHELL: '/bin/bash',
222+
HOME: home,
223+
},
224+
'y' /* stdin: accept prompt */,
225+
);
226+
227+
if (!AUTOCOMPLETION_PROMPT.test(stdout2)) {
228+
throw new Error(
229+
'Subsequent execution after failed autocompletion setup did not prompt again when it should' +
230+
' have.',
231+
);
232+
}
233+
234+
const bashrcContents = await fs.readFile(bashrc, 'utf-8');
235+
if (!bashrcContents.includes('ng completion')) {
236+
throw new Error(
237+
'`~/.bashrc` file does not include `ng completion` after the user never accepted the' +
238+
` autocompletion prompt a second time. Contents:\n${bashrcContents}`,
239+
);
240+
}
86241
});
87242

88243
// Does *not* prompt user for CI executions.

0 commit comments

Comments
 (0)