refactoring
This commit is contained in:
		| @@ -12,242 +12,73 @@ | ||||
| /* eslint-disable max-statements */ | ||||
| /* eslint-disable no-process-env */ | ||||
| import { Persistent } from '@sapphirecode/modelling'; | ||||
| import fs from 'fs-extra'; | ||||
| import yargs, { Options } from 'yargs'; | ||||
| import { Confirm, Input } from 'enquirer'; | ||||
| import { TypeValidation } from './Types/TypeValidation'; | ||||
| import { PathType } from './Types/PathType'; | ||||
| import { Option, OptionProcess, OptionType } from './Types'; | ||||
| import { OptionSource } from './Sources/OptionSource'; | ||||
| import { EnvSource } from './Sources/EnvSource'; | ||||
| import { ArgSource } from './Sources/ArgSource'; | ||||
| import { InteractiveSource } from './Sources/InteractiveSource'; | ||||
|  | ||||
| type OptionType = | ||||
|   'string' | ||||
|   | 'number' | ||||
|   | 'boolean' | ||||
|   | 'file' | ||||
|   | 'folder' | ||||
|   | 'path'; | ||||
| const types: Record<OptionType, TypeValidation> = { | ||||
|   string:  new TypeValidation ('string'), | ||||
|   number:  new TypeValidation ('number'), | ||||
|   boolean: new TypeValidation ('boolean'), | ||||
|   file:    new PathType ('file'), | ||||
|   folder:  new PathType ('folder'), | ||||
|   path:    new PathType ('path') | ||||
| }; | ||||
|  | ||||
| interface Option { | ||||
|   name: string; | ||||
|   type: OptionType; | ||||
|   required?: boolean; | ||||
|   default?: unknown; | ||||
|   alias?: string; | ||||
|   env?: string; | ||||
|   description?: string; | ||||
|   message?: string; | ||||
| } | ||||
|  | ||||
| interface OptionProcess extends Option { | ||||
|   filled: boolean; | ||||
|   value?: unknown; | ||||
| } | ||||
|  | ||||
| function get_string_type (type: OptionType): 'string'|'number'|'boolean' { | ||||
|   if ([ | ||||
|     'string', | ||||
|     'number', | ||||
|     'boolean' | ||||
|   ].includes (type)) | ||||
|     return type as ('string'|'number'|'boolean'); | ||||
|   if ([ | ||||
|     'file', | ||||
|     'folder', | ||||
|     'path' | ||||
|   ].includes (type)) | ||||
|     return 'string'; | ||||
|   throw new Error (`unknown option type ${type}`); | ||||
| interface SourceConfig { | ||||
|   env: boolean; | ||||
|   args: boolean; | ||||
|   interactive: boolean; | ||||
| } | ||||
|  | ||||
| export class InteractiveOptions extends Persistent { | ||||
|   protected options: Array<OptionProcess>; | ||||
|   protected quiet = false; | ||||
|   protected sources: OptionSource[] = []; | ||||
|  | ||||
|   public constructor (options: Array<Option>) { | ||||
|   public constructor ( | ||||
|     options: Array<Option>, | ||||
|     source_config: SourceConfig = { args: true, env: true, interactive: true } | ||||
|   ) { | ||||
|     super (); | ||||
|     this.options = options | ||||
|       .map ((v) => ({ filled: false, ...v } as OptionProcess)); | ||||
|       .map ((v) => ({ | ||||
|         filled:          false, | ||||
|         type_validation: types[v.type], | ||||
|         ...v | ||||
|       } as OptionProcess)); | ||||
|     for (const option of this.options) { | ||||
|       if ( | ||||
|         typeof option.default !== 'undefined' | ||||
|         && typeof option.default !== get_string_type (option.type) | ||||
|         && typeof option.default !== option.type_validation.string_type | ||||
|       ) { | ||||
|         throw new Error ( | ||||
|           `default does not match option type on ${option.name}` | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       this.properties[option.name] = get_string_type (option.type); | ||||
|       this.properties[option.name] = option.type_validation.string_type; | ||||
|     } | ||||
|  | ||||
|     if (source_config.env) | ||||
|       this.sources.push (new EnvSource); | ||||
|     if (source_config.args) | ||||
|       this.sources.push (new ArgSource); | ||||
|     if (source_config.interactive) | ||||
|       this.sources.push (new InteractiveSource); | ||||
|   } | ||||
|  | ||||
|   public async parse (): Promise<Record<string, unknown>> { | ||||
|     await this.get_env_options (); | ||||
|     await this.get_args_options (); | ||||
|     await this.get_interactive_options (); | ||||
|     for (const src of this.sources) | ||||
|       // eslint-disable-next-line no-await-in-loop | ||||
|       await src.parse (this.options); | ||||
|     for (const opt of this.options) | ||||
|       this.set (opt.name, opt.value); | ||||
|  | ||||
|     return this.to_object (); | ||||
|   } | ||||
|  | ||||
|   private async assign_arg (opt: OptionProcess, value: unknown): Promise<void> { | ||||
|     if (opt.type === 'string') { | ||||
|       opt.value = String (value); | ||||
|       opt.filled = true; | ||||
|       return; | ||||
|     } | ||||
|     if (opt.type === 'number') { | ||||
|       if (![ | ||||
|         'string', | ||||
|         'number' | ||||
|       ].includes (typeof value)) | ||||
|         return; | ||||
|  | ||||
|       const as_num = parseInt (String (value)); | ||||
|       const is_num = !isNaN (as_num); | ||||
|       if (is_num) { | ||||
|         opt.value = as_num; | ||||
|         opt.filled = true; | ||||
|       } | ||||
|       return; | ||||
|     } | ||||
|     if (opt.type === 'boolean') { | ||||
|       if (![ | ||||
|         'string', | ||||
|         'boolean', | ||||
|         'number' | ||||
|       ].includes (typeof value)) | ||||
|         return; | ||||
|  | ||||
|       const is_bool = [ | ||||
|         0, | ||||
|         1 | ||||
|       ].includes (parseInt (String (value))) | ||||
|       || (/^(?:true|false)$/ui).test (value as string); | ||||
|       if (is_bool) { | ||||
|         const as_bool = value === 1 || (/true/ui).test (value as string); | ||||
|         opt.value = as_bool; | ||||
|         opt.filled = true; | ||||
|       } | ||||
|       return; | ||||
|     } | ||||
|     if ( | ||||
|       opt.type === 'path' | ||||
|       || opt.type === 'file' | ||||
|       || opt.type === 'folder' | ||||
|     ) { | ||||
|       if (typeof value !== 'string' || !await fs.pathExists (value)) | ||||
|         return; | ||||
|       if (opt.type === 'path') { | ||||
|         opt.value = value; | ||||
|         opt.filled = true; | ||||
|         return; | ||||
|       } | ||||
|       const stat = await fs.stat (value); | ||||
|       if (stat.isDirectory () === (opt.type === 'folder')) { | ||||
|         opt.value = value; | ||||
|         opt.filled = true; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private async get_env_options (): Promise<void> { | ||||
|     await Promise.all (this.options.map ((opt) => { | ||||
|       if ( | ||||
|         typeof opt.env !== 'undefined' | ||||
|         && typeof process.env[opt.env] !== 'undefined' | ||||
|       ) | ||||
|         return this.assign_arg (opt, process.env[opt.env]); | ||||
|       return Promise.resolve (); | ||||
|     })); | ||||
|   } | ||||
|  | ||||
|   private async get_args_options (): Promise<void> { | ||||
|     const yargs_config: Record<string, Options> = { | ||||
|       quiet: { | ||||
|         alias:    'q', | ||||
|         default:  false, | ||||
|         type:     'boolean', | ||||
|         describe: 'do not ask for options interactively' | ||||
|       }, | ||||
|       help: { | ||||
|         alias:    'h', | ||||
|         default:  false, | ||||
|         type:     'boolean', | ||||
|         describe: '' | ||||
|       } | ||||
|     }; | ||||
|     for (const opt of this.options) { | ||||
|       yargs_config[opt.name] = { | ||||
|         alias:    opt.alias, | ||||
|         default:  opt.default, | ||||
|         type:     get_string_type (opt.type), | ||||
|         describe: opt.description | ||||
|       }; | ||||
|     } | ||||
|     const argv = yargs.options (yargs_config) | ||||
|       .parse (); | ||||
|     if (argv.help) { | ||||
|       yargs.options (yargs_config) | ||||
|         .showHelp (); | ||||
|       process.exit (0); | ||||
|     } | ||||
|     this.quiet = argv.quiet as boolean; | ||||
|  | ||||
|     await Promise.all (this.options.map ((opt) => { | ||||
|       if (typeof argv[opt.name] !== 'undefined') | ||||
|         return this.assign_arg (opt, argv[opt.name]); | ||||
|       return Promise.resolve (); | ||||
|     })); | ||||
|   } | ||||
|  | ||||
|   private async prompt (opt: OptionProcess): Promise<void> { | ||||
|     if (opt.filled) | ||||
|       return; | ||||
|     if ( | ||||
|       opt.type === 'string' | ||||
|       || opt.type === 'file' | ||||
|       || opt.type === 'folder' | ||||
|       || opt.type === 'path' | ||||
|       || opt.type === 'number' | ||||
|     ) { | ||||
|       const value = await new Input ({ | ||||
|         message: typeof opt.message === 'undefined' | ||||
|           ? `input ${opt.name}` | ||||
|           : opt.message, | ||||
|         default: opt.default | ||||
|       }) | ||||
|         .run (); | ||||
|       await this.assign_arg (opt, value); | ||||
|       return; | ||||
|     } | ||||
|     if ( | ||||
|       opt.type === 'boolean' | ||||
|     ) { | ||||
|       const value = await new Confirm ({ | ||||
|         message: opt.message, | ||||
|         default: opt.default | ||||
|       }) | ||||
|         .run (); | ||||
|       await this.assign_arg (opt, value); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private async get_interactive_options (): Promise<void> { | ||||
|     if (this.quiet) { | ||||
|       const missing = this.options.filter ((o) => !o.filled && o.required) | ||||
|         .map ((o) => o.name); | ||||
|       if (missing.length > 0) { | ||||
|         console.error ('missing arguments:'); | ||||
|         console.error (missing.join (', ')); | ||||
|         process.exit (0); | ||||
|       } | ||||
|     } | ||||
|     for (const opt of this.options) { | ||||
|       while (!opt.filled) { | ||||
|         // eslint-disable-next-line no-await-in-loop | ||||
|         await this.prompt (opt); | ||||
|         if (!opt.filled) | ||||
|           console.log ('input was invalid'); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										55
									
								
								lib/Sources/ArgSource.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								lib/Sources/ArgSource.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | ||||
| /* eslint-disable no-console */ | ||||
| /* eslint-disable no-process-exit */ | ||||
| import yargs, { Options } from 'yargs'; | ||||
| import { OptionProcess } from '../Types'; | ||||
| import { OptionSource } from './OptionSource'; | ||||
|  | ||||
| export class ArgSource extends OptionSource { | ||||
|   public async parse (options: OptionProcess[]): Promise<void> { | ||||
|     const yargs_config: Record<string, Options> = { | ||||
|       quiet: { | ||||
|         alias:    'q', | ||||
|         default:  false, | ||||
|         type:     'boolean', | ||||
|         describe: 'do not ask for options interactively' | ||||
|       }, | ||||
|       help: { | ||||
|         alias:    'h', | ||||
|         default:  false, | ||||
|         type:     'boolean', | ||||
|         describe: '' | ||||
|       } | ||||
|     }; | ||||
|     for (const opt of options) { | ||||
|       yargs_config[opt.name] = { | ||||
|         alias:    opt.alias, | ||||
|         default:  opt.default, | ||||
|         type:     opt.type_validation.string_type, | ||||
|         describe: opt.description | ||||
|       }; | ||||
|     } | ||||
|     const argv = yargs.options (yargs_config) | ||||
|       .parse (); | ||||
|     if (argv.help) { | ||||
|       yargs.options (yargs_config) | ||||
|         .showHelp (); | ||||
|       process.exit (0); | ||||
|     } | ||||
|  | ||||
|     await Promise.all (options.map ((opt) => { | ||||
|       if (typeof argv[opt.name] !== 'undefined') | ||||
|         return this.assign_arg (opt, argv[opt.name]); | ||||
|       return Promise.resolve (); | ||||
|     })); | ||||
|  | ||||
|     if (argv.quiet) { | ||||
|       const missing = options.filter ((o) => !o.filled && o.required) | ||||
|         .map ((o) => o.name); | ||||
|       if (missing.length > 0) { | ||||
|         console.error ('missing arguments:'); | ||||
|         console.error (missing.join (', ')); | ||||
|         process.exit (0); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										16
									
								
								lib/Sources/EnvSource.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								lib/Sources/EnvSource.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| /* eslint-disable no-process-env */ | ||||
| import { OptionProcess } from '../Types'; | ||||
| import { OptionSource } from './OptionSource'; | ||||
|  | ||||
| export class EnvSource extends OptionSource { | ||||
|   public async parse (options: OptionProcess[]): Promise<void> { | ||||
|     await Promise.all (options.map ((opt) => { | ||||
|       if ( | ||||
|         typeof opt.env !== 'undefined' | ||||
|         && typeof process.env[opt.env] !== 'undefined' | ||||
|       ) | ||||
|         return this.assign_arg (opt, process.env[opt.env]); | ||||
|       return Promise.resolve (); | ||||
|     })); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										50
									
								
								lib/Sources/InteractiveSource.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								lib/Sources/InteractiveSource.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,50 @@ | ||||
| /* eslint-disable no-console */ | ||||
| /* eslint-disable no-process-exit */ | ||||
| import { Confirm, Input } from 'enquirer'; | ||||
| import { OptionProcess } from '../Types'; | ||||
| import { OptionSource } from './OptionSource'; | ||||
|  | ||||
| export class InteractiveSource extends OptionSource { | ||||
|   private async prompt (opt: OptionProcess): Promise<void> { | ||||
|     if (opt.filled) | ||||
|       return; | ||||
|     if ( | ||||
|       opt.type === 'string' | ||||
|       || opt.type === 'file' | ||||
|       || opt.type === 'folder' | ||||
|       || opt.type === 'path' | ||||
|       || opt.type === 'number' | ||||
|     ) { | ||||
|       const value = await new Input ({ | ||||
|         message: typeof opt.message === 'undefined' | ||||
|           ? `input ${opt.name}` | ||||
|           : opt.message, | ||||
|         default: opt.default | ||||
|       }) | ||||
|         .run (); | ||||
|       await this.assign_arg (opt, value); | ||||
|       return; | ||||
|     } | ||||
|     if ( | ||||
|       opt.type === 'boolean' | ||||
|     ) { | ||||
|       const value = await new Confirm ({ | ||||
|         message: opt.message, | ||||
|         default: opt.default | ||||
|       }) | ||||
|         .run (); | ||||
|       await this.assign_arg (opt, value); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public async parse (options: OptionProcess[]): Promise<void> { | ||||
|     for (const opt of options) { | ||||
|       while (!opt.filled) { | ||||
|         // eslint-disable-next-line no-await-in-loop | ||||
|         await this.prompt (opt); | ||||
|         if (!opt.filled) | ||||
|           console.log ('input was invalid'); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										18
									
								
								lib/Sources/OptionSource.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								lib/Sources/OptionSource.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| import { OptionProcess } from '../Types'; | ||||
|  | ||||
| export abstract class OptionSource { | ||||
|   public abstract async parse(opt: OptionProcess[]): Promise<void>; | ||||
|  | ||||
|   protected async assign_arg ( | ||||
|     opt: OptionProcess, | ||||
|     value: unknown | ||||
|   ): Promise<void> { | ||||
|     try { | ||||
|       opt.value = await opt.type_validation.to_type (value); | ||||
|       opt.filled = true; | ||||
|     } | ||||
|     catch (e) { | ||||
|       // could not assing | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										28
									
								
								lib/Types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								lib/Types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| import { TypeValidation } from './Types/TypeValidation'; | ||||
|  | ||||
| type OptionType = | ||||
|   'string' | ||||
|   | 'number' | ||||
|   | 'boolean' | ||||
|   | 'file' | ||||
|   | 'folder' | ||||
|   | 'path'; | ||||
|  | ||||
| interface Option { | ||||
|   name: string; | ||||
|   type: OptionType; | ||||
|   required?: boolean; | ||||
|   default?: unknown; | ||||
|   alias?: string; | ||||
|   env?: string; | ||||
|   description?: string; | ||||
|   message?: string; | ||||
| } | ||||
|  | ||||
| interface OptionProcess extends Option { | ||||
|   filled: boolean; | ||||
|   value?: unknown; | ||||
|   type_validation: TypeValidation; | ||||
| } | ||||
|  | ||||
| export { OptionType, Option, OptionProcess }; | ||||
							
								
								
									
										23
									
								
								lib/Types/PathType.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								lib/Types/PathType.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| import fs from 'fs-extra'; | ||||
| import { TypeValidation } from './TypeValidation'; | ||||
|  | ||||
| export class PathType extends TypeValidation { | ||||
|   public get string_type (): 'string'|'number'|'boolean'|'array' { | ||||
|     return 'string'; | ||||
|   } | ||||
|  | ||||
|   public async to_type (value: unknown): Promise<unknown> { | ||||
|     if (typeof value !== 'string') | ||||
|       throw new Error (`invalid type for ${this.general_type}`); | ||||
|     if (!await fs.pathExists (value)) | ||||
|       throw new Error ('path does not exist'); | ||||
|     if (this.general_type === 'path') | ||||
|       return value; | ||||
|  | ||||
|     const stat = await fs.stat (value); | ||||
|     if (stat.isDirectory () === (this.general_type === 'folder')) | ||||
|       return value; | ||||
|  | ||||
|     throw new Error ('cannot assign folder to file'); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										44
									
								
								lib/Types/TypeValidation.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								lib/Types/TypeValidation.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | ||||
| export class TypeValidation { | ||||
|   private readonly _general_type: string; | ||||
|  | ||||
|   public get general_type (): string { | ||||
|     return this._general_type; | ||||
|   } | ||||
|  | ||||
|   public get string_type (): 'string'|'number'|'boolean'|'array' { | ||||
|     return this._general_type as 'string'|'number'|'boolean'|'array'; | ||||
|   } | ||||
|  | ||||
|   public constructor (type: string) { | ||||
|     this._general_type = type; | ||||
|   } | ||||
|  | ||||
|   public validate_type (value: unknown): boolean { | ||||
|     return typeof value === this.general_type; | ||||
|   } | ||||
|  | ||||
|   public to_type (value: unknown): Promise<unknown> { | ||||
|     if (this.general_type === 'string') | ||||
|       return Promise.resolve (String (value)); | ||||
|  | ||||
|     if (this.general_type === 'number') { | ||||
|       const as_num = parseInt (String (value)); | ||||
|       if (isNaN (as_num)) | ||||
|         throw new Error ('value is not a number'); | ||||
|       return Promise.resolve (as_num); | ||||
|     } | ||||
|  | ||||
|     if (this.general_type === 'boolean') { | ||||
|       const as_num = parseInt (String (value)); | ||||
|       if ( | ||||
|         as_num !== 1 && as_num !== 0 | ||||
|         && !(/^(?:true|false)$/iu).test (String (value)) | ||||
|       ) | ||||
|         throw new Error ('value is not a boolean'); | ||||
|       return Promise.resolve ( | ||||
|         as_num === 1 || (/true/iu).test (String (value)) | ||||
|       ); | ||||
|     } | ||||
|     throw new Error ('unknown type'); | ||||
|   } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user