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