Import other modules into monorepo
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
2021-06-01 20:59:40 -05:00
parent 26d54033af
commit 9be9c44a32
138 changed files with 11544 additions and 139 deletions

443
src/cli/Directive.ts Normal file
View 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
}
}

64
src/cli/Template.ts Normal file
View File

@@ -0,0 +1,64 @@
import {UniversalPath} from '../util'
/**
* Interface defining a template that can be generated using the TemplateDirective.
*/
export interface Template {
/**
* The name of the template as it will be specified from the command line.
*
* @example
* If this is `'mytemplate'`, then the template will be created with:
*
* ```shell
* ./ex new mytemplate some:path
* ```
*/
name: string,
/**
* The suffix of the file generated by this template.
* @example `.mytemplate.ts`
* @example `.controller.ts`
*/
fileSuffix: string,
/**
* Brief description of the template displayed on the --help page for the TemplateDirective.
* Should be brief (1 sentence).
*/
description: string,
/**
* Array of path-strings that are resolved relative to the base `app` directory.
* @example `['http', 'controllers']`
* @example `['units']`
*/
baseAppPath: string[],
/**
* Render the given template to a string which will be written to the file.
* Note: this method should NOT write the contents to `targetFilePath`.
*
* @example
* If the user enters:
*
* ```shell
* ./ex new mytemplate path:to:NewInstance
* ```
*
* Then, the following params are:
* ```typescript
* {
* name: 'NewInstance',
* fullCanonicalPath: 'path:to:NewInstance',
* targetFilePath: UniversalPath { }
* }
* ```
*
* @param name - the singular name of the resource
* @param fullCanonicalName - the full canonical name of the resource
* @param targetFilePath - the UniversalPath where the file will be written
*/
render: (name: string, fullCanonicalName: string, targetFilePath: UniversalPath) => string | Promise<string>
}

View File

@@ -0,0 +1,31 @@
import {Directive} from "../Directive"
import {CommandLineApplication} from "../service"
import {Injectable} from "../../di"
import {ErrorWithContext} from "../../util"
import {Unit} from "../../lifecycle/Unit";
/**
* A directive that starts the framework's final target normally.
* In most cases, this runs the HTTP server, which would have been replaced
* by the CommandLineApplication unit.
*/
@Injectable()
export class RunDirective extends Directive {
getDescription(): string {
return 'run the application normally'
}
getKeywords(): string | string[] {
return ['run', 'up']
}
async handle(): Promise<void> {
if ( !CommandLineApplication.getReplacement() ) {
throw new ErrorWithContext(`Cannot run application: no run target specified.`)
}
const unit = <Unit> this.make(CommandLineApplication.getReplacement())
await this.app().startUnit(unit)
await this.app().stopUnit(unit)
}
}

View File

@@ -0,0 +1,47 @@
import {Directive} from "../Directive"
import * as colors from "colors/safe"
import * as repl from 'repl'
import {DependencyKey} from "../../di";
/**
* Launch an interactive REPL shell from within the application.
* This is very useful for debugging and testing things during development.
*/
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(') ➤ ')}`,
}
/**
* The created Node.js REPL server.
* @protected
*/
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<void> {
const state: any = {
app: this.app(),
make: (target: DependencyKey, ...parameters: any[]) => this.make(target, ...parameters),
}
await new Promise<void>(res => {
console.log(this.options.welcome)
this.repl = repl.start(this.options.prompt)
Object.assign(this.repl.context, state)
this.repl.on('exit', () => res())
})
}
}

View File

@@ -0,0 +1,90 @@
import {Directive, OptionDefinition} from "../Directive"
import {PositionalOption} from "./options/PositionalOption"
import {CommandLine} from "../service"
import {Inject, Injectable} from "../../di";
import {ErrorWithContext} from "../../util";
/**
* Create a new file based on a template registered with the CommandLine service.
*/
@Injectable()
export class TemplateDirective extends Directive {
@Inject()
protected readonly cli!: CommandLine
getKeywords(): string | string[] {
return ['new', 'make']
}
getDescription(): string {
return 'create a new file from a registered template'
}
getOptions(): OptionDefinition[] {
const registeredTemplates = this.cli.getTemplates()
const template = new PositionalOption('template_name', 'the template to base the new file on (e.g. model, controller)')
template.whitelist(...registeredTemplates.pluck('name').all())
const destination = new PositionalOption('file_name', 'canonical name of the file to create (e.g. auth:Group, dash:Activity)')
return [template, destination]
}
getHelpText(): string {
const registeredTemplates = this.cli.getTemplates()
return [
'Modules in Extollo register templates that can be used to quickly create common file types.',
'',
'For example, you can create a new model from @extollo/orm using the "model" template:',
'',
'./ex new model auth:Group',
'',
'This would create a new Group model in the ./src/app/models/auth/Group.model.ts file.',
'',
'AVAILABLE TEMPLATES:',
'',
...(registeredTemplates.map(template => {
return ` - ${template.name}: ${template.description}`
}).all())
].join('\n')
}
async handle(argv: string[]) {
const templateName: string = this.option('template_name')
const destinationName: string = this.option('file_name')
if ( destinationName.includes('/') || destinationName.includes('\\') ) {
this.error(`The destination should be a canonical name, not a file path.`)
this.error(`Reference sub-directories using the : character instead.`)
this.error(`Did you mean ${destinationName.replace(/\/|\\/g, ':')}?`)
process.exitCode = 1
return
}
const template = this.cli.getTemplate(templateName)
if ( !template ) {
throw new ErrorWithContext(`Unable to find template supposedly registered with name: ${templateName}`, {
templateName,
destinationName,
})
}
const name = destinationName.split(':').reverse()[0]
const path = this.app().path('..', 'src', 'app', ...template.baseAppPath, ...(`${destinationName}${template.fileSuffix}`).split(':'))
if ( await path.exists() ) {
this.error(`File already exists: ${path}`)
process.exitCode = 1
return
}
// Make sure the parent direction exists
await path.concat('..').mkdir()
const contents = await template.render(name, destinationName, path.clone())
await path.write(contents)
this.success(`Created new ${template.name} in ${path}`)
}
}

View File

@@ -0,0 +1,54 @@
import {Directive} from "../Directive"
import {Injectable, Inject} from "../../di"
import {padRight} from "../../util"
import {CommandLine} from "../service"
/**
* Directive that prints the help message and usage information about
* directives registered with the command line utility.
*/
@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<void> {
const directiveStrings = this.cli.getDirectives()
.map(cls => this.make<Directive>(cls))
.map<[string, string]>(dir => {
return [dir.getMainKeyword(), dir.getDescription()]
})
const maxLen = directiveStrings.max<number>(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 <directive> [..options]`,
'',
...printStrings,
'',
'For usage information about a particular command, pass the --help flag.',
'-------------------------------------------',
`powered by Extollo, © ${(new Date).getFullYear()} Garrett Mills`,
].join('\n'))
}
}

View File

@@ -0,0 +1,244 @@
/**
* A CLI option. Supports basic comparative, and set-based validation.
* @class
*/
export abstract class CLIOption<T> {
/**
* 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<string>}
*/
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
}
}

View File

@@ -0,0 +1,45 @@
import {CLIOption} from "./CLIOption"
/**
* Non-positional, flag-based CLI option.
*/
export class FlagOption<T> extends CLIOption<T> {
constructor(
/**
* The long-form flag for this option.
* @example --path, --create
*/
public readonly longFlag?: string,
/**
* The short-form flag for this option.
* @example -p, -c
*/
public readonly shortFlag?: string,
/**
* Usage message describing this flag.
*/
public readonly message?: string,
/**
* Description of the argument required by this flag.
* If this is set, the flag will expect a positional argument to follow as a param.
*/
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.')
}
}

View File

@@ -0,0 +1,32 @@
import {CLIOption} from "./CLIOption"
/**
* A positional CLI option. Defined without a flag.
*/
export class PositionalOption<T> extends CLIOption<T> {
/**
* Instantiate the option.
* @param {string} name - the name of the option
* @param {string} message - message describing the option
*/
constructor(
/**
* The display name of this positional argument.
* @example path, filename
*/
public readonly name: string,
/**
* A usage message describing this parameter.
*/
public readonly message: string = ''
) { super() }
/**
* Gets the name of the option.
* @returns {string}
*/
getArgumentName () {
return this.name
}
}

13
src/cli/index.ts Normal file
View File

@@ -0,0 +1,13 @@
export * from './Directive'
export * from './Template'
export * from './service/CommandLineApplication'
export * from './service/CommandLine'
export * from './directive/options/CLIOption'
export * from './directive/options/FlagOption'
export * from './directive/options/PositionalOption'
export * from './directive/ShellDirective'
export * from './directive/TemplateDirective'
export * from './directive/UsageDirective'

View File

@@ -0,0 +1,124 @@
import {Singleton, Instantiable, Inject} from "../../di"
import {Collection} from "../../util"
import {CommandLineApplication} from "./CommandLineApplication"
import {Directive} from "../Directive"
import {Template} from "../Template"
import {directive_template} from "../templates/directive"
import {unit_template} from "../templates/unit";
import {controller_template} from "../templates/controller";
import {middleware_template} from "../templates/middleware";
import {routes_template} from "../templates/routes";
import {config_template} from "../templates/config";
import {Unit} from "../../lifecycle/Unit";
import {Logging} from "../../service/Logging";
/**
* Service for managing directives, templates, and other resources related
* to the command line utilities.
*/
@Singleton()
export class CommandLine extends Unit {
@Inject()
protected readonly logging!: Logging
/** Directive classes registered with the CLI command. */
protected directives: Collection<Instantiable<Directive>> = new Collection<Instantiable<Directive>>()
/** Templates registered with the CLI command. These can be created with the TemplateDirective. */
protected templates: Collection<Template> = new Collection<Template>()
constructor() { super() }
async up() {
this.registerTemplate(directive_template)
this.registerTemplate(unit_template)
this.registerTemplate(controller_template)
this.registerTemplate(middleware_template)
this.registerTemplate(routes_template)
this.registerTemplate(config_template)
}
/**
* Returns true if the application was started from the command line.
*/
public isCLI() {
return this.app().hasUnit(CommandLineApplication)
}
/**
* Returns a string containing the Extollo ASCII logo.
*/
public getASCIILogo() {
return ` _
/ /\\ ______ _ _ _
/ / \\ | ____| | | | | |
/ / /\\ \\ | |__ __ _| |_ ___ | | | ___
/ / /\\ \\ \\ | __| \\ \\/ / __/ _ \\| | |/ _ \\
/ / / \\ \\_\\ | |____ > <| || (_) | | | (_) |
\\/_/ \\/_/ |______/_/\\_\\\\__\\___/|_|_|\\___/
`
}
/**
* Register a Directive class with this service. This will make
* the directive available for use on the CLI.
* @param directiveClass
*/
public registerDirective(directiveClass: Instantiable<Directive>) {
if ( !this.directives.includes(directiveClass) ) {
this.directives.push(directiveClass)
}
}
/**
* Returns true if the given directive is registered with this service.
* @param directiveClass
*/
public hasDirective(directiveClass: Instantiable<Directive>) {
return this.directives.includes(directiveClass)
}
/**
* Get a collection of all registered directives.
*/
public getDirectives() {
return this.directives.clone()
}
/**
* Register the given template with this service. This makes the template
* available for use with the TemplateDirective service.
* @param template
*/
public registerTemplate(template: Template) {
if ( !this.templates.firstWhere('name', '=', template.name) ) {
this.templates.push(template)
} else {
this.logging.warn(`Duplicate template will not be registered: ${template.name}`)
this.logging.debug(`Duplicate template registered at: ${(new Error()).stack}`)
}
}
/**
* Returns true if a template with the given name exists.
* @param name
*/
public hasTemplate(name: string) {
return !!this.templates.firstWhere('name', '=', name)
}
/**
* Returns the template with the given name, if one exists.
* @param name
*/
public getTemplate(name: string): Template | undefined {
return this.templates.firstWhere('name', '=', name)
}
/**
* Get a collection of all registered templates.
*/
public getTemplates() {
return this.templates.clone()
}
}

View File

@@ -0,0 +1,56 @@
import {Unit} from "../../lifecycle/Unit"
import {Logging} from "../../service/Logging";
import {Singleton, Inject} from "../../di/decorator/injection"
import {CommandLine} from "./CommandLine"
import {UsageDirective} from "../directive/UsageDirective";
import {Directive} from "../Directive";
import {ShellDirective} from "../directive/ShellDirective";
import {TemplateDirective} from "../directive/TemplateDirective";
import {RunDirective} from "../directive/RunDirective";
/**
* Unit that takes the place of the final unit in the application that handles
* invocations from the command line.
*/
@Singleton()
export class CommandLineApplication extends Unit {
/** The unit that was replaced by the CLI app. */
private static replacement?: typeof Unit
/** Set the replaced unit. */
public static setReplacement(unitClass?: typeof Unit) {
this.replacement = unitClass
}
/** Get the replaced unit. */
public static getReplacement() {
return this.replacement
}
@Inject()
protected readonly cli!: CommandLine
@Inject()
protected readonly logging!: Logging
constructor() { super() }
public async up() {
this.cli.registerDirective(UsageDirective)
this.cli.registerDirective(ShellDirective)
this.cli.registerDirective(TemplateDirective)
this.cli.registerDirective(RunDirective)
const argv = process.argv.slice(2)
const match = this.cli.getDirectives()
.map(dirCls => this.make<Directive>(dirCls))
.firstWhere(dir => dir.matchesKeyword(argv[0]))
if ( match ) {
await match.invoke(argv.slice(1))
} else {
const usage = this.make<UsageDirective>(UsageDirective)
await usage.handle(process.argv)
}
}
}

2
src/cli/service/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './CommandLine'
export * from './CommandLineApplication'

View File

@@ -0,0 +1,22 @@
import {Template} from "../Template"
import {UniversalPath} from "../../util"
/**
* A template that generates a new configuration file in the app/configs directory.
*/
const config_template: Template = {
name: 'config',
fileSuffix: '.config.ts',
description: 'Create a new config file.',
baseAppPath: ['configs'],
render(name: string, fullCanonicalName: string, targetFilePath: UniversalPath) {
return `import { env } from '@extollo/lib'
export default {
key: env('VALUE_ENV_VAR', 'default value'),
}
`
}
}
export { config_template }

View File

@@ -0,0 +1,31 @@
import {Template} from "../Template"
import {UniversalPath} from "../../util"
/**
* Template that generates a new controller in the app/http/controllers directory.
*/
const controller_template: Template = {
name: 'controller',
fileSuffix: '.controller.ts',
description: 'Create a controller class that can be used to handle requests.',
baseAppPath: ['http', 'controllers'],
render(name: string, fullCanonicalName: string, targetFilePath: UniversalPath) {
return `import {Controller, view} from "@extollo/lib"
import {Inject, Injectable} from "@extollo/di"
/**
* ${name} Controller
* ------------------------------------
* Put some description here.
*/
@Injectable()
export class ${name} extends Controller {
public ${name.toLowerCase()}() {
return view('${name.toLowerCase()}')
}
}
`
}
}
export { controller_template }

View File

@@ -0,0 +1,43 @@
import {Template} from "../Template"
import {UniversalPath} from "../../util"
/**
* Template that generates a new Directive class in the app/directives directory.
*/
const directive_template: Template = {
name: 'directive',
fileSuffix: '.directive.ts',
description: 'Create a new Directive class which adds functionality to the ./ex command.',
baseAppPath: ['directives'],
render(name: string, fullCanonicalName: string, targetFilePath: UniversalPath) {
return `import {Directive, OptionDefinition} from "@extollo/cli"
import {Injectable} from "@extollo/di"
/**
* ${name} Directive
* ---------------------------------------------------
* Put some description here.
*/
@Injectable()
export class ${name}Directive extends Directive {
getKeywords(): string | string[] {
return ['${name.toLowerCase()}']
}
getDescription(): string {
return ''
}
getOptions(): OptionDefinition[] {
return []
}
async handle(argv: string[]) {
}
}
`
}
}
export { directive_template }

View File

@@ -0,0 +1,31 @@
import {Template} from "../Template"
import {UniversalPath} from "../../util"
/**
* Template that generates a new middleware class in app/http/middlewares.
*/
const middleware_template: Template = {
name: 'middleware',
fileSuffix: '.middleware.ts',
description: 'Create a middleware class that can be applied to routes.',
baseAppPath: ['http', 'middlewares'],
render(name: string, fullCanonicalName: string, targetFilePath: UniversalPath) {
return `import {Middleware} from "@extollo/lib"
import {Injectable} from "@extollo/di"
/**
* ${name} Middleware
* --------------------------------------------
* Put some description here.
*/
@Injectable()
export class ${name} extends Middleware {
public async apply() {
}
}
`
}
}
export { middleware_template }

View File

@@ -0,0 +1,26 @@
import {Template} from "../Template"
import {UniversalPath} from "../../util"
/**
* Template that generates a new route definition file in app/http/routes.
*/
const routes_template: Template = {
name: 'routes',
fileSuffix: '.routes.ts',
description: 'Create a file for route definitions.',
baseAppPath: ['http', 'routes'],
render(name: string, fullCanonicalName: string, targetFilePath: UniversalPath) {
return `import {Route} from "@extollo/lib"
/*
* ${name} Routes
* -------------------------------
* Put some description here.
*/
`
}
}
export { routes_template }

38
src/cli/templates/unit.ts Normal file
View File

@@ -0,0 +1,38 @@
import {Template} from "../Template"
import {UniversalPath} from "../../util"
/**
* Template that generates a new application unit class in app/units.
*/
const unit_template: Template = {
name: 'unit',
fileSuffix: '.ts',
description: 'Create a service unit that will start and stop with your application.',
baseAppPath: ['units'],
render(name: string, fullCanonicalName: string, targetFilePath: UniversalPath) {
return `import {Singleton, Inject} from "@extollo/di"
import {Unit, Logging} from "@extollo/lib"
/**
* ${name} Unit
* ---------------------------------------
* Put some description here.
*/
@Singleton()
export class ${name} extends Unit {
@Inject()
protected readonly logging!: Logging
public async up() {
this.logging.info('${name} has started!')
}
public async down() {
this.logging.info('${name} has stopped!')
}
}
`
}
}
export { unit_template }

11
src/cli/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es5",
"sourceMap": true,
"experimentalDecorators": true
},
"exclude": [
"node_modules"
]
}