import {Injectable, Inject} from '../di' import {infer, ErrorWithContext} from '../util' import {CLIOption} from './directive/options/CLIOption' import {PositionalOption} from './directive/options/PositionalOption' import {FlagOption} from './directive/options/FlagOption' import {AppClass} from '../lifecycle/AppClass' import {Logging} from '../service/Logging' /** * Type alias for a definition of a command-line option. * * This can be either an instance of CLIOption or a string describing an option. * * @example * Some examples of positional/flag options defined by strings: * `'{file name} | canonical name of the resource to create'` * * `'--push -p {value} | the value to be pushed'` * * `'--force -f | do a force push'` */ export type OptionDefinition = CLIOption | string /** * An error thrown when an invalid option was detected. */ export class OptionValidationError extends ErrorWithContext {} /** * A base class representing a sub-command in the command-line utility. */ @Injectable() export abstract class Directive extends AppClass { @Inject() protected readonly logging!: Logging /** Parsed option values. */ private optionValues: any /** * Get the keyword or array of keywords that will specify this directive. * * @example * If this returns `['up', 'start']`, the directive can be run by either of: * * ```shell * ./ex up * ./ex start * ``` */ public abstract getKeywords(): string | string[] /** * Get the usage description of this directive. Should be brief (1 sentence). */ public abstract getDescription(): string /** * Optionally, specify a longer usage text that is shown on the directive's `--help` page. */ public getHelpText(): string { return '' } /** * Get an array of options defined for this command. */ public getOptions(): OptionDefinition[] { return [] } /** * Called when the directive is run from the command line. * * The raw arguments are provided as `argv`, but you are encouraged to use * `getOptions()` and `option()` helpers to access the parsed options instead. * * @param argv */ public abstract handle(argv: string[]): void | Promise /** * Sets the parsed option values. * @param optionValues * @private */ private setOptionValues(optionValues: any) { this.optionValues = optionValues } /** * Get the value of a parsed option. If none exists, return `defaultValue`. * @param name * @param defaultValue */ public option(name: string, defaultValue?: unknown): any { if ( name in this.optionValues ) { return this.optionValues[name] } return defaultValue } /** * Invoke this directive with the specified arguments. * * If usage was requested (see `didRequestUsage()`), it prints the extended usage info. * * Otherwise, it parses the options from `argv` and calls `handle()`. * * @param argv */ async invoke(argv: string[]): Promise { const options = this.getResolvedOptions() if ( this.didRequestUsage(argv) ) { const positionalArguments: PositionalOption[] = [] options.forEach(opt => { if ( opt instanceof PositionalOption ) { positionalArguments.push(opt) } }) const flagArguments: FlagOption[] = [] options.forEach(opt => { if ( opt instanceof FlagOption ) { flagArguments.push(opt) } }) const positionalDisplay: string = positionalArguments.map(x => `<${x.getArgumentName()}>`).join(' ') const flagDisplay: string = flagArguments.length ? ' [...flags]' : '' this.nativeOutput([ '', `DIRECTIVE: ${this.getMainKeyword()} - ${this.getDescription()}`, '', `USAGE: ${this.getMainKeyword()} ${positionalDisplay}${flagDisplay}`, ].join('\n')) if ( positionalArguments.length ) { this.nativeOutput([ '', `POSITIONAL ARGUMENTS:`, ...(positionalArguments.map(arg => { return ` ${arg.getArgumentName()}${arg.message ? ' - ' + arg.message : ''}` })), ].join('\n')) } if ( flagArguments.length ) { this.nativeOutput([ '', `FLAGS:`, ...(flagArguments.map(arg => { return ` ${arg.shortFlag ? arg.shortFlag + ', ' : ''}${arg.longFlag}${arg.argumentDescription ? ' {' + arg.argumentDescription + '}' : ''}${arg.message ? ' - ' + arg.message : ''}` })), ].join('\n')) } const help = this.getHelpText() if ( help ) { this.nativeOutput('\n' + help) } this.nativeOutput('\n') } else { try { const optionValues = this.parseOptions(options, argv) this.setOptionValues(optionValues) await this.handle(argv) } catch (e: unknown) { if ( e instanceof Error ) { this.nativeOutput(e.message) this.error(e) } if ( e instanceof OptionValidationError ) { // expecting, value, requirements if ( e.context.expecting ) { this.nativeOutput(` - Expecting: ${e.context.expecting}`) } if ( e.context.requirements && Array.isArray(e.context.requirements) ) { for ( const req of e.context.requirements ) { this.nativeOutput(` - ${req}`) } } if ( e.context.value ) { this.nativeOutput(` - ${e.context.value}`) } } this.nativeOutput('\nUse --help for more info.') } } } /** * Resolve the array of option definitions to CLIOption instances. * Of note, this resolves the string-form definitions to actual CLIOption instances. */ public getResolvedOptions(): CLIOption[] { return this.getOptions().map(option => { if ( typeof option === 'string' ) { return this.instantiateOptionFromString(option) } else { return option } }) } /** * Get the main keyword displayed for this directive. * @example * If `getKeywords()` returns `['up', 'start']`, this will return `'up'`. */ public getMainKeyword(): string { const kws = this.getKeywords() if ( Array.isArray(kws) ) { return kws[0] } return kws } /** * Returns true if the given keyword should invoke this directive. * @param name */ public matchesKeyword(name: string): boolean { let kws = this.getKeywords() if ( !Array.isArray(kws) ) { kws = [kws] } return kws.includes(name) } /** * Print the given output to the log as success text. * @param output */ success(output: unknown): void { this.logging.success(output, true) } /** * Print the given output to the log as error text. * @param output */ error(output: unknown): void { this.logging.error(output, true) } /** * Print the given output to the log as warning text. * @param output */ warn(output: unknown): void { this.logging.warn(output, true) } /** * Print the given output to the log as info text. * @param output */ info(output: unknown): void { this.logging.info(output, true) } /** * Print the given output to the log as debugging text. * @param output */ debug(output: unknown): void { this.logging.debug(output, true) } /** * Print the given output to the log as verbose text. * @param output */ verbose(output: unknown): void { this.logging.verbose(output, true) } /** * Get the flag option that signals help. Usually, this is named 'help' * and supports the flags '--help' and '-?'. */ getHelpOption(): FlagOption { return new FlagOption('--help', '-?', 'usage information about this directive') } /** * Process the raw CLI arguments using an array of option class instances to build * a mapping of option names to provided values. */ parseOptions(options: CLIOption[], args: string[]): {[key: string]: any} { let positionalArguments: PositionalOption[] = [] options.forEach(opt => { if ( opt instanceof PositionalOption ) { positionalArguments.push(opt) } }) const flagArguments: FlagOption[] = [] options.forEach(opt => { if ( opt instanceof FlagOption ) { flagArguments.push(opt) } }) const optionValue: any = {} flagArguments.push(this.getHelpOption()) let expectingFlagArgument = false let positionalFlagName = '' for ( const value of args ) { if ( value.startsWith('--') ) { if ( expectingFlagArgument ) { throw new OptionValidationError(`Unexpected flag argument. Expecting argument for flag: ${positionalFlagName}`, { expecting: positionalFlagName, }) } else { const flagArgument = flagArguments.filter(x => x.longFlag === value) if ( flagArgument.length < 1 ) { throw new OptionValidationError(`Unknown flag argument: ${value}`, { value, }) } else { if ( flagArgument[0].argumentDescription ) { positionalFlagName = flagArgument[0].getArgumentName() expectingFlagArgument = true } else { optionValue[flagArgument[0].getArgumentName()] = true } } } } else if ( value.startsWith('-') ) { if ( expectingFlagArgument ) { throw new OptionValidationError(`Unknown flag argument: ${value}`, { expecting: positionalFlagName, }) } else { const flagArgument = flagArguments.filter(x => x.shortFlag === value) if ( flagArgument.length < 1 ) { throw new OptionValidationError(`Unknown flag argument: ${value}`, { value, }) } else { if ( flagArgument[0].argumentDescription ) { positionalFlagName = flagArgument[0].getArgumentName() expectingFlagArgument = true } else { optionValue[flagArgument[0].getArgumentName()] = true } } } } else if ( expectingFlagArgument ) { const inferredValue = infer(value) const optionInstance = flagArguments.filter(x => x.getArgumentName() === positionalFlagName)[0] if ( !optionInstance.validate(inferredValue) ) { throw new OptionValidationError(`Invalid value for argument: ${positionalFlagName}`, { requirements: optionInstance.getRequirementDisplays(), }) } optionValue[positionalFlagName] = inferredValue expectingFlagArgument = false } else { if ( positionalArguments.length < 1 ) { throw new OptionValidationError(`Unknown positional argument: ${value}`, { value, }) } else { const inferredValue = infer(value) if ( !positionalArguments[0].validate(inferredValue) ) { throw new OptionValidationError(`Invalid value for argument: ${positionalArguments[0].getArgumentName()}`, { requirements: positionalArguments[0].getRequirementDisplays(), }) } optionValue[positionalArguments[0].getArgumentName()] = infer(value) positionalArguments = positionalArguments.slice(1) } } } if ( expectingFlagArgument ) { throw new OptionValidationError(`Missing argument for flag: ${positionalFlagName}`, { expecting: positionalFlagName, }) } if ( positionalArguments.length > 0 ) { throw new OptionValidationError(`Missing required argument: ${positionalArguments[0].getArgumentName()}`, { expecting: positionalArguments[0].getArgumentName(), }) } return optionValue } /** * Create an instance of CLIOption based on a string definition of a particular format. * * e.g. '{file name} | canonical name of the resource to create' * e.g. '--push -p {value} | the value to be pushed' * e.g. '--force -f | do a force push' * * @param string */ protected instantiateOptionFromString(string: string): CLIOption { if ( string.startsWith('{') ) { // The string is a positional argument const stringParts = string.split('|').map(x => x.trim()) const name = stringParts[0].replace(/\{|\}/g, '') return stringParts.length > 1 ? (new PositionalOption(name, stringParts[1])) : (new PositionalOption(name)) } else { // The string is a flag argument const stringParts = string.split('|').map(x => x.trim()) // Parse the flag parts first const hasArgument = stringParts[0].indexOf('{') >= 0 const flagString = hasArgument ? stringParts[0].substr(0, stringParts[0].indexOf('{')).trim() : stringParts[0].trim() const flagParts = flagString.split(' ') let longFlag = flagParts[0].startsWith('--') ? flagParts[0] : undefined if ( !longFlag && flagParts.length > 1 ) { if ( flagParts[1].startsWith('--') ) { longFlag = flagParts[1] } } let shortFlag = flagParts[0].length === 2 ? flagParts[0] : undefined if ( !shortFlag && flagParts.length > 1 ) { if ( flagParts[1].length === 2 ) { shortFlag = flagParts[1] } } const argumentDescription = hasArgument ? stringParts[0].substring(stringParts[0].indexOf('{')+1, stringParts[0].indexOf('}')) : undefined const description = stringParts.length > 1 ? stringParts[1] : undefined return new FlagOption(longFlag, shortFlag, description, argumentDescription) } } /** * Determines if, at any point in the arguments, the help option's short or long flag appears. * @returns {boolean} - true if the help flag appeared */ didRequestUsage(argv: string[]): boolean { const helpOption = this.getHelpOption() for ( const arg of argv ) { if ( arg.trim() === helpOption.longFlag || arg.trim() === helpOption.shortFlag ) { return true } } return false } protected nativeOutput(...outputs: any[]): void { console.log(...outputs) // eslint-disable-line no-console } /** * Get a promise that resolves after SIGINT is received, executing a * callback beforehand. * @param callback * @protected */ protected async untilInterrupt(callback?: () => unknown): Promise { return new Promise(res => { process.on('SIGINT', async () => { if ( callback ) { await callback() } res() }) }) } }