Skip to content

Commit 2e15df9

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. Refs #23003.
1 parent 4212fb8 commit 2e15df9

File tree

3 files changed

+217
-6
lines changed

3 files changed

+217
-6
lines changed

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

+2
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ export class ConfigCommandModule
106106
'cli.analytics',
107107
'cli.analyticsSharing.tracking',
108108
'cli.analyticsSharing.uuid',
109+
110+
'cli.completion.prompted',
109111
]);
110112

111113
if (

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

+59-5
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,24 @@
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 { forceAutocomplete } from '../utilities/environment-options';
1516
import { isTTY } from '../utilities/tty';
1617

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+
1727
/**
1828
* Checks if it is appropriate to prompt the user to setup autocompletion. If not, does nothing. If
1929
* so prompts and sets up autocompletion for the user. Returns an exit code if the program should
@@ -24,14 +34,27 @@ export async function considerSettingUpAutocompletion(
2434
logger: logging.Logger,
2535
): Promise<number | undefined> {
2636
// Check if we should prompt the user to setup autocompletion.
27-
if (!(await shouldPromptForAutocompletionSetup())) {
28-
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.
2940
}
3041

3142
// Prompt the user and record their response.
3243
const shouldSetupAutocompletion = await promptForAutocompletion();
3344
if (!shouldSetupAutocompletion) {
34-
return undefined; // User rejected the prompt and doesn't want autocompletion.
45+
// User rejected the prompt and doesn't want autocompletion.
46+
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`)}
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;
3558
}
3659

3760
// User accepted the prompt, set up autocompletion.
@@ -54,10 +77,36 @@ Appended \`source <(ng completion script)\` to \`${rcFile}\`. Restart your termi
5477
`.trim(),
5578
);
5679

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

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

+156-1
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 script)')) {
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 script')) {
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)