Skip to content

Commit f2f3b4e

Browse files
committed
feat(@angular/cli): make ng completion set up CLI autocompletion by modifying .bashrc files
`ng completion` is changed to set up Angular CLI autocompletion for the current user by appending `source <(ng completion script)` to their `~/.bashrc`, `~/.bash_profile`, `~/.zshrc`, `~/.zsh_profile`, or `~/.profile`. The previous `ng completion` functionality (printing Yargs autocompletion shell script) is moved to `ng completion script` because most users won't need to worry about this, so we're prioritizing `ng completion` as the part most users will actually type. I couldn't find a good way of testing an error when writing to the `~/.bashrc` file. Since the CLI checks if it has access to the file first, that would usually fail in any circumstance when the file can't be written to. Things could change in between (user modifies file permissions or disk runs out of storage), but there's no easy hook to simulate this change in the e2e test. Refs #23003.
1 parent b5bbe7c commit f2f3b4e

File tree

6 files changed

+537
-66
lines changed

6 files changed

+537
-66
lines changed

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

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,51 @@
88

99
import { join } from 'path';
1010
import yargs, { Argv } from 'yargs';
11-
import { CommandModule, CommandModuleImplementation } from '../../command-builder/command-module';
11+
import {
12+
CommandModule,
13+
CommandModuleImplementation,
14+
CommandScope,
15+
} from '../../command-builder/command-module';
16+
import { addCommandModuleToYargs } from '../../command-builder/utilities/command';
17+
import { colors } from '../../utilities/color';
18+
import { initializeAutocomplete } from '../../utilities/completion';
1219

1320
export class CompletionCommandModule extends CommandModule implements CommandModuleImplementation {
1421
command = 'completion';
15-
describe = 'Generate a bash and zsh real-time type-ahead autocompletion script.';
22+
describe = 'Set up Angular CLI autocompletion for your terminal.';
1623
longDescriptionPath = join(__dirname, 'long-description.md');
1724

25+
builder(localYargs: Argv): Argv {
26+
return addCommandModuleToYargs(localYargs, CompletionScriptCommandModule, this.context);
27+
}
28+
29+
async run(): Promise<number> {
30+
let rcFile: string;
31+
try {
32+
rcFile = await initializeAutocomplete();
33+
} catch (err) {
34+
this.context.logger.error(err.message);
35+
36+
return 1;
37+
}
38+
39+
this.context.logger.info(
40+
`
41+
Appended \`source <(ng completion script)\` to \`${rcFile}\`. Restart your terminal or run the following to autocomplete \`ng\` commands:
42+
43+
${colors.yellow('source <(ng completion script)')}
44+
`.trim(),
45+
);
46+
47+
return 0;
48+
}
49+
}
50+
51+
class CompletionScriptCommandModule extends CommandModule implements CommandModuleImplementation {
52+
command = 'script';
53+
describe = 'Generate a bash and zsh real-time type-ahead autocompletion script.';
54+
longDescriptionPath = undefined;
55+
1856
builder(localYargs: Argv): Argv {
1957
return localYargs;
2058
}
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1-
To enable bash and zsh real-time type-ahead autocompletion, copy and paste the generated script to your `.bashrc`, `.bash_profile`, `.zshrc` or `.zsh_profile`.
1+
To enable Bash and Zsh real-time type-ahead autocompletion, run
2+
`ng completion` and restart your terminal.
3+
4+
Alternatively, append `source <(ng completion script)` to the appropriate `.bashrc`,
5+
`.bash_profile`, `.zshrc`, `.zsh_profile`, or `.profile` file.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import { promises as fs } from 'fs';
10+
import * as path from 'path';
11+
import { env } from 'process';
12+
13+
/**
14+
* Sets up autocompletion for the user's terminal. This attempts to find the configuration file for
15+
* the current shell (`.bashrc`, `.zshrc`, etc.) and append a command which enables autocompletion
16+
* for the Angular CLI. Supports only Bash and Zsh. Returns whether or not it was successful.
17+
* @return The full path of the configuration file modified.
18+
*/
19+
export async function initializeAutocomplete(): Promise<string> {
20+
// Get the currently active `$SHELL` and `$HOME` environment variables.
21+
const shell = env['SHELL'];
22+
if (!shell) {
23+
throw new Error(
24+
'`$SHELL` environment variable not set. Angular CLI autocompletion only supports Bash or' +
25+
' Zsh.',
26+
);
27+
}
28+
const home = env['HOME'];
29+
if (!home) {
30+
throw new Error(
31+
'`$HOME` environment variable not set. Setting up autocompletion modifies configuration files' +
32+
' in the home directory and must be set.',
33+
);
34+
}
35+
36+
// Get all the files we can add `ng completion` to which apply to the user's `$SHELL`.
37+
const runCommandCandidates = getShellRunCommandCandidates(shell, home);
38+
if (!runCommandCandidates) {
39+
throw new Error(
40+
`Unknown \`$SHELL\` environment variable value (${shell}). Angular CLI autocompletion only supports Bash or Zsh.`,
41+
);
42+
}
43+
44+
// Get the first file that already exists or fallback to a new file of the first candidate.
45+
const candidates = await Promise.allSettled(
46+
runCommandCandidates.map((rcFile) => fs.access(rcFile).then(() => rcFile)),
47+
);
48+
const rcFile =
49+
candidates.find(
50+
(result): result is PromiseFulfilledResult<string> => result.status === 'fulfilled',
51+
)?.value ?? runCommandCandidates[0];
52+
53+
// Append Angular autocompletion setup to RC file.
54+
try {
55+
await fs.appendFile(
56+
rcFile,
57+
'\n\n# Load Angular CLI autocompletion.\nsource <(ng completion script)\n',
58+
);
59+
} catch (err) {
60+
throw new Error(`Failed to append autocompletion setup to \`${rcFile}\`:\n${err.message}`);
61+
}
62+
63+
return rcFile;
64+
}
65+
66+
/** Returns an ordered list of possibile candidates of RC files used by the given shell. */
67+
function getShellRunCommandCandidates(shell: string, home: string): string[] | undefined {
68+
if (shell.toLowerCase().includes('bash')) {
69+
return ['.bashrc', '.bash_profile', '.profile'].map((file) => path.join(home, file));
70+
} else if (shell.toLowerCase().includes('zsh')) {
71+
return ['.zshrc', '.zsh_profile', '.profile'].map((file) => path.join(home, file));
72+
} else {
73+
return undefined;
74+
}
75+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { execAndWaitForOutputToMatch } from '../../utils/process';
2+
3+
export default async function () {
4+
// ng build
5+
await execAndWaitForOutputToMatch(
6+
'ng',
7+
['--get-yargs-completions', 'ng', 'b', ''],
8+
/test-project/,
9+
);
10+
await execAndWaitForOutputToMatch(
11+
'ng',
12+
['--get-yargs-completions', 'ng', 'build', ''],
13+
/test-project/,
14+
);
15+
await execAndWaitForOutputToMatch(
16+
'ng',
17+
['--get-yargs-completions', 'ng', 'build', '--a'],
18+
/--aot/,
19+
);
20+
await execAndWaitForOutputToMatch(
21+
'ng',
22+
['--get-yargs-completions', 'ng', 'build', '--configuration'],
23+
/production/,
24+
);
25+
await execAndWaitForOutputToMatch(
26+
'ng',
27+
['--get-yargs-completions', 'ng', 'b', '--configuration'],
28+
/production/,
29+
);
30+
31+
// ng run
32+
await execAndWaitForOutputToMatch(
33+
'ng',
34+
['--get-yargs-completions', 'ng', 'run', ''],
35+
/test-project\\:build\\:development/,
36+
);
37+
await execAndWaitForOutputToMatch(
38+
'ng',
39+
['--get-yargs-completions', 'ng', 'run', ''],
40+
/test-project\\:build/,
41+
);
42+
await execAndWaitForOutputToMatch(
43+
'ng',
44+
['--get-yargs-completions', 'ng', 'run', ''],
45+
/test-project\\:test/,
46+
);
47+
await execAndWaitForOutputToMatch(
48+
'ng',
49+
['--get-yargs-completions', 'ng', 'run', 'test-project:build'],
50+
/test-project\\:build\\:development/,
51+
);
52+
await execAndWaitForOutputToMatch(
53+
'ng',
54+
['--get-yargs-completions', 'ng', 'run', 'test-project:'],
55+
/test-project\\:test/,
56+
);
57+
await execAndWaitForOutputToMatch(
58+
'ng',
59+
['--get-yargs-completions', 'ng', 'run', 'test-project:build'],
60+
// does not include 'test-project:serve'
61+
/^((?!:serve).)*$/,
62+
);
63+
}

0 commit comments

Comments
 (0)