diff --git a/package.json b/package.json index c0f0cf5..9753522 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,12 @@ "lib": "lib" }, "dependencies": { - "@extollo/util": "file:../util", - "@extollo/lib": "file:../lib", "@extollo/di": "file:../di", + "@extollo/lib": "file:../lib", + "@extollo/util": "file:../util", + "colors": "^1.4.0", "typescript": "^4.1.3" }, - "devDependencies": {}, "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "build": "tsc" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb4b972..155795a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2,9 +2,16 @@ dependencies: '@extollo/di': link:../di '@extollo/lib': link:../lib '@extollo/util': link:../util + colors: 1.4.0 typescript: 4.2.3 lockfileVersion: 5.2 packages: + /colors/1.4.0: + dev: false + engines: + node: '>=0.1.90' + resolution: + integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA== /typescript/4.2.3: dev: false engines: @@ -16,4 +23,5 @@ specifiers: '@extollo/di': file:../di '@extollo/lib': file:../lib '@extollo/util': file:../util + colors: ^1.4.0 typescript: ^4.1.3 diff --git a/src/Directive.ts b/src/Directive.ts new file mode 100644 index 0000000..d5822d3 --- /dev/null +++ b/src/Directive.ts @@ -0,0 +1,337 @@ +import {AppClass, Logging} from "@extollo/lib" +import {Injectable, Inject} from "@extollo/di" +import {infer, ErrorWithContext} from "@extollo/util" +import {CLIOption} from "./directive/options/CLIOption" +import {PositionalOption} from "./directive/options/PositionalOption"; +import {FlagOption} from "./directive/options/FlagOption"; + +export type OptionDefinition = CLIOption | string + +export class OptionValidationError extends ErrorWithContext {} + +@Injectable() +export abstract class Directive extends AppClass { + @Inject() + protected readonly logging!: Logging + + private _optionValues: any + + public abstract getKeywords(): string | string[] + + public abstract getDescription(): string + + public getHelpText(): string { + return '' + } + + public abstract handle(argv: string[]): void | Promise + + private _setOptionValues(optionValues: any) { + this._optionValues = optionValues; + } + + public option(name: string, defaultValue?: any) { + if ( name in this._optionValues ) { + return this._optionValues[name] + } + + return defaultValue + } + + async invoke(argv: string[]) { + const options = this.getResolvedOptions() + + if ( this.didRequestUsage(argv) ) { + // @ts-ignore + const positionalArguments: PositionalOption[] = options.filter(opt => opt instanceof PositionalOption) + + // @ts-ignore + const flagArguments: FlagOption[] = 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}`) + } + } + } + } + } + + public getOptions(): OptionDefinition[] { + return [] + } + + public getResolvedOptions(): CLIOption[] { + return this.getOptions().map(option => { + if ( typeof option === 'string' ) { + return this.instantiateOptionFromString(option) + } else { + return option + } + }) + } + + public getMainKeyword(): string { + const kws = this.getKeywords() + + if ( Array.isArray(kws) ) { + return kws[0] + } + + return kws + } + + public matchesKeyword(name: string) { + let kws = this.getKeywords() + if ( !Array.isArray(kws) ) kws = [kws] + return kws.includes(name) + } + + success(output: any) { + this.logging.success(output, true) + } + + error(output: any) { + this.logging.error(output, true) + } + + warn(output: any) { + this.logging.warn(output, true) + } + + info(output: any) { + this.logging.info(output, true) + } + + debug(output: any) { + this.logging.debug(output, true) + } + + 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[], args: string[]) { + // @ts-ignore + let positionalArguments: PositionalOption[] = options.filter(cls => cls instanceof PositionalOption) + + // @ts-ignore + const flagArguments: FlagOption[] = 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 { + 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 + } +} diff --git a/src/directive/ShellDirective.ts b/src/directive/ShellDirective.ts new file mode 100644 index 0000000..688afc9 --- /dev/null +++ b/src/directive/ShellDirective.ts @@ -0,0 +1,39 @@ +import {Directive} from "../Directive" +import * as colors from "colors/safe" +import * as repl from 'repl' + +export class ShellDirective extends Directive { + protected options: any = { + welcome: `powered by Extollo, © ${(new Date).getFullYear()} Garrett Mills\nAccess your application using the "app" global.`, + prompt: `${colors.blue('(')}extollo${colors.blue(') ➤ ')}`, + } + + protected repl?: repl.REPLServer + + getDescription(): string { + return 'launch an interactive shell inside your application' + } + + getKeywords(): string | string[] { + return ['shell'] + } + + getHelpText(): string { + return '' + } + + async handle(): Promise { + const state: any = { + app: this.app(), + make: this.make, + container: this.container, + } + + await new Promise(res => { + console.log(this.options.welcome) + this.repl = repl.start(this.options.prompt) + Object.assign(this.repl.context, state) + this.repl.on('exit', () => res()) + }) + } +} diff --git a/src/directive/UsageDirective.ts b/src/directive/UsageDirective.ts new file mode 100644 index 0000000..66fddb3 --- /dev/null +++ b/src/directive/UsageDirective.ts @@ -0,0 +1,50 @@ +import {Directive} from "../Directive" +import {Injectable, Inject} from "@extollo/di" +import {padRight} from "@extollo/util" +import {CommandLine} from "../service" + +@Injectable() +export class UsageDirective extends Directive { + @Inject() + protected readonly cli!: CommandLine + + public getKeywords(): string | string[] { + return 'help' + } + + public getDescription(): string { + return 'print information about available commands' + } + + public handle(argv: string[]): void | Promise { + const directiveStrings = this.cli.getDirectives() + .map(cls => this.make(cls)) + .map<[string, string]>(dir => { + return [dir.getMainKeyword(), dir.getDescription()] + }) + + const maxLen = directiveStrings.max(x => x[0].length) + + const printStrings = directiveStrings.map(grp => { + return [padRight(grp[0], maxLen + 1), grp[1]] + }) + .map(grp => { + return ` ${grp[0]}: ${grp[1]}` + }) + .toArray() + + console.log(this.cli.getASCIILogo()) + console.log([ + '', + 'Welcome to Extollo! Specify a command to get started.', + '', + `USAGE: ex [..options]`, + '', + ...printStrings, + '', + 'For usage information about a particular command, pass the --help flag.', + '-------------------------------------------', + `powered by Extollo, © ${(new Date).getFullYear()} Garrett Mills`, + ].join('\n')) + } +} diff --git a/src/directive/options/CLIOption.ts b/src/directive/options/CLIOption.ts new file mode 100644 index 0000000..a8bed39 --- /dev/null +++ b/src/directive/options/CLIOption.ts @@ -0,0 +1,244 @@ +/** + * A CLI option. Supports basic comparative, and set-based validation. + * @class + */ +export abstract class CLIOption { + + /** + * Do we use the whitelist? + * @type {boolean} + * @private + */ + protected _useWhitelist: boolean = false + + /** + * Do we use the blacklist? + * @type {boolean} + * @private + */ + protected _useBlacklist: boolean = false + + /** + * Do we use the less-than comparison? + * @type {boolean} + * @private + */ + protected _useLessThan: boolean = false + + /** + * Do we use the greater-than comparison? + * @type {boolean} + * @private + */ + protected _useGreaterThan: boolean = false + + /** + * Do we use the equality operator? + * @type {boolean} + * @private + */ + protected _useEquality: boolean = false + + /** + * Is this option optional? + * @type {boolean} + * @private + */ + protected _optional: boolean = false + + /** + * Whitelisted values. + * @type {Array<*>} + * @private + */ + protected _whitelist: T[] = [] + + /** + * Blacklisted values. + * @type {Array<*>} + * @private + */ + protected _blacklist: T[] = [] + + /** + * Value to be compared in less than. + * @type {*} + * @private + */ + protected _lessThanValue?: T + + /** + * If true, the less than will be less than or equal to. + * @type {boolean} + * @private + */ + protected _lessThanBit: boolean = false + + /** + * Value to be compared in greater than. + * @type {*} + * @private + */ + protected _greaterThanValue?: T + + /** + * If true, the greater than will be greater than or equal to. + * @type {boolean} + * @private + */ + protected _greateerThanBit: boolean = false + + /** + * The value to be used to check equality. + * @type {*} + * @private + */ + protected _equalityValue?: T + + /** + * Whitelist the specified item or items and enable the whitelist. + * @param {...*} items - the items to whitelist + */ + whitelist(...items: T[]) { + this._useWhitelist = true + items.forEach(item => this._whitelist.push(item)) + } + + /** + * Blacklist the specified item or items and enable the blacklist. + * @param {...*} items - the items to blacklist + */ + blacklist(...items: T[]) { + this._useBlacklist = true + items.forEach(item => this._blacklist.push(item)) + } + + /** + * Specifies the value to be used in less-than comparison and enables less-than comparison. + * @param {*} value + */ + lessThan(value: T) { + this._useLessThan = true + this._lessThanValue = value + } + + /** + * Specifies the value to be used in less-than or equal-to comparison and enables that comparison. + * @param {*} value + */ + lessThanOrEqualTo(value: T) { + this._lessThanBit = true + this.lessThan(value) + } + + /** + * Specifies the value to be used in greater-than comparison and enables that comparison. + * @param {*} value + */ + greaterThan(value: T) { + this._useGreaterThan = true + this._greaterThanValue = value + } + + /** + * Specifies the value to be used in greater-than or equal-to comparison and enables that comparison. + * @param {*} value + */ + greaterThanOrEqualTo(value: T) { + this._greateerThanBit = true + this.greaterThan(value) + } + + /** + * Specifies the value to be used in equality comparison and enables that comparison. + * @param {*} value + */ + equals(value: T) { + this._useEquality = true + this._equalityValue = value + } + + /** + * Checks if the specified value passes the configured comparisons. + * @param {*} value + * @returns {boolean} + */ + validate(value: any) { + let is_valid = true + if ( this._useEquality ) { + is_valid = is_valid && (this._equalityValue === value) + } + + if ( this._useLessThan && typeof this._lessThanValue !== 'undefined' ) { + if ( this._lessThanBit ) { + is_valid = is_valid && (value <= this._lessThanValue) + } else { + is_valid = is_valid && (value < this._lessThanValue) + } + } + + if ( this._useGreaterThan && typeof this._greaterThanValue !== 'undefined' ) { + if ( this._greateerThanBit ) { + is_valid = is_valid && (value >= this._greaterThanValue) + } else { + is_valid = is_valid && (value > this._greaterThanValue) + } + } + + if ( this._useWhitelist ) { + is_valid = is_valid && this._whitelist.some(x => { + return x === value + }) + } + + if ( this._useBlacklist ) { + is_valid = is_valid && !(this._blacklist.some(x => x === value)) + } + + return is_valid + } + + /** + * Sets the Option as optional. + */ + optional() { + this._optional = true + return this + } + + /** + * Get the argument name. Should be overridden by child classes. + * @returns {string} + */ + abstract getArgumentName(): string + + /** + * Get an array of strings denoting the human-readable requirements for this option to be valid. + * @returns {Array} + */ + getRequirementDisplays() { + const clauses = [] + + if ( this._useBlacklist ) { + clauses.push(`must not be one of: ${this._blacklist.map(x => String(x)).join(', ')}`) + } + + if ( this._useWhitelist ) { + clauses.push(`must be one of: ${this._whitelist.map(x => String(x)).join(', ')}`) + } + + if ( this._useGreaterThan ) { + clauses.push(`must be greater than${this._greateerThanBit ? ' or equal to' : ''}: ${String(this._greaterThanValue)}`) + } + + if ( this._useLessThan ) { + clauses.push(`must be less than${this._lessThanBit ? ' or equal to' : ''}: ${String(this._lessThanValue)}`) + } + + if ( this._useEquality ) { + clauses.push(`must be equal to: ${String(this._equalityValue)}`) + } + + return clauses + } +} diff --git a/src/directive/options/FlagOption.ts b/src/directive/options/FlagOption.ts new file mode 100644 index 0000000..10ff10c --- /dev/null +++ b/src/directive/options/FlagOption.ts @@ -0,0 +1,30 @@ +import {CLIOption} from "./CLIOption" + +/** + * Non-positional, flag-based CLI option. + */ +export class FlagOption extends CLIOption { + + constructor( + public readonly longFlag?: string, + public readonly shortFlag?: string, + public readonly message?: string, + public readonly argumentDescription?: string + ) { super() } + + /** + * Get the referential name for this option. + * Defaults to the long flag (without the '--'). If this cannot + * be found, the short flag (without the '-') is used. + * @returns {string} + */ + getArgumentName() { + if ( this.longFlag ) { + return this.longFlag.replace('--', '') + } else if ( this.shortFlag ) { + return this.shortFlag.replace('-', '') + } + + throw new Error('Missing either a long- or short-flag for FlagOption.') + } +} diff --git a/src/directive/options/PositionalOption.ts b/src/directive/options/PositionalOption.ts new file mode 100644 index 0000000..a69dadd --- /dev/null +++ b/src/directive/options/PositionalOption.ts @@ -0,0 +1,25 @@ +import {CLIOption} from "./CLIOption" + +/** + * A positional CLI option. Defined without a flag. + */ +export class PositionalOption extends CLIOption { + + /** + * Instantiate the option. + * @param {string} name - the name of the option + * @param {string} message - message describing the option + */ + constructor( + public readonly name: string, + public readonly message: string = '' + ) { super() } + + /** + * Gets the name of the option. + * @returns {string} + */ + getArgumentName () { + return this.name + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..6415f16 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,4 @@ +export * from './Directive' + +export * from './service/CommandLineApplication' +export * from './service/CommandLine' diff --git a/src/service/CommandLine.ts b/src/service/CommandLine.ts new file mode 100644 index 0000000..1a9637c --- /dev/null +++ b/src/service/CommandLine.ts @@ -0,0 +1,39 @@ +import {Unit} from "@extollo/lib" +import {Singleton, Instantiable} from "@extollo/di" +import {Collection} from "@extollo/util" +import {CommandLineApplication} from "./CommandLineApplication" +import {Directive} from "../Directive"; + +@Singleton() +export class CommandLine extends Unit { + protected directives: Collection> = new Collection>() + + constructor() { super() } + + public isCLI() { + return this.app().hasUnit(CommandLineApplication) + } + + public getASCIILogo() { + return ` ______ _ _ _ + | ____| | | | | | + | |__ __ _| |_ ___ | | | ___ + | __| \\ \\/ / __/ _ \\| | |/ _ \\ + | |____ > <| || (_) | | | (_) | + |______/_/\\_\\\\__\\___/|_|_|\\___/` + } + + public registerDirective(directiveClass: Instantiable) { + if ( !this.directives.includes(directiveClass) ) { + this.directives.push(directiveClass) + } + } + + public hasDirective(directiveClass: Instantiable) { + return this.directives.includes(directiveClass) + } + + public getDirectives() { + return this.directives.clone() + } +} diff --git a/src/service/CommandLineApplication.ts b/src/service/CommandLineApplication.ts new file mode 100644 index 0000000..7e3f4be --- /dev/null +++ b/src/service/CommandLineApplication.ts @@ -0,0 +1,39 @@ +import {Unit, Logging} from "@extollo/lib" +import {Singleton, Inject} from "@extollo/di" +import {CommandLine} from "./CommandLine" +import {UsageDirective} from "../directive/UsageDirective"; +import {Directive} from "../Directive"; +import {ShellDirective} from "../directive/ShellDirective"; + +@Singleton() +export class CommandLineApplication extends Unit { + private static replacement?: typeof Unit + public static setReplacement(unitClass?: typeof Unit) { + this.replacement = unitClass + } + + @Inject() + protected readonly cli!: CommandLine + + @Inject() + protected readonly logging!: Logging + + constructor() { super() } + + public async up() { + this.cli.registerDirective(UsageDirective) + this.cli.registerDirective(ShellDirective) + + const argv = process.argv.slice(2) + const match = this.cli.getDirectives() + .map(dirCls => this.make(dirCls)) + .firstWhere(dir => dir.matchesKeyword(argv[0])) + + if ( match ) { + await match.invoke(argv.slice(1)) + } else { + const usage = this.make(UsageDirective) + await usage.handle(process.argv) + } + } +} diff --git a/src/service/index.ts b/src/service/index.ts new file mode 100644 index 0000000..b3face3 --- /dev/null +++ b/src/service/index.ts @@ -0,0 +1,2 @@ +export * from './CommandLine' +export * from './CommandLineApplication'