Skip to content

Commit e7b8f72

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 cfe930b commit e7b8f72

File tree

2 files changed

+215
-5
lines changed

2 files changed

+215
-5
lines changed

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

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

9+
import { json } from '@angular-devkit/core';
910
import { promises as fs } from 'fs';
1011
import { CommandContext } from '../command-builder/command-module';
1112
import { colors } from '../utilities/color';
13+
import { getWorkspace } from '../utilities/config';
1214
import { isTTY } from '../utilities/tty';
1315
import * as path from 'path';
1416
import { env } from 'process';
1517

18+
/** Interface for the autocompletion configuration stored in the global workspace. */
19+
interface CompletionConfig {
20+
/**
21+
* Whether or not the user has been prompted to set up autocompletion. If `true`, should *not*
22+
* prompt them again.
23+
*/
24+
prompted?: boolean;
25+
}
26+
1627
/**
1728
* Checks if it is appropriate to prompt the user to setup autocompletion. If not, does nothing. If
1829
* so prompts and sets up autocompletion for the user. Returns an exit code if the program should
@@ -23,14 +34,27 @@ export async function considerSettingUpAutocompletion(
2334
ctx: CommandContext,
2435
): Promise<number | undefined> {
2536
// Check if we should prompt the user to setup autocompletion.
26-
if (!(await shouldPromptForAutocompletionSetup())) {
27-
return undefined; // Already set up, nothing to do.
37+
const completionConfig = await getCompletionConfig();
38+
if (!(await shouldPromptForAutocompletionSetup(completionConfig))) {
39+
return undefined; // Already set up or prompted previously, nothing to do.
2840
}
2941

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

3660
// User accepted the prompt, set up autocompletion.
@@ -52,10 +76,36 @@ Appended \`source <(ng completion)\` to \`${rcFile}\`. Restart your terminal or
5276
`.trim(),
5377
);
5478

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

58-
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> {
59109
// Force whether or not to prompt for autocomplete to give an easy path for e2e testing to skip.
60110
const forceAutocomplete = env['NG_FORCE_AUTOCOMPLETE'];
61111
if (forceAutocomplete !== undefined) {
@@ -67,6 +117,11 @@ async function shouldPromptForAutocompletionSetup(): Promise<boolean> {
67117
return false;
68118
}
69119

120+
// Skip prompt if the user has already been prompted.
121+
if (config?.prompted) {
122+
return false;
123+
}
124+
70125
// `$HOME` variable is necessary to find RC files to modify.
71126
const home = env['HOME'];
72127
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)