refactoring

This commit is contained in:
2020-05-09 19:51:43 +02:00
parent 0ff924d2dd
commit 469afeb777
15 changed files with 431 additions and 240 deletions

View File

@ -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
View 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
View 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 ();
}));
}
}

View 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');
}
}
}
}

View 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
View 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
View 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');
}
}

View 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');
}
}