490 lines
16 KiB
TypeScript
490 lines
16 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?: 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<void> {
|
|
const options = this.getResolvedOptions()
|
|
|
|
if ( this.didRequestUsage(argv) ) {
|
|
const positionalArguments: PositionalOption<any>[] = []
|
|
options.forEach(opt => {
|
|
if ( opt instanceof PositionalOption ) {
|
|
positionalArguments.push(opt)
|
|
}
|
|
})
|
|
|
|
const flagArguments: FlagOption<any>[] = []
|
|
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<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): 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<any> {
|
|
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[]): {[key: string]: any} {
|
|
let positionalArguments: PositionalOption<any>[] = []
|
|
options.forEach(opt => {
|
|
if ( opt instanceof PositionalOption ) {
|
|
positionalArguments.push(opt)
|
|
}
|
|
})
|
|
|
|
const flagArguments: FlagOption<any>[] = []
|
|
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<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[]): 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<void> {
|
|
return new Promise<void>(res => {
|
|
process.on('SIGINT', async () => {
|
|
if ( callback ) {
|
|
await callback()
|
|
}
|
|
|
|
res()
|
|
})
|
|
})
|
|
}
|
|
}
|