diff --git a/packages/angular/cli/src/command-builder/architect-base-command-module.ts b/packages/angular/cli/src/command-builder/architect-base-command-module.ts index b0ec9d451910..7900a8b0bfbd 100644 --- a/packages/angular/cli/src/command-builder/architect-base-command-module.ts +++ b/packages/angular/cli/src/command-builder/architect-base-command-module.ts @@ -7,7 +7,10 @@ */ import { Architect, Target } from '@angular-devkit/architect'; -import { WorkspaceNodeModulesArchitectHost } from '@angular-devkit/architect/node'; +import { + NodeModulesBuilderInfo, + WorkspaceNodeModulesArchitectHost, +} from '@angular-devkit/architect/node'; import { json } from '@angular-devkit/core'; import { spawnSync } from 'child_process'; import { existsSync } from 'fs'; @@ -100,9 +103,15 @@ export abstract class ArchitectBaseCommandModule protected async getArchitectTargetOptions(target: Target): Promise { const architectHost = this.getArchitectHost(); - const builderConf = await architectHost.getBuilderNameForTarget(target); + let builderConf: string; + + try { + builderConf = await architectHost.getBuilderNameForTarget(target); + } catch { + return []; + } - let builderDesc; + let builderDesc: NodeModulesBuilderInfo; try { builderDesc = await architectHost.resolveBuilder(builderConf); } catch (e) { diff --git a/packages/angular/cli/src/command-builder/architect-command-module.ts b/packages/angular/cli/src/command-builder/architect-command-module.ts index 4b0ae4b64e1f..a58edcccc06d 100644 --- a/packages/angular/cli/src/command-builder/architect-command-module.ts +++ b/packages/angular/cli/src/command-builder/architect-command-module.ts @@ -29,10 +29,15 @@ export abstract class ArchitectCommandModule abstract readonly multiTarget: boolean; async builder(argv: Argv): Promise> { + const project = this.getArchitectProject(); + const { jsonHelp, getYargsCompletions, help } = this.context.args.options; + const localYargs: Argv = argv .positional('project', { describe: 'The name of the project to build. Can be an application or a library.', type: 'string', + // Hide choices from JSON help so that we don't display them in AIO. + choices: jsonHelp ? undefined : this.getProjectChoices(), }) .option('configuration', { describe: @@ -42,10 +47,15 @@ export abstract class ArchitectCommandModule `For more information, see https://angular.io/guide/workspace-config#alternate-build-configurations.`, alias: 'c', type: 'string', + // Show only in when using --help and auto completion because otherwise comma seperated configuration values will be invalid. + // Also, hide choices from JSON help so that we don't display them in AIO. + choices: + (getYargsCompletions || help) && !jsonHelp && project + ? this.getConfigurationChoices(project) + : undefined, }) .strict(); - const project = this.getArchitectProject(); if (!project) { return localYargs; } @@ -92,11 +102,7 @@ export abstract class ArchitectCommandModule const [, projectName] = this.context.args.positional; if (projectName) { - if (!workspace.projects.has(projectName)) { - throw new CommandModuleError(`Project '${projectName}' does not exist.`); - } - - return projectName; + return workspace.projects.has(projectName) ? projectName : undefined; } const target = this.getArchitectTarget(); @@ -136,4 +142,24 @@ export abstract class ArchitectCommandModule return undefined; } + + /** @returns a sorted list of project names to be used for auto completion. */ + private getProjectChoices(): string[] | undefined { + const { workspace } = this.context; + + return workspace ? [...workspace.projects.keys()].sort() : undefined; + } + + /** @returns a sorted list of configuration names to be used for auto completion. */ + private getConfigurationChoices(project: string): string[] | undefined { + const projectDefinition = this.context.workspace?.projects.get(project); + if (!projectDefinition) { + return undefined; + } + + const target = this.getArchitectTarget(); + const configurations = projectDefinition.targets.get(target)?.configurations; + + return configurations ? Object.keys(configurations).sort() : undefined; + } } diff --git a/packages/angular/cli/src/command-builder/command-module.ts b/packages/angular/cli/src/command-builder/command-module.ts index 2d9ec6193715..203e9351fb36 100644 --- a/packages/angular/cli/src/command-builder/command-module.ts +++ b/packages/angular/cli/src/command-builder/command-module.ts @@ -48,6 +48,7 @@ export interface CommandContext { options: { help: boolean; jsonHelp: boolean; + getYargsCompletions: boolean; } & Record; }; } diff --git a/packages/angular/cli/src/command-builder/command-runner.ts b/packages/angular/cli/src/command-builder/command-runner.ts index 822adb641e4c..a1d77bbfaa88 100644 --- a/packages/angular/cli/src/command-builder/command-runner.ts +++ b/packages/angular/cli/src/command-builder/command-runner.ts @@ -13,6 +13,7 @@ import { AddCommandModule } from '../commands/add/cli'; import { AnalyticsCommandModule } from '../commands/analytics/cli'; import { BuildCommandModule } from '../commands/build/cli'; import { CacheCommandModule } from '../commands/cache/cli'; +import { CompletionCommandModule } from '../commands/completion/cli'; import { ConfigCommandModule } from '../commands/config/cli'; import { DeployCommandModule } from '../commands/deploy/cli'; import { DocCommandModule } from '../commands/doc/cli'; @@ -54,6 +55,7 @@ const COMMANDS = [ UpdateCommandModule, RunCommandModule, CacheCommandModule, + CompletionCommandModule, ].sort(); // Will be sorted by class name. const yargsParser = Parser as unknown as typeof Parser.default; @@ -61,11 +63,18 @@ const yargsParser = Parser as unknown as typeof Parser.default; export async function runCommand(args: string[], logger: logging.Logger): Promise { const { $0, - _: positional, + _, help = false, jsonHelp = false, + getYargsCompletions = false, ...rest - } = yargsParser(args, { boolean: ['help', 'json-help'], alias: { 'collection': 'c' } }); + } = yargsParser(args, { + boolean: ['help', 'json-help', 'get-yargs-completions'], + alias: { 'collection': 'c' }, + }); + + // When `getYargsCompletions` is true the scriptName 'ng' at index 0 is not removed. + const positional = getYargsCompletions ? _.slice(1) : _; let workspace: AngularWorkspace | undefined; let globalConfiguration: AngularWorkspace | undefined; @@ -93,6 +102,7 @@ export async function runCommand(args: string[], logger: logging.Logger): Promis options: { help, jsonHelp, + getYargsCompletions, ...rest, }, }, @@ -111,9 +121,16 @@ export async function runCommand(args: string[], logger: logging.Logger): Promis localYargs = addCommandModuleToYargs(localYargs, CommandModule, context); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const usageInstance = (localYargs as any).getInternalMethods().getUsageInstance(); if (jsonHelp) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (localYargs as any).getInternalMethods().getUsageInstance().help = () => jsonHelpUsage(); + usageInstance.help = () => jsonHelpUsage(); + } + + if (getYargsCompletions) { + // When in auto completion mode avoid printing description as it causes a slugish + // experience when there are a large set of options. + usageInstance.getDescriptions = () => ({}); } await localYargs diff --git a/packages/angular/cli/src/commands/completion/cli.ts b/packages/angular/cli/src/commands/completion/cli.ts new file mode 100644 index 000000000000..13a74ef6da1b --- /dev/null +++ b/packages/angular/cli/src/commands/completion/cli.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { join } from 'path'; +import yargs, { Argv } from 'yargs'; +import { CommandModule, CommandModuleImplementation } from '../../command-builder/command-module'; + +export class CompletionCommandModule extends CommandModule implements CommandModuleImplementation { + command = 'completion'; + describe = 'Generate a bash and zsh real-time type-ahead autocompletion script.'; + longDescriptionPath = join(__dirname, 'long-description.md'); + + builder(localYargs: Argv): Argv { + return localYargs; + } + + run(): void { + yargs.showCompletionScript(); + } +} diff --git a/packages/angular/cli/src/commands/completion/long-description.md b/packages/angular/cli/src/commands/completion/long-description.md new file mode 100644 index 000000000000..59f8e107b58a --- /dev/null +++ b/packages/angular/cli/src/commands/completion/long-description.md @@ -0,0 +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`. diff --git a/packages/angular/cli/src/commands/run/cli.ts b/packages/angular/cli/src/commands/run/cli.ts index 97b06f3f5a0c..3d6f31fdfa84 100644 --- a/packages/angular/cli/src/commands/run/cli.ts +++ b/packages/angular/cli/src/commands/run/cli.ts @@ -11,7 +11,6 @@ import { join } from 'path'; import { Argv } from 'yargs'; import { ArchitectBaseCommandModule } from '../../command-builder/architect-base-command-module'; import { - CommandModule, CommandModuleError, CommandModuleImplementation, CommandScope, @@ -35,11 +34,16 @@ export class RunCommandModule longDescriptionPath = join(__dirname, 'long-description.md'); async builder(argv: Argv): Promise> { + const { jsonHelp, getYargsCompletions, help } = this.context.args.options; + const localYargs: Argv = argv .positional('target', { describe: 'The Architect target to run.', type: 'string', demandOption: true, + // Show only in when using --help and auto completion because otherwise comma seperated configuration values will be invalid. + // Also, hide choices from JSON help so that we don't display them in AIO. + choices: (getYargsCompletions || help) && !jsonHelp ? this.getTargetChoices() : undefined, }) .strict(); @@ -78,4 +82,29 @@ export class RunCommandModule configuration, }; } + + /** @returns a sorted list of target specifiers to be used for auto completion. */ + private getTargetChoices(): string[] | undefined { + if (!this.context.workspace) { + return; + } + + const targets = []; + for (const [projectName, project] of this.context.workspace.projects) { + for (const [targetName, target] of project.targets) { + const currentTarget = `${projectName}:${targetName}`; + targets.push(currentTarget); + + if (!target.configurations) { + continue; + } + + for (const configName of Object.keys(target.configurations)) { + targets.push(`${currentTarget}:${configName}`); + } + } + } + + return targets.sort(); + } } diff --git a/tests/legacy-cli/e2e/tests/misc/browsers.ts b/tests/legacy-cli/e2e/tests/misc/browsers.ts index 370490644396..c6a01b2552ba 100644 --- a/tests/legacy-cli/e2e/tests/misc/browsers.ts +++ b/tests/legacy-cli/e2e/tests/misc/browsers.ts @@ -1,7 +1,7 @@ import express from 'express'; import * as path from 'path'; import { copyProjectAsset } from '../../utils/assets'; -import { appendToFile, replaceInFile } from '../../utils/fs'; +import { replaceInFile } from '../../utils/fs'; import { ng } from '../../utils/process'; export default async function () { diff --git a/tests/legacy-cli/e2e/tests/misc/completion.ts b/tests/legacy-cli/e2e/tests/misc/completion.ts new file mode 100644 index 000000000000..bbcce970329c --- /dev/null +++ b/tests/legacy-cli/e2e/tests/misc/completion.ts @@ -0,0 +1,51 @@ +import { execAndWaitForOutputToMatch } from '../../utils/process'; + +export default async function () { + // ng build + await execAndWaitForOutputToMatch('ng', ['--get-yargs-completions', 'b', ''], /test-project/); + await execAndWaitForOutputToMatch('ng', ['--get-yargs-completions', 'build', ''], /test-project/); + await execAndWaitForOutputToMatch('ng', ['--get-yargs-completions', 'build', '--a'], /--aot/); + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'build', '--configuration'], + /production/, + ); + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'b', '--configuration'], + /production/, + ); + + // ng run + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'run', ''], + /test-project\:build\:development/, + ); + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'run', ''], + /test-project\:build/, + ); + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'run', ''], + /test-project\:test/, + ); + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'run', 'test-project:build'], + /test-project\:build\:development/, + ); + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'run', 'test-project:'], + /test-project\:test/, + ); + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'run', 'test-project:build'], + // does not include 'test-project:serve' + /^((?!:serve).)*$/, + ); +}