fix(@angular/cli): show more actionable error when command is ran in wrong scope

Currently, we don't register all available commands. For instance, when the CLI is ran inside a workspace the `new` command is not registered. Thus, this will cause a confusing error message when `ng new` is ran inside a workspace.

Example:
```
$ ng new
Error: Unknown command. Did you mean e?
```

With this commit we change this by registering all the commands and valid the command scope during the command building phase which is only triggered once the command is invoked but prior to the execution phase.
This commit is contained in:
Alan Agius 2022-06-13 09:40:04 +00:00 committed by Alan Agius
parent 14929e28b1
commit 82ec1af4e1
15 changed files with 107 additions and 75 deletions

View File

@ -37,7 +37,7 @@ export abstract class ArchitectBaseCommandModule<T extends object>
extends CommandModule<T>
implements CommandModuleImplementation<T>
{
static override scope = CommandScope.In;
override scope = CommandScope.In;
protected override shouldReportAnalytics = false;
protected readonly missingTargetChoices: MissingTargetChoice[] | undefined;

View File

@ -58,6 +58,8 @@ export type OtherOptions = Record<string, unknown>;
export interface CommandModuleImplementation<T extends {} = {}>
extends Omit<YargsCommandModule<{}, T>, 'builder' | 'handler'> {
/** Scope in which the command can be executed in. */
scope: CommandScope;
/** Path used to load the long description for the command in JSON help text. */
longDescriptionPath?: string;
/** Object declaring the options the command accepts, or a function accepting and returning a yargs instance. */
@ -77,7 +79,7 @@ export abstract class CommandModule<T extends {} = {}> implements CommandModuleI
abstract readonly describe: string | false;
abstract readonly longDescriptionPath?: string;
protected readonly shouldReportAnalytics: boolean = true;
static scope = CommandScope.Both;
readonly scope: CommandScope = CommandScope.Both;
private readonly optionsWithAnalytics = new Map<string, number>();

View File

@ -112,14 +112,6 @@ export async function runCommand(args: string[], logger: logging.Logger): Promis
let localYargs = yargs(args);
for (const CommandModule of COMMANDS) {
if (!jsonHelp) {
// Skip scope validation when running with '--json-help' since it's easier to generate the output for all commands this way.
const scope = CommandModule.scope;
if ((scope === CommandScope.In && !workspace) || (scope === CommandScope.Out && workspace)) {
continue;
}
}
localYargs = addCommandModuleToYargs(localYargs, CommandModule, context);
}
@ -157,7 +149,7 @@ export async function runCommand(args: string[], logger: logging.Logger): Promis
'deprecated: %s': colors.yellow('deprecated:') + ' %s',
'Did you mean %s?': 'Unknown command. Did you mean %s?',
})
.epilogue(colors.gray(getEpilogue(!!workspace)))
.epilogue(colors.gray('For more information, see https://angular.io/cli/.\n'))
.demandCommand(1, demandCommandFailureMessage)
.recommendCommands()
.middleware(normalizeOptionsMiddleware)
@ -176,18 +168,3 @@ export async function runCommand(args: string[], logger: logging.Logger): Promis
return process.exitCode ?? 0;
}
function getEpilogue(isInsideWorkspace: boolean): string {
let message: string;
if (isInsideWorkspace) {
message =
'The above commands are available when running the Angular CLI inside a workspace.' +
'More commands are available when running outside a workspace.\n';
} else {
message =
'The above commands are available when running the Angular CLI outside a workspace.' +
'More commands are available when running inside a workspace.\n';
}
return message + 'For more information, see https://angular.io/cli/.\n';
}

View File

@ -48,7 +48,7 @@ export abstract class SchematicsCommandModule
extends CommandModule<SchematicsCommandArgs>
implements CommandModuleImplementation<SchematicsCommandArgs>
{
static override scope = CommandScope.In;
override scope = CommandScope.In;
protected readonly allowPrivateSchematics: boolean = false;
protected override readonly shouldReportAnalytics = false;

View File

@ -7,7 +7,13 @@
*/
import { Argv } from 'yargs';
import { CommandContext, CommandModule, CommandModuleImplementation } from '../command-module';
import {
CommandContext,
CommandModule,
CommandModuleError,
CommandModuleImplementation,
CommandScope,
} from '../command-module';
export const demandCommandFailureMessage = `You need to specify a command before moving on. Use '--help' to view the available commands.`;
@ -18,7 +24,14 @@ export function addCommandModuleToYargs<
},
>(localYargs: Argv<T>, commandModule: U, context: CommandContext): Argv<T> {
const cmd = new commandModule(context);
const describe = context.args.options.jsonHelp ? cmd.fullDescribe : cmd.describe;
const {
args: {
options: { jsonHelp },
},
workspace,
} = context;
const describe = jsonHelp ? cmd.fullDescribe : cmd.describe;
return localYargs.command({
command: cmd.command,
@ -28,7 +41,23 @@ export function addCommandModuleToYargs<
// Therefore, we get around this by adding a complex object as a string which we later parse when generating the help files.
typeof describe === 'object' ? JSON.stringify(describe) : describe,
deprecated: cmd.deprecated,
builder: (argv) => cmd.builder(argv) as Argv<T>,
builder: (argv) => {
// Skip scope validation when running with '--json-help' since it's easier to generate the output for all commands this way.
const isInvalidScope =
!jsonHelp &&
((cmd.scope === CommandScope.In && !workspace) ||
(cmd.scope === CommandScope.Out && workspace));
if (isInvalidScope) {
throw new CommandModuleError(
`This command is not available when running the Angular CLI ${
workspace ? 'inside' : 'outside'
} a workspace.`,
);
}
return cmd.builder(argv) as Argv<T>;
},
handler: (args) => cmd.handler(args),
});
}

View File

@ -19,7 +19,7 @@ export class CacheCleanModule extends CommandModule implements CommandModuleImpl
command = 'clean';
describe = 'Deletes persistent disk cache from disk.';
longDescriptionPath: string | undefined;
static override scope = CommandScope.In;
override scope = CommandScope.In;
builder(localYargs: Argv): Argv {
return localYargs.strict();

View File

@ -26,7 +26,7 @@ export class CacheCommandModule extends CommandModule implements CommandModuleIm
command = 'cache';
describe = 'Configure persistent disk cache and retrieve cache statistics.';
longDescriptionPath = join(__dirname, 'long-description.md');
static override scope = CommandScope.In;
override scope = CommandScope.In;
builder(localYargs: Argv): Argv {
const subcommands = [

View File

@ -22,7 +22,7 @@ export class CacheInfoCommandModule extends CommandModule implements CommandModu
command = 'info';
describe = 'Prints persistent disk cache configuration and statistics in the console.';
longDescriptionPath?: string | undefined;
static override scope = CommandScope.In;
override scope = CommandScope.In;
builder(localYargs: Argv): Argv {
return localYargs.strict();

View File

@ -19,7 +19,7 @@ export class CacheDisableModule extends CommandModule implements CommandModuleIm
aliases = 'off';
describe = 'Disables persistent disk cache for all projects in the workspace.';
longDescriptionPath: string | undefined;
static override scope = CommandScope.In;
override scope = CommandScope.In;
builder(localYargs: Argv): Argv {
return localYargs;
@ -35,7 +35,7 @@ export class CacheEnableModule extends CommandModule implements CommandModuleImp
aliases = 'on';
describe = 'Enables disk cache for all projects in the workspace.';
longDescriptionPath: string | undefined;
static override scope = CommandScope.In;
override scope = CommandScope.In;
builder(localYargs: Argv): Argv {
return localYargs;

View File

@ -29,7 +29,7 @@ export class NewCommandModule
implements CommandModuleImplementation<NewCommandArgs>
{
private readonly schematicName = 'ng-new';
static override scope = CommandScope.Out;
override scope = CommandScope.Out;
protected override allowPrivateSchematics = true;
command = 'new [name]';

View File

@ -26,7 +26,7 @@ export class RunCommandModule
extends ArchitectBaseCommandModule<RunCommandArgs>
implements CommandModuleImplementation<RunCommandArgs>
{
static override scope = CommandScope.In;
override scope = CommandScope.In;
command = 'run <target>';
describe =

View File

@ -60,7 +60,7 @@ const ANGULAR_PACKAGES_REGEXP = /^@(?:angular|nguniversal)\//;
const UPDATE_SCHEMATIC_COLLECTION = path.join(__dirname, 'schematic/collection.json');
export class UpdateCommandModule extends CommandModule<UpdateCommandArgs> {
static override scope = CommandScope.In;
override scope = CommandScope.In;
protected override shouldReportAnalytics = false;
command = 'update [packages..]';

View File

@ -0,0 +1,49 @@
import { homedir } from 'os';
import { silentNg } from '../../utils/process';
import { expectToFail } from '../../utils/utils';
export default async function () {
const originalCwd = process.cwd();
try {
// Run inside workspace
await silentNg('generate', 'component', 'foo', '--dry-run');
// The version command can be run in and outside of a workspace.
await silentNg('version');
const { message: ngNewFailure } = await expectToFail(() =>
silentNg('new', 'proj-name', '--dry-run'),
);
if (
!ngNewFailure.includes(
'This command is not available when running the Angular CLI inside a workspace.',
)
) {
throw new Error('ng new should have failed when ran inside a workspace.');
}
// Chnage CWD to run outside a workspace.
process.chdir(homedir());
// ng generate can only be ran inside.
const { message: ngGenerateFailure } = await expectToFail(() =>
silentNg('generate', 'component', 'foo', '--dry-run'),
);
if (
!ngGenerateFailure.includes(
'This command is not available when running the Angular CLI outside a workspace.',
)
) {
throw new Error('ng generate should have failed when ran outside a workspace.');
}
// ng new can only be ran outside of a workspace
await silentNg('new', 'proj-name', '--dry-run');
// The version command can be run in and outside of a workspace.
await silentNg('version');
} finally {
process.chdir(originalCwd);
}
}

View File

@ -1,21 +0,0 @@
import * as os from 'os';
import { join } from 'path';
import { writeFile, deleteFile } from '../../utils/fs';
import { ng } from '../../utils/process';
import { expectToFail } from '../../utils/utils';
export default function () {
const homedir = os.homedir();
const globalConfigPath = join(homedir, '.angular-config.json');
return (
Promise.resolve()
.then(() => writeFile(globalConfigPath, '{"version":1}'))
.then(() => process.chdir(homedir))
.then(() => ng('new', 'proj-name', '--dry-run'))
.then(() => deleteFile(globalConfigPath))
// Test that we cannot create a project inside another project.
.then(() => writeFile(join(homedir, '.angular.json'), '{"version":1}'))
.then(() => expectToFail(() => ng('new', 'proj-name', '--dry-run')))
.then(() => deleteFile(join(homedir, '.angular.json')))
);
}

View File

@ -1,19 +1,15 @@
import { silentNg } from '../../../utils/process';
export default function () {
return Promise.resolve()
.then(() => silentNg('--help'))
.then(({ stdout }) => {
if (stdout.match(/(easter-egg)|(ng make-this-awesome)|(ng init)/)) {
export default async function () {
const { stdout: stdoutNew } = await silentNg('--help');
if (/(easter-egg)|(ng make-this-awesome)|(ng init)/.test(stdoutNew)) {
throw new Error(
'Expected to not match "(easter-egg)|(ng make-this-awesome)|(ng init)" in help output.',
);
}
})
.then(() => silentNg('--help', 'new'))
.then(({ stdout }) => {
if (stdout.match(/--link-cli/)) {
throw new Error('Expected to not match "--link-cli" in help output.');
const { stdout: ngGenerate } = await silentNg('--help', 'generate', 'component');
if (ngGenerate.includes('--path')) {
throw new Error('Expected to not match "--path" in help output.');
}
});
}