Created
January 22, 2021 20:29
-
-
Save paularmstrong/d1c88c56a08f0b44a915ba349e25e52d to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import yargsParser from 'yargs-parser'; | |
import Logger from './logger'; | |
type $ObjMap<O extends {}, T> = { | |
[K in keyof O]: T; | |
}; | |
type CommandOption = { | |
alias?: Array<string> | string; | |
description: string; | |
normalize?: boolean; | |
}; | |
type ArrayOption = CommandOption & { | |
type: 'array'; | |
}; | |
type ArrayOptionRequired = ArrayOption & { | |
required: true; | |
}; | |
type ArrayOptionDefault = ArrayOption & { | |
default: Array<string>; | |
}; | |
type BooleanOption = CommandOption & { | |
type: 'boolean'; | |
}; | |
type BooleanOptionRequired = BooleanOption & { | |
required: true; | |
}; | |
type BooleanOptionDefault = BooleanOption & { | |
default: boolean; | |
}; | |
type CountOption = CommandOption & { | |
type: 'count'; | |
}; | |
type CountOptionRequired = CountOption & { | |
required: true; | |
}; | |
type CountOptionDefault = CountOption & { | |
default: number; | |
}; | |
type NumberOption = CommandOption & { | |
type: 'number'; | |
}; | |
type NumberOptionRequired = NumberOption & { | |
required: true; | |
}; | |
type NumberOptionDefault = NumberOption & { | |
default: number; | |
}; | |
type StringOption = CommandOption & { | |
type: 'string'; | |
choices?: Array<string>; | |
}; | |
type StringOptionRequired = StringOption & { | |
required: true; | |
}; | |
type StringOptionDefault = StringOption & { | |
default: string; | |
}; | |
type ExtractArrayOption = ((arg0: ArrayOptionDefault) => Array<string>) & | |
((arg0: ArrayOptionRequired) => Array<string>) & | |
((arg0: ArrayOption) => Array<string> | void); | |
type ExtractBooleanOption = ((arg0: BooleanOptionDefault) => boolean) & | |
((arg0: BooleanOptionRequired) => boolean) & | |
((arg0: BooleanOption) => boolean); | |
type ExtractCountOption = ((arg0: CountOptionDefault) => number) & | |
((arg0: CountOptionRequired) => number) & | |
((arg0: CountOption) => number); | |
type ExtractNumberOption = ((arg0: NumberOptionDefault) => number) & | |
((arg0: NumberOptionRequired) => number) & | |
((arg0: NumberOption) => number | void); | |
type ExtractStringOption = ((arg0: StringOptionDefault) => string) & | |
((arg0: StringOptionRequired) => string) & | |
((arg0: StringOption) => string | void); | |
type ExtractOption = ExtractArrayOption & | |
ExtractBooleanOption & | |
ExtractCountOption & | |
ExtractNumberOption & | |
ExtractStringOption; | |
export type Options = { | |
[key: string]: | |
| ArrayOption | |
| ArrayOptionDefault | |
| ArrayOptionRequired | |
| BooleanOption | |
| BooleanOptionDefault | |
| BooleanOptionRequired | |
| CountOption | |
| CountOptionDefault | |
| CountOptionRequired | |
| NumberOption | |
| NumberOptionDefault | |
| NumberOptionRequired | |
| StringOption | |
| StringOptionDefault | |
| StringOptionRequired; | |
}; | |
type Positional = { | |
description: string; | |
choices?: Array<string>; | |
}; | |
type RequiredPositional = Positional & { | |
required: true; | |
}; | |
type GreedyPositional = Positional & { | |
greedy: true; | |
}; | |
type ExtractPlainPositional = (arg0: Positional) => string | void; | |
type ExtractRequiredPositional = (arg0: RequiredPositional) => string; | |
type ExtractGreedyPositional = (arg0: GreedyPositional) => Array<string>; | |
type ExtractPositional = ExtractRequiredPositional & ExtractGreedyPositional & ExtractPlainPositional; | |
export type Positionals = { | |
[key: string]: GreedyPositional | RequiredPositional | Positional; | |
}; | |
export type Argv<pos extends Positionals, opts extends Options> = $ObjMap<opts, ExtractOption> & { | |
_: $ObjMap<pos, ExtractPositional>; | |
}; | |
export type Examples = Array<{ code: string; description: string }>; | |
export type Middleware = (args: {}) => Promise<{}>; | |
export type Command = { | |
alias?: string; | |
command: string; | |
description: string; | |
examples: Examples; | |
handler: <T>(args: T, logger: Logger) => Promise<void>; | |
middleware?: Array<Middleware>; | |
options: Options; | |
positionals: Positionals; | |
}; | |
interface YargsParserOptions { | |
alias: { [key: string]: string | string[] }; | |
array: Array<string>; | |
boolean: Array<string>; | |
count: Array<string>; | |
default: { [key: string]: any }; | |
normalize: Array<string>; | |
number: Array<string>; | |
string: Array<string>; | |
} | |
export default function optionsToParserOptions(options: Options): YargsParserOptions { | |
const parserOptions: YargsParserOptions = { | |
alias: {}, | |
array: [], | |
boolean: [], | |
count: [], | |
default: {}, | |
normalize: [], | |
number: [], | |
string: [], | |
}; | |
Object.keys(options).forEach((argKey) => { | |
const { alias, normalize, type, ...rest } = options[argKey]; | |
if (!type || !(type in parserOptions)) { | |
throw new Error(`Option "${argKey}" has invalid type "${type}"`); | |
} | |
parserOptions[type].push(argKey); | |
if (Array.isArray(alias) || typeof alias === 'string') { | |
parserOptions.alias[argKey] = alias; | |
} | |
if ('default' in rest) { | |
parserOptions.default[argKey] = rest.default; | |
} | |
if (type === 'string' && typeof normalize === 'boolean' && normalize) { | |
parserOptions.normalize.push(argKey); | |
} | |
}); | |
return parserOptions; | |
} | |
function getRequiredOptions(options: Options | Positionals): Array<string> { | |
return Object.keys(options).reduce((memo, argKey) => { | |
// @ts-ignore fuck you | |
if ('required' in options[argKey] && typeof options[argKey].required === 'boolean' && options[argKey].required) { | |
// @ts-ignore fuck you | |
memo.push(argKey); | |
} | |
return memo; | |
}, []); | |
} | |
type OptionResult = { [key: string]: Array<Error> }; | |
type ValidationResult = { | |
_isValid: boolean; | |
_: { [key: string]: Array<Error> }; | |
_unknown: Array<Error>; | |
}; | |
type CombinedResult = OptionResult & ValidationResult; | |
export function validate<P extends Positionals, O extends Options>( | |
argv: Argv<P, O>, | |
positionals: P, | |
options: O | |
): CombinedResult { | |
const empty: ValidationResult = { | |
_isValid: true, | |
_: Object.keys(positionals).reduce((memo, key) => { | |
memo[key] = []; | |
return memo; | |
}, {} as ValidationResult['_']), | |
_unknown: [], | |
}; | |
const errors = Object.keys(options).reduce((memo, key) => { | |
memo[key] = []; | |
return memo; | |
}, empty as CombinedResult); | |
getRequiredOptions(options).forEach((requiredKey) => { | |
if (!(requiredKey in argv)) { | |
errors[requiredKey].push(new Error(`No value provided for required argument "--${requiredKey}"`)); | |
errors._isValid = false; | |
} | |
}); | |
getRequiredOptions(positionals).forEach((requiredPositional) => { | |
if (!(requiredPositional in argv._)) { | |
errors._[requiredPositional].push( | |
new Error(`No value provided for required positional "<${requiredPositional}>"`) | |
); | |
errors._isValid = false; | |
} | |
}); | |
Object.entries(argv).forEach(([argKey, argValue]) => { | |
if (argKey === 'positionals') { | |
return; | |
} | |
if (!Object.keys(options).includes(argKey)) { | |
errors._unknown.push(new Error(`Received unknown argument "--${argKey}"`)); | |
errors._isValid = false; | |
return; | |
} | |
const option = options[argKey]; | |
if ( | |
option.type === 'string' && | |
typeof argValue === 'string' && | |
'choices' in option && | |
Array.isArray(option.choices) | |
) { | |
const { choices } = option; | |
if (!choices.includes(argValue)) { | |
errors[argKey].push( | |
new Error(`Value "${argValue}" for "--${argKey}" failed to match one of "${choices.join('", "')}"`) | |
); | |
errors._isValid = false; | |
} | |
} | |
}); | |
return errors; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment