Skip to content

Commit

Permalink
feat(@angular/cli): add prompt to set up CLI autocompletion
Browse files Browse the repository at this point in the history
When the CLI is executed with any command, it will check if `ng completion script` is already included in the user's `~/.bashrc` file (or similar) 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 script`, 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.

Refs #23003.
  • Loading branch information
dgp1130 committed May 3, 2022
1 parent 022d8c7 commit 4212fb8
Show file tree
Hide file tree
Showing 5 changed files with 323 additions and 0 deletions.
9 changes: 9 additions & 0 deletions packages/angular/cli/src/command-builder/command-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from 'yargs';
import { Parser as yargsParser } from 'yargs/helpers';
import { createAnalytics } from '../analytics/analytics';
import { considerSettingUpAutocompletion } from '../utilities/completion';
import { AngularWorkspace } from '../utilities/config';
import { memoize } from '../utilities/memoize';
import { PackageManagerUtils } from '../utilities/package-manager';
Expand Down Expand Up @@ -123,6 +124,14 @@ export abstract class CommandModule<T extends {} = {}> implements CommandModuleI
camelCasedOptions[yargsParser.camelCase(key)] = value;
}

// Set up autocompletion if appropriate.
const autocompletionExitCode = await considerSettingUpAutocompletion(this.context.logger);
if (autocompletionExitCode !== undefined) {
process.exitCode = autocompletionExitCode;

return;
}

// Gather and report analytics.
const analytics = await this.getAnalytics();
if (this.shouldReportAnalytics) {
Expand Down
108 changes: 108 additions & 0 deletions packages/angular/cli/src/utilities/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,117 @@
* found in the LICENSE file at https://angular.io/license
*/

import { 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 { forceAutocomplete } from '../utilities/environment-options';
import { isTTY } from '../utilities/tty';

/**
* 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
* terminate, otherwise returns `undefined`.
* @returns an exit code if the program should terminate, undefined otherwise.
*/
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.
}

// 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 accepted the prompt, set up autocompletion.
let rcFile: string;
try {
rcFile = await initializeAutocomplete();
} catch (err) {
// Failed to set up autocompeletion, log the error and abort.
logger.error(err.message);

return 1;
}

// Notify the user autocompletion was set up successfully.
logger.info(
`
Appended \`source <(ng completion script)\` to \`${rcFile}\`. Restart your terminal or run the following to autocomplete \`ng\` commands:
${colors.yellow(`source <(ng completion script)`)}
`.trim(),
);

return undefined;
}

async function shouldPromptForAutocompletionSetup(): 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;
}

// Non-interactive and continuous integration systems don't care about autocompletion.
if (!isTTY()) {
return false;
}

// `$HOME` variable is necessary to find RC files to modify.
const home = env['HOME'];
if (!home) {
return false;
}

// Get possible RC files for the current shell.
const shell = env['SHELL'];
if (!shell) {
return false;
}
const rcFiles = getShellRunCommandCandidates(shell, home);
if (!rcFiles) {
return false; // Unknown shell.
}

// Check each RC file if they already use `ng completion script` in any capacity and don't prompt.
for (const rcFile of rcFiles) {
const contents = await fs.readFile(rcFile, 'utf-8').catch(() => undefined);
if (contents?.includes('ng completion script')) {
return false;
}
}

return true;
}

async function promptForAutocompletion(): Promise<boolean> {
// Dynamically load `inquirer` so users don't have to pay the cost of parsing and executing it for
// the 99% of builds that *don't* prompt for autocompletion.
const { prompt } = await import('inquirer');
const { autocomplete } = await prompt<{ autocomplete: boolean }>([
{
name: 'autocomplete',
type: 'confirm',
message: `
Would you like to enable autocompletion? This will set up your terminal so pressing TAB while typing
Angular CLI commands will show possible options and autocomplete arguments. (Enabling autocompletion
will modify configuration files in your home directory.)
`
.split('\n')
.join(' ')
.trim(),
default: true,
},
]);

return autocomplete;
}

/**
* Sets up autocompletion for the user's terminal. This attempts to find the configuration file for
Expand Down
9 changes: 9 additions & 0 deletions packages/angular/cli/src/utilities/environment-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,17 @@ function isEnabled(variable: string | undefined): boolean {
return isPresent(variable) && (variable === '1' || variable.toLowerCase() === 'true');
}

function optional(variable: string | undefined): boolean | undefined {
if (!isPresent(variable)) {
return undefined;
}

return isEnabled(variable);
}

export const analyticsDisabled = isDisabled(process.env['NG_CLI_ANALYTICS']);
export const analyticsShareDisabled = isDisabled(process.env['NG_CLI_ANALYTICS_SHARE']);
export const isCI = isEnabled(process.env['CI']);
export const disableVersionCheck = isEnabled(process.env['NG_DISABLE_VERSION_CHECK']);
export const ngDebug = isEnabled(process.env['NG_DEBUG']);
export const forceAutocomplete = optional(process.env['NG_FORCE_AUTOCOMPLETE']);
3 changes: 3 additions & 0 deletions tests/legacy-cli/e2e/tests/misc/ask-analytics-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export default async function () {
...process.env,
HOME: home,
NG_FORCE_TTY: '1',
NG_FORCE_AUTOCOMPLETE: 'false',
},
'y' /* stdin */,
);
Expand All @@ -29,6 +30,7 @@ export default async function () {
HOME: home,
NG_FORCE_TTY: '1',
NG_CLI_ANALYTICS: 'false',
NG_FORCE_AUTOCOMPLETE: 'false',
});

if (ANALYTICS_PROMPT.test(stdout)) {
Expand All @@ -42,6 +44,7 @@ export default async function () {
...process.env,
HOME: home,
NG_FORCE_TTY: '1',
NG_FORCE_AUTOCOMPLETE: 'false',
});

if (ANALYTICS_PROMPT.test(stdout)) {
Expand Down
194 changes: 194 additions & 0 deletions tests/legacy-cli/e2e/tests/misc/completion-prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
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';

const AUTOCOMPLETION_PROMPT = /Would you like to enable autocompletion\?/;
const DEFAULT_ENV = Object.freeze({
...env,
// Shell should be mocked for each test that cares about it.
SHELL: '/bin/bash',
// Even if the actual test process is run on CI, we're testing user flows which aren't on CI.
CI: undefined,
// Tests run on CI technically don't have a TTY, but the autocompletion prompt requires it, so we
// force a TTY by default.
NG_FORCE_TTY: '1',
// Analytics wants to prompt for a first command as well, but we don't care about that here.
NG_CLI_ANALYTICS: 'false',
});

export default async function () {
// Sets up autocompletion after user accepts a prompt from any command.
await mockHome(async (home) => {
const bashrc = path.join(home, '.bashrc');
await fs.writeFile(bashrc, `# Other content...`);

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

if (!AUTOCOMPLETION_PROMPT.test(stdout)) {
throw new Error('CLI execution did not prompt for autocompletion setup when it should have.');
}

const bashrcContents = await fs.readFile(bashrc, 'utf-8');
if (!bashrcContents.includes('source <(ng completion script)')) {
throw new Error(
'Autocompletion was *not* added to `~/.bashrc` after accepting the setup' + ' prompt.',
);
}

if (!stdout.includes('Appended `source <(ng completion script)`')) {
throw new Error('CLI did not print that it successfully set up autocompletion.');
}
});

// Does nothing if the user rejects the autocompletion prompt.
await mockHome(async (home) => {
const bashrc = path.join(home, '.bashrc');
await fs.writeFile(bashrc, `# Other content...`);

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

if (!AUTOCOMPLETION_PROMPT.test(stdout)) {
throw new Error('CLI execution did not prompt for autocompletion setup when it should have.');
}

const bashrcContents = await fs.readFile(bashrc, 'utf-8');
if (bashrcContents.includes('ng completion')) {
throw new Error(
'Autocompletion was incorrectly added to `~/.bashrc` after refusing the setup' + ' prompt.',
);
}

if (stdout.includes('Appended `source <(ng completion script)`')) {
throw new Error(
'CLI printed that it successfully set up autocompletion when it actually' + " didn't.",
);
}
});

// Does *not* prompt user for CI executions.
{
const { stdout } = await execWithEnv('ng', ['version'], {
...DEFAULT_ENV,
CI: 'true',
NG_FORCE_TTY: undefined,
});

if (AUTOCOMPLETION_PROMPT.test(stdout)) {
throw new Error('CI execution prompted for autocompletion setup but should not have.');
}
}

// Does *not* prompt user for non-TTY executions.
{
const { stdout } = await execWithEnv('ng', ['version'], {
...DEFAULT_ENV,
NG_FORCE_TTY: 'false',
});

if (AUTOCOMPLETION_PROMPT.test(stdout)) {
throw new Error('Non-TTY execution prompted for autocompletion setup but should not have.');
}
}

// Does *not* prompt user for executions without a `$HOME`.
{
const { stdout } = await execWithEnv('ng', ['version'], {
...DEFAULT_ENV,
HOME: undefined,
});

if (AUTOCOMPLETION_PROMPT.test(stdout)) {
throw new Error(
'Execution without a `$HOME` value prompted for autocompletion setup but' +
' should not have.',
);
}
}

// Does *not* prompt user for executions without a `$SHELL`.
{
const { stdout } = await execWithEnv('ng', ['version'], {
...DEFAULT_ENV,
SHELL: undefined,
});

if (AUTOCOMPLETION_PROMPT.test(stdout)) {
throw new Error(
'Execution without a `$SHELL` value prompted for autocompletion setup but' +
' should not have.',
);
}
}

// Does *not* prompt user for executions from unknown shells.
{
const { stdout } = await execWithEnv('ng', ['version'], {
...DEFAULT_ENV,
SHELL: '/usr/bin/unknown',
});

if (AUTOCOMPLETION_PROMPT.test(stdout)) {
throw new Error(
'Execution with an unknown `$SHELL` value prompted for autocompletion setup' +
' but should not have.',
);
}
}

// Does *not* prompt user when an RC file already uses `ng completion`.
await mockHome(async (home) => {
await fs.writeFile(
path.join(home, '.bashrc'),
`
# Some stuff...
source <(ng completion script)
# Some other stuff...
`.trim(),
);

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

if (AUTOCOMPLETION_PROMPT.test(stdout)) {
throw new Error(
"Execution with an existing `ng completion` line in the user's RC file" +
' prompted for autocompletion setup but should not have.',
);
}
});
}

async function mockHome(cb: (home: string) => Promise<void>): Promise<void> {
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'angular-cli-e2e-home-'));

try {
await cb(tempHome);
} finally {
await fs.rm(tempHome, { recursive: true, force: true });
}
}

0 comments on commit 4212fb8

Please sign in to comment.