444 lines
15 KiB
TypeScript
444 lines
15 KiB
TypeScript
|
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
|
||
|
}
|
||
|
}
|