Import other modules into monorepo
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
443
src/cli/Directive.ts
Normal file
443
src/cli/Directive.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
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<any> | 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<void>
|
||||
|
||||
/**
|
||||
* 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?: 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[]) {
|
||||
const options = this.getResolvedOptions()
|
||||
|
||||
if ( this.didRequestUsage(argv) ) {
|
||||
// @ts-ignore
|
||||
const positionalArguments: PositionalOption<any>[] = options.filter(opt => opt instanceof PositionalOption)
|
||||
|
||||
// @ts-ignore
|
||||
const flagArguments: FlagOption<any>[] = options.filter(opt => opt instanceof FlagOption)
|
||||
|
||||
const positionalDisplay: string = positionalArguments.map(x => `<${x.getArgumentName()}>`).join(' ')
|
||||
const flagDisplay: string = flagArguments.length ? ' [...flags]' : ''
|
||||
|
||||
console.log([
|
||||
'',
|
||||
`DIRECTIVE: ${this.getMainKeyword()} - ${this.getDescription()}`,
|
||||
'',
|
||||
`USAGE: ${this.getMainKeyword()} ${positionalDisplay}${flagDisplay}`,
|
||||
].join('\n'))
|
||||
|
||||
if ( positionalArguments.length ) {
|
||||
console.log([
|
||||
'',
|
||||
`POSITIONAL ARGUMENTS:`,
|
||||
...(positionalArguments.map(arg => {
|
||||
return ` ${arg.getArgumentName()}${arg.message ? ' - ' + arg.message : ''}`
|
||||
})),
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
if ( flagArguments.length ) {
|
||||
console.log([
|
||||
'',
|
||||
`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 ) {
|
||||
console.log('\n' + help)
|
||||
}
|
||||
|
||||
console.log('\n')
|
||||
} else {
|
||||
try {
|
||||
const optionValues = this.parseOptions(options, argv)
|
||||
this._setOptionValues(optionValues)
|
||||
await this.handle(argv)
|
||||
} catch (e) {
|
||||
console.error(e.message)
|
||||
if ( e instanceof OptionValidationError ) {
|
||||
// expecting, value, requirements
|
||||
if ( e.context.expecting ) {
|
||||
console.error(` - Expecting: ${e.context.expecting}`)
|
||||
}
|
||||
|
||||
if ( e.context.requirements && Array.isArray(e.context.requirements) ) {
|
||||
for ( const req of e.context.requirements ) {
|
||||
console.error(` - ${req}`)
|
||||
}
|
||||
}
|
||||
|
||||
if ( e.context.value ) {
|
||||
console.error(` - ${e.context.value}`)
|
||||
}
|
||||
}
|
||||
console.error('\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<any>[] {
|
||||
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) {
|
||||
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: any) {
|
||||
this.logging.success(output, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the given output to the log as error text.
|
||||
* @param output
|
||||
*/
|
||||
error(output: any) {
|
||||
this.logging.error(output, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the given output to the log as warning text.
|
||||
* @param output
|
||||
*/
|
||||
warn(output: any) {
|
||||
this.logging.warn(output, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the given output to the log as info text.
|
||||
* @param output
|
||||
*/
|
||||
info(output: any) {
|
||||
this.logging.info(output, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the given output to the log as debugging text.
|
||||
* @param output
|
||||
*/
|
||||
debug(output: any) {
|
||||
this.logging.debug(output, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the given output to the log as verbose text.
|
||||
* @param output
|
||||
*/
|
||||
verbose(output: any) {
|
||||
this.logging.verbose(output, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the flag option that signals help. Usually, this is named 'help'
|
||||
* and supports the flags '--help' and '-?'.
|
||||
*/
|
||||
getHelpOption() {
|
||||
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<any>[], args: string[]) {
|
||||
// @ts-ignore
|
||||
let positionalArguments: PositionalOption<any>[] = options.filter(cls => cls instanceof PositionalOption)
|
||||
|
||||
// @ts-ignore
|
||||
const flagArguments: FlagOption<any>[] = options.filter(cls => cls instanceof FlagOption)
|
||||
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<any> {
|
||||
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[]) {
|
||||
const help_option = this.getHelpOption()
|
||||
for ( const arg of argv ) {
|
||||
if ( arg.trim() === help_option.longFlag || arg.trim() === help_option.shortFlag ) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user