feat(@angular/cli): add subcommand to options

SubCommands are not tied to the option that triggers them. They
contain a subset of a CommandDescription interface, with at least
a short and long description and usage notes. These are generated
from the subcommand schema (e.g. schematics in case of generate).
This commit is contained in:
Hans 2018-09-18 12:28:53 -07:00
parent 6622aa9d1a
commit 34818b0346
6 changed files with 120 additions and 75 deletions

View File

@ -8,9 +8,9 @@
// tslint:disable:no-global-tslint-disable no-any // tslint:disable:no-global-tslint-disable no-any
import { terminal } from '@angular-devkit/core'; import { terminal } from '@angular-devkit/core';
import { Arguments, Option } from '../models/interface'; import { Arguments, SubCommandDescription } from '../models/interface';
import { SchematicCommand } from '../models/schematic-command'; import { SchematicCommand } from '../models/schematic-command';
import { parseJsonSchemaToOptions } from '../utilities/json-schema'; import { parseJsonSchemaToSubCommandDescription } from '../utilities/json-schema';
import { Schema as GenerateCommandSchema } from './generate'; import { Schema as GenerateCommandSchema } from './generate';
export class GenerateCommand extends SchematicCommand<GenerateCommandSchema> { export class GenerateCommand extends SchematicCommand<GenerateCommandSchema> {
@ -21,7 +21,7 @@ export class GenerateCommand extends SchematicCommand<GenerateCommandSchema> {
const [collectionName, schematicName] = this.parseSchematicInfo(options); const [collectionName, schematicName] = this.parseSchematicInfo(options);
const collection = this.getCollection(collectionName); const collection = this.getCollection(collectionName);
this.description.suboptions = {}; const subcommands: { [name: string]: SubCommandDescription } = {};
const schematicNames = schematicName ? [schematicName] : collection.listSchematicNames(); const schematicNames = schematicName ? [schematicName] : collection.listSchematicNames();
// Sort as a courtesy for the user. // Sort as a courtesy for the user.
@ -29,24 +29,28 @@ export class GenerateCommand extends SchematicCommand<GenerateCommandSchema> {
for (const name of schematicNames) { for (const name of schematicNames) {
const schematic = this.getSchematic(collection, name, true); const schematic = this.getSchematic(collection, name, true);
let options: Option[] = []; let subcommand: SubCommandDescription;
if (schematic.description.schemaJson) { if (schematic.description.schemaJson) {
options = await parseJsonSchemaToOptions( subcommand = await parseJsonSchemaToSubCommandDescription(
name,
schematic.description.path,
this._workflow.registry, this._workflow.registry,
schematic.description.schemaJson, schematic.description.schemaJson,
); );
} else {
continue;
} }
if (this.getDefaultSchematicCollection() == collectionName) { if (this.getDefaultSchematicCollection() == collectionName) {
this.description.suboptions[name] = options; subcommands[name] = subcommand;
} else { } else {
this.description.suboptions[`${collectionName}:${name}`] = options; subcommands[`${collectionName}:${name}`] = subcommand;
} }
} }
this.description.options.forEach(option => { this.description.options.forEach(option => {
if (option.name == 'schematic') { if (option.name == 'schematic') {
option.type = 'suboption'; option.subcommands = subcommands;
} }
}); });
} }
@ -86,7 +90,9 @@ export class GenerateCommand extends SchematicCommand<GenerateCommandSchema> {
await super.printHelp(options); await super.printHelp(options);
this.logger.info(''); this.logger.info('');
if (Object.keys(this.description.suboptions || {}).length == 1) { // Find the generate subcommand.
const subcommand = this.description.options.filter(x => x.subcommands)[0];
if (Object.keys((subcommand && subcommand.subcommands) || {}).length == 1) {
this.logger.info(`\nTo see help for a schematic run:`); this.logger.info(`\nTo see help for a schematic run:`);
this.logger.info(terminal.cyan(` ng generate <schematic> --help`)); this.logger.info(terminal.cyan(` ng generate <schematic> --help`));
} }

View File

@ -16,7 +16,7 @@ import {
CommandDescriptionMap, CommandDescriptionMap,
CommandScope, CommandScope,
CommandWorkspace, CommandWorkspace,
Option, Option, SubCommandDescription,
} from './interface'; } from './interface';
export interface BaseCommandOptions { export interface BaseCommandOptions {
@ -76,6 +76,12 @@ export abstract class Command<T extends BaseCommandOptions = BaseCommandOptions>
this.logger.info(''); this.logger.info('');
} }
protected async printHelpSubcommand(subcommand: SubCommandDescription) {
this.logger.info(subcommand.description);
await this.printHelpOptions(subcommand.options);
}
protected async printHelpOptions(options: Option[] = this.description.options) { protected async printHelpOptions(options: Option[] = this.description.options) {
const args = options.filter(opt => opt.positional !== undefined); const args = options.filter(opt => opt.positional !== undefined);
const opts = options.filter(opt => opt.positional === undefined); const opts = options.filter(opt => opt.positional === undefined);

View File

@ -88,13 +88,23 @@ export interface Option {
* The type of option value. If multiple types exist, this type will be the first one, and the * The type of option value. If multiple types exist, this type will be the first one, and the
* types array will contain all types accepted. * types array will contain all types accepted.
*/ */
type: OptionType | 'suboption'; type: OptionType;
/** /**
* {@see type} * {@see type}
*/ */
types?: OptionType[]; types?: OptionType[];
/**
* If this option maps to a subcommand in the parent command, will contain all the subcommands
* supported. There is a maximum of 1 subcommand Option per command, and the type of this
* option will always be "string" (no other types). The value of this option will map into
* this map and return the extra information.
*/
subcommands?: {
[name: string]: SubCommandDescription;
};
/** /**
* Aliases supported by this option. * Aliases supported by this option.
*/ */
@ -143,26 +153,26 @@ export enum CommandScope {
} }
/** /**
* A description of a command, its metadata. * A description of a command and its options.
*/ */
export interface CommandDescription { export interface SubCommandDescription {
/** /**
* Name of the command. * The name of the subcommand.
*/ */
name: string; name: string;
/** /**
* Short description (1-2 lines) of this command. * Short description (1-2 lines) of this sub command.
*/ */
description: string; description: string;
/** /**
* A long description of the option, in Markdown format. * A long description of the sub command, in Markdown format.
*/ */
longDescription?: string; longDescription?: string;
/** /**
* Additional notes about usage of this command. * Additional notes about usage of this sub command, in Markdown format.
*/ */
usageNotes?: string; usageNotes?: string;
@ -172,10 +182,15 @@ export interface CommandDescription {
options: Option[]; options: Option[];
/** /**
* Aliases supported for this command. * Aliases supported for this sub command.
*/ */
aliases: string[]; aliases: string[];
}
/**
* A description of a command, its metadata.
*/
export interface CommandDescription extends SubCommandDescription {
/** /**
* Scope of the command, whether it can be executed in a project, outside of a project or * Scope of the command, whether it can be executed in a project, outside of a project or
* anywhere. * anywhere.
@ -191,13 +206,6 @@ export interface CommandDescription {
* The constructor of the command, which should be extending the abstract Command<> class. * The constructor of the command, which should be extending the abstract Command<> class.
*/ */
impl: CommandConstructor; impl: CommandConstructor;
/**
* Suboptions.
*/
suboptions?: {
[name: string]: Option[];
};
} }
export interface OptionSmartDefault { export interface OptionSmartDefault {

View File

@ -60,8 +60,6 @@ function _coerceType(str: string | undefined, type: OptionType, v?: Value): Valu
function _coerce(str: string | undefined, o: Option | null, v?: Value): Value | undefined { function _coerce(str: string | undefined, o: Option | null, v?: Value): Value | undefined {
if (!o) { if (!o) {
return _coerceType(str, OptionType.Any, v); return _coerceType(str, OptionType.Any, v);
} else if (o.type == 'suboption') {
return _coerceType(str, OptionType.String, v);
} else { } else {
return _coerceType(str, o.type, v); return _coerceType(str, o.type, v);
} }

View File

@ -116,46 +116,60 @@ export abstract class SchematicCommand<
await super.printHelp(options); await super.printHelp(options);
this.logger.info(''); this.logger.info('');
const schematicNames = Object.keys(this.description.suboptions || {}); const subCommandOption = this.description.options.filter(x => x.subcommands)[0];
if (this.description.suboptions) { if (!subCommandOption || !subCommandOption.subcommands) {
if (schematicNames.length > 1) { return 0;
this.logger.info('Available Schematics:'); }
const namesPerCollection: { [c: string]: string[] } = {}; const schematicNames = Object.keys(subCommandOption.subcommands);
schematicNames.forEach(name => {
const [collectionName, schematicName] = name.split(/:/, 2);
if (!namesPerCollection[collectionName]) { if (schematicNames.length > 1) {
namesPerCollection[collectionName] = []; this.logger.info('Available Schematics:');
}
namesPerCollection[collectionName].push(schematicName); const namesPerCollection: { [c: string]: string[] } = {};
schematicNames.forEach(name => {
let [collectionName, schematicName] = name.split(/:/, 2);
if (!schematicName) {
schematicName = collectionName;
collectionName = this.collectionName;
}
if (!namesPerCollection[collectionName]) {
namesPerCollection[collectionName] = [];
}
namesPerCollection[collectionName].push(schematicName);
});
const defaultCollection = this.getDefaultSchematicCollection();
Object.keys(namesPerCollection).forEach(collectionName => {
const isDefault = defaultCollection == collectionName;
this.logger.info(
` Collection "${collectionName}"${isDefault ? ' (default)' : ''}:`,
);
namesPerCollection[collectionName].forEach(schematicName => {
this.logger.info(` ${schematicName}`);
}); });
});
const defaultCollection = this.getDefaultSchematicCollection(); } else if (schematicNames.length == 1) {
Object.keys(namesPerCollection).forEach(collectionName => { this.logger.info('Help for schematic ' + schematicNames[0]);
const isDefault = defaultCollection == collectionName; await this.printHelpSubcommand(subCommandOption.subcommands[schematicNames[0]]);
this.logger.info(
` Collection "${collectionName}"${isDefault ? ' (default)' : ''}:`,
);
namesPerCollection[collectionName].forEach(schematicName => {
this.logger.info(` ${schematicName}`);
});
});
} else if (schematicNames.length == 1) {
this.logger.info('Options for schematic ' + schematicNames[0]);
await this.printHelpOptions(this.description.suboptions[schematicNames[0]]);
}
} }
return 0; return 0;
} }
async printHelpUsage() { async printHelpUsage() {
const schematicNames = Object.keys(this.description.suboptions || {}); const subCommandOption = this.description.options.filter(x => x.subcommands)[0];
if (this.description.suboptions && schematicNames.length == 1) {
if (!subCommandOption || !subCommandOption.subcommands) {
return;
}
const schematicNames = Object.keys(subCommandOption.subcommands);
if (schematicNames.length == 1) {
this.logger.info(this.description.description); this.logger.info(this.description.description);
const opts = this.description.options.filter(x => x.positional === undefined); const opts = this.description.options.filter(x => x.positional === undefined);
@ -167,7 +181,7 @@ export abstract class SchematicCommand<
? schematicName ? schematicName
: schematicNames[0]; : schematicNames[0];
const schematicOptions = this.description.suboptions[schematicNames[0]]; const schematicOptions = subCommandOption.subcommands[schematicNames[0]].options;
const schematicArgs = schematicOptions.filter(x => x.positional !== undefined); const schematicArgs = schematicOptions.filter(x => x.positional !== undefined);
const argDisplay = schematicArgs.length > 0 const argDisplay = schematicArgs.length > 0
? ' ' + schematicArgs.map(a => `<${strings.dasherize(a.name)}>`).join(' ') ? ' ' + schematicArgs.map(a => `<${strings.dasherize(a.name)}>`).join(' ')

View File

@ -14,7 +14,7 @@ import {
CommandDescription, CommandDescription,
CommandScope, CommandScope,
Option, Option,
OptionType, OptionType, SubCommandDescription,
} from '../models/interface'; } from '../models/interface';
function _getEnumFromValue<E, T extends string>(v: json.JsonValue, e: E, d: T): T { function _getEnumFromValue<E, T extends string>(v: json.JsonValue, e: E, d: T): T {
@ -29,23 +29,12 @@ function _getEnumFromValue<E, T extends string>(v: json.JsonValue, e: E, d: T):
return d; return d;
} }
export async function parseJsonSchemaToCommandDescription( export async function parseJsonSchemaToSubCommandDescription(
name: string, name: string,
jsonPath: string, jsonPath: string,
registry: json.schema.SchemaRegistry, registry: json.schema.SchemaRegistry,
schema: json.JsonObject, schema: json.JsonObject,
): Promise<CommandDescription> { ): Promise<SubCommandDescription> {
// Before doing any work, let's validate the implementation.
if (typeof schema.$impl != 'string') {
throw new Error(`Command ${name} has an invalid implementation.`);
}
const ref = new ExportStringRef<CommandConstructor>(schema.$impl, dirname(jsonPath));
const impl = ref.ref;
if (impl === undefined || typeof impl !== 'function') {
throw new Error(`Command ${name} has an invalid implementation.`);
}
const options = await parseJsonSchemaToOptions(registry, schema); const options = await parseJsonSchemaToOptions(registry, schema);
const aliases: string[] = []; const aliases: string[] = [];
@ -78,20 +67,44 @@ export async function parseJsonSchemaToCommandDescription(
usageNotes = readFileSync(unPath, 'utf-8'); usageNotes = readFileSync(unPath, 'utf-8');
} }
const scope = _getEnumFromValue(schema.$scope, CommandScope, CommandScope.Default);
const type = _getEnumFromValue(schema.$type, CommandType, CommandType.Default);
const description = '' + (schema.description === undefined ? '' : schema.description); const description = '' + (schema.description === undefined ? '' : schema.description);
const hidden = !!schema.$hidden;
return { return {
name, name,
description, description,
...(longDescription ? { longDescription } : {}), ...(longDescription ? { longDescription } : {}),
...(usageNotes ? { usageNotes } : {}), ...(usageNotes ? { usageNotes } : {}),
hidden,
options, options,
aliases, aliases,
};
}
export async function parseJsonSchemaToCommandDescription(
name: string,
jsonPath: string,
registry: json.schema.SchemaRegistry,
schema: json.JsonObject,
): Promise<CommandDescription> {
const subcommand = await parseJsonSchemaToSubCommandDescription(name, jsonPath, registry, schema);
// Before doing any work, let's validate the implementation.
if (typeof schema.$impl != 'string') {
throw new Error(`Command ${name} has an invalid implementation.`);
}
const ref = new ExportStringRef<CommandConstructor>(schema.$impl, dirname(jsonPath));
const impl = ref.ref;
if (impl === undefined || typeof impl !== 'function') {
throw new Error(`Command ${name} has an invalid implementation.`);
}
const scope = _getEnumFromValue(schema.$scope, CommandScope, CommandScope.Default);
const hidden = !!schema.$hidden;
return {
...subcommand,
scope, scope,
hidden,
impl, impl,
}; };
} }