Skip to content

Commit

Permalink
feat(@angular/cli): remember after prompting users to set up autocomp…
Browse files Browse the repository at this point in the history
…letion 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.
  • Loading branch information
dgp1130 committed May 3, 2022
1 parent 4212fb8 commit 2e15df9
Show file tree
Hide file tree
Showing 3 changed files with 217 additions and 6 deletions.
2 changes: 2 additions & 0 deletions packages/angular/cli/src/commands/config/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ export class ConfigCommandModule
'cli.analytics',
'cli.analyticsSharing.tracking',
'cli.analyticsSharing.uuid',

'cli.completion.prompted',
]);

if (
Expand Down
64 changes: 59 additions & 5 deletions packages/angular/cli/src/utilities/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,24 @@
* found in the LICENSE file at https://angular.io/license
*/

import { logging } from '@angular-devkit/core';
import { json, logging } from '@angular-devkit/core';
import { promises as fs } from 'fs';
import * as path from 'path';
import { env } from 'process';
import { colors } from '../utilities/color';
import { getWorkspace } from '../utilities/config';
import { forceAutocomplete } from '../utilities/environment-options';
import { isTTY } from '../utilities/tty';

/** Interface for the autocompletion configuration stored in the global workspace. */
interface CompletionConfig {
/**
* Whether or not the user has been prompted to set up autocompletion. If `true`, should *not*
* prompt them again.
*/
prompted?: boolean;
}

/**
* Checks if it is appropriate to prompt the user to setup autocompletion. If not, does nothing. If
* so prompts and sets up autocompletion for the user. Returns an exit code if the program should
Expand All @@ -24,14 +34,27 @@ export async function considerSettingUpAutocompletion(
logger: logging.Logger,
): Promise<number | undefined> {
// Check if we should prompt the user to setup autocompletion.
if (!(await shouldPromptForAutocompletionSetup())) {
return undefined; // Already set up, nothing to do.
const completionConfig = await getCompletionConfig();
if (!(await shouldPromptForAutocompletionSetup(completionConfig))) {
return undefined; // Already set up or prompted previously, nothing to do.
}

// Prompt the user and record their response.
const shouldSetupAutocompletion = await promptForAutocompletion();
if (!shouldSetupAutocompletion) {
return undefined; // User rejected the prompt and doesn't want autocompletion.
// User rejected the prompt and doesn't want autocompletion.
logger.info(
`
Ok, you won't be prompted again. Should you change your mind, the following command will set up autocompletion for you:
${colors.yellow(`ng completion`)}
`.trim(),
);

// Save configuration to remember that the user was prompted and avoid prompting again.
await setCompletionConfig({ ...completionConfig, prompted: true });

return undefined;
}

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

// Save configuration to remember that the user was prompted.
await setCompletionConfig({ ...completionConfig, prompted: true });

return undefined;
}

async function shouldPromptForAutocompletionSetup(): Promise<boolean> {
async function getCompletionConfig(): Promise<CompletionConfig | undefined> {
const wksp = await getWorkspace('global');

return wksp?.getCli()?.['completion'];
}

async function setCompletionConfig(config: CompletionConfig): Promise<void> {
const wksp = await getWorkspace('global');
if (!wksp) {
throw new Error(`Could not find global workspace`);
}

wksp.extensions['cli'] ??= {};
const cli = wksp.extensions['cli'];
if (!json.isJsonObject(cli)) {
throw new Error(
`Invalid config found at ${wksp.filePath}. \`extensions.cli\` should be an object.`,
);
}
cli.completion = config as json.JsonObject;
await wksp.save();
}

async function shouldPromptForAutocompletionSetup(config?: CompletionConfig): Promise<boolean> {
// Force whether or not to prompt for autocomplete to give an easy path for e2e testing to skip.
if (forceAutocomplete !== undefined) {
return forceAutocomplete;
Expand All @@ -68,6 +117,11 @@ async function shouldPromptForAutocompletionSetup(): Promise<boolean> {
return false;
}

// Skip prompt if the user has already been prompted.
if (config?.prompted) {
return false;
}

// `$HOME` variable is necessary to find RC files to modify.
const home = env['HOME'];
if (!home) {
Expand Down
157 changes: 156 additions & 1 deletion tests/legacy-cli/e2e/tests/misc/completion-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { env } from 'process';
import { execWithEnv } from '../../utils/process';
import { execAndCaptureError, execWithEnv } from '../../utils/process';

const AUTOCOMPLETION_PROMPT = /Would you like to enable autocompletion\?/;
const DEFAULT_ENV = Object.freeze({
Expand Down Expand Up @@ -83,6 +83,161 @@ export default async function () {
'CLI printed that it successfully set up autocompletion when it actually' + " didn't.",
);
}

if (!stdout.includes("Ok, you won't be prompted again.")) {
throw new Error('CLI did not inform the user they will not be prompted again.');
}
});

// Does *not* prompt if the user already accepted (even if they delete the completion config).
await mockHome(async (home) => {
const bashrc = path.join(home, '.bashrc');
await fs.writeFile(bashrc, '# Other commands...');

const { stdout: stdout1 } = await execWithEnv(
'ng',
['version'],
{
...DEFAULT_ENV,
SHELL: '/bin/bash',
HOME: home,
},
'y' /* stdin: accept prompt */,
);

if (!AUTOCOMPLETION_PROMPT.test(stdout1)) {
throw new Error('First execution did not prompt for autocompletion setup.');
}

const bashrcContents1 = await fs.readFile(bashrc, 'utf-8');
if (!bashrcContents1.includes('source <(ng completion script)')) {
throw new Error(
'`~/.bashrc` file was not updated after the user accepted the autocompletion' +
` prompt. Contents:\n${bashrcContents1}`,
);
}

// User modifies their configuration and removes `ng completion`.
await fs.writeFile(bashrc, '# Some new commands...');

const { stdout: stdout2 } = await execWithEnv('ng', ['version'], {
...DEFAULT_ENV,
SHELL: '/bin/bash',
HOME: home,
});

if (AUTOCOMPLETION_PROMPT.test(stdout2)) {
throw new Error(
'Subsequent execution after rejecting autocompletion setup prompted again' +
' when it should not have.',
);
}

const bashrcContents2 = await fs.readFile(bashrc, 'utf-8');
if (bashrcContents2 !== '# Some new commands...') {
throw new Error(
'`~/.bashrc` file was incorrectly modified when using a modified `~/.bashrc`' +
` after previously accepting the autocompletion prompt. Contents:\n${bashrcContents2}`,
);
}
});

// Does *not* prompt if the user already rejected.
await mockHome(async (home) => {
const bashrc = path.join(home, '.bashrc');
await fs.writeFile(bashrc, '# Other commands...');

const { stdout: stdout1 } = await execWithEnv(
'ng',
['version'],
{
...DEFAULT_ENV,
SHELL: '/bin/bash',
HOME: home,
},
'n' /* stdin: reject prompt */,
);

if (!AUTOCOMPLETION_PROMPT.test(stdout1)) {
throw new Error('First execution did not prompt for autocompletion setup.');
}

const { stdout: stdout2 } = await execWithEnv('ng', ['version'], {
...DEFAULT_ENV,
SHELL: '/bin/bash',
HOME: home,
});

if (AUTOCOMPLETION_PROMPT.test(stdout2)) {
throw new Error(
'Subsequent execution after rejecting autocompletion setup prompted again' +
' when it should not have.',
);
}

const bashrcContents = await fs.readFile(bashrc, 'utf-8');
if (bashrcContents !== '# Other commands...') {
throw new Error(
'`~/.bashrc` file was incorrectly modified when the user never accepted the' +
` autocompletion prompt. Contents:\n${bashrcContents}`,
);
}
});

// Prompts user again on subsequent execution after accepting prompt but failing to setup.
await mockHome(async (home) => {
const bashrc = path.join(home, '.bashrc');
await fs.writeFile(bashrc, '# Other commands...');

// Make `~/.bashrc` readonly. This is enough for the CLI to verify that the file exists and
// `ng completion` is not in it, but will fail when actually trying to modify the file.
await fs.chmod(bashrc, 0o444);

const err = await execAndCaptureError(
'ng',
['version'],
{
...DEFAULT_ENV,
SHELL: '/bin/bash',
HOME: home,
},
'y' /* stdin: accept prompt */,
);

if (!err.message.includes('Failed to append autocompletion setup')) {
throw new Error(
`Failed first execution did not print the expected error message. Actual:\n${err.message}`,
);
}

// User corrects file permissions between executions.
await fs.chmod(bashrc, 0o777);

const { stdout: stdout2 } = await execWithEnv(
'ng',
['version'],
{
...DEFAULT_ENV,
SHELL: '/bin/bash',
HOME: home,
},
'y' /* stdin: accept prompt */,
);

if (!AUTOCOMPLETION_PROMPT.test(stdout2)) {
throw new Error(
'Subsequent execution after failed autocompletion setup did not prompt again when it should' +
' have.',
);
}

const bashrcContents = await fs.readFile(bashrc, 'utf-8');
if (!bashrcContents.includes('ng completion script')) {
throw new Error(
'`~/.bashrc` file does not include `ng completion` after the user never accepted the' +
` autocompletion prompt a second time. Contents:\n${bashrcContents}`,
);
}
});

// Does *not* prompt user for CI executions.
Expand Down

0 comments on commit 2e15df9

Please sign in to comment.