@rawcmd/core
Version:
Rawcmd core package.
407 lines (350 loc) • 9.64 kB
text/typescript
import { realize } from '@rawcmd/utils';
import { CommandOption, CommandOptionData } from './command-option';
import { Typewriter } from '../writers/typewriter';
import { createModelClass } from '@rawmodel/core';
import { CommandResolver, ErrorCode } from '../types';
import { ValidationError } from '../errors/validation';
import { RuntimeError } from '../errors/runtime';
import { CommandLink, CommandLinkData } from './command-link';
import { Spinwriter } from '../writers/spinwriter';
/**
* Command data type.
*/
export type CommandData<Context> = (
Command<Context>
| CommandRecipe<Context>
| ((config: CommandConfig<Context>) => (Command<Context> | CommandRecipe<Context>))
);
/**
* Command recipe interface.
*/
export interface CommandRecipe<Context> {
/**
* Command name.
*/
name?: string;
/**
* Command description.
*/
description?: string;
/**
* Command summary.
*/
summary?: string;
/**
* List of command options.
*/
options?: CommandOptionData[];
/**
* List of sub commands.
*/
commands?: CommandData<Context>[];
/**
* List of links.
*/
links?: CommandLinkData[];
/**
* Command resolver.
*/
resolver?: CommandResolver;
}
/**
* Command configuration interface.
*/
export interface CommandConfig<Context> {
/**
* Parent command instance.
*/
parent?: Command<Context>;
/**
* Arbitrary context data.
*/
context?: Context;
/**
* Custom printer instance.
*/
typewriter?: Typewriter;
/**
* Custom spinner instance.
*/
spinwriter?: Spinwriter;
}
/**
* Command class.
*/
export class Command<Context = any> {
/**
* Command resolver.
*/
public __config: CommandConfig<Context>;
/**
* Command name.
*/
public name: string;
/**
* Command description.
*/
public description: string;
/**
* Command summary.
*/
public summary: string;
/**
* Command options.
*/
public options: CommandOption[];
/**
* Sub commands.
*/
public commands: Command<Context>[];
/**
* Command links.
*/
public links: CommandLink[];
/**
* Command resolver.
*/
public resolver: CommandResolver;
/**
* Class constructor.
* @param recipe Command recipe.
* @param config Command configuration.
*/
public constructor(recipe?: CommandData<Context>, config?: CommandConfig<Context>) {
recipe = { ...recipe };
Object.defineProperty(this, '__config', {
value: {
typewriter: new Typewriter(),
spinwriter: new Spinwriter(),
...config,
},
enumerable: false,
});
this.name = recipe.name || null;
this.description = recipe.description || null;
this.summary = recipe.summary || null;
config = {
...this.__config,
parent: this,
};
this.options = (recipe.options || []).map((option) => {
option = realize(option, this, [config]);
return option instanceof CommandOption ? option.clone() : new CommandOption(option);
});
this.commands = (recipe.commands || []).map((command) => {
command = realize(command, this, [config]);
return command instanceof Command ? command.clone() : new Command(command, config);
});
this.links = (recipe.links || []).map((link) => {
link = realize(link, this, [config]);
return link instanceof CommandLink ? link.clone() : new CommandLink(link);
});
this.resolver = recipe.resolver || (() => null);
}
/**
* Returns command arbitrary context data.
*/
public getParent(): Command<Context> {
return this.__config.parent || null;
}
/**
* Returns a list of all parent command instances.
*/
public getAncestors(): Command<Context>[] {
const tree = [];
let parent = this as any;
while (true) {
parent = parent.getParent();
if (parent) {
tree.unshift(parent);
} else {
break;
}
}
return tree;
}
/**
* Returns command arbitrary context data.
*/
public getContext(): Context {
return this.__config.context || null;
}
/**
* Returns command typewriter instance.
*/
public getTypewriter(): Typewriter {
return this.__config.typewriter || null;
}
/**
* Returns command typewriter instance.
*/
public getSpinwriter(): Spinwriter {
return this.__config.spinwriter || null;
}
/**
* Performs a command based on the provided command-line arguments. The
* function expects command-line arguments as `process.argv.slice(2)`.
* @param args List of command-line arguments.
*/
public async perform(...args: string[]): Promise<this> {
await performCommand(this, [...args]);
return this;
}
/**
* Performs Typewriter's `write` operation for each message.
* @param messages List of arbitrary messages.
*/
public write(...messages: string[]): this {
this.getSpinwriter().stop();
messages.forEach((message) => {
this.getTypewriter().write(message);
});
return this;
}
/**
* Performs Typewriter's `write` operation for each message end `break` method
* at the end.
* @param messages List of arbitrary messages.
*/
public print(...messages: string[]): this {
return this.write(...messages).break();
}
/**
* Performs Typewriter's `break` operation.
*/
public break() {
this.getSpinwriter().stop();
this.getTypewriter().break();
return this;
}
/**
* Writes a message as spinning animation message.
* @param message Arbitrary spinner label.
*/
public spin(message: string) {
this.getSpinwriter().start();
this.getSpinwriter().write(message);
return this;
}
/**
* Returns a new Command instance which is the exact copy of the original.
* @param recipe Command recipe.
*/
public clone(recipe?: CommandRecipe<Context>): this {
return new (this.constructor as any)({
name: this.name,
description: this.description,
summary: this.summary,
options: this.options,
commands: this.commands,
resolver: this.resolver,
...recipe,
}, {
...this.__config,
});
}
}
/**
* Loops through command-line arguments until it finds the last command, then
* performs the command resolver or throws an error.
* @param command Command to process.
* @param args List of command-line arguments.
*/
async function performCommand(command: Command<any>, args: string[]) {
if (!command) {
throw new RuntimeError(ErrorCode.INVALID_COMMAND);
} else if (/^-\w|^--\w/.test(args[0]) || args.length === 0) {
return resolveCommand(command, args);
} else {
return performCommand(
command.commands.map((c) => realize(c)).find((c) => c.name === args[0]),
args.slice(1),
);
}
}
/**
* Executes command resolver.
* @param command Command to process.
* @param args List of command-line arguments.
*/
async function resolveCommand(command: Command<any>, args: string[]) {
const tail = readTail(args);
const options = await Promise.resolve().then(() => {
return readOptions(command, args);
}).then((data) => {
return sanitizeOptions(command, data);
});
return command.resolver.call(command, { options, tail }, command);
}
/**
* Returns options object with loaded values that are present among the
* provided command-line arguments.
* @param command Command to process.
* @param args List of command-line arguments.
*/
function readOptions(command: Command<any>, args: string[]): {[key: string]: any} {
const data = {};
args = args.indexOf('--') >= 0 ? args.slice(0, args.indexOf('--')) : args; // remove tail
command.options.map((o) => realize(o)).forEach(({ name, alias }) => {
let value = readOptionValueByName(name, args);
if (typeof value === 'undefined') {
value = readOptionValueByAlias(alias, args);
}
if (typeof value !== 'undefined') {
data[name] = value;
}
});
return data;
}
/**
* Validates command line options then returns serialized options object or
* throws when the first property validation fails.
* @param command Command to process.
* @param data Options data object.
*/
async function sanitizeOptions(command: Command<any>, data: {[key: string]: any}): Promise<{[key: string]: any}> {
const Model = createModelClass(command.options);
const model = new Model(data);
try {
await model.validate();
return model.serialize();
} catch (e) {
await model.handle(e);
throw new ValidationError(model);
}
}
/**
* Reads option value by option name from a list of command-line arguments.
* @param name Option name.
* @param args List of command-line arguments.
*/
function readOptionValueByName(name: string, args: string[]) {
const item = args.find((a) => a === `--${name}` || a.indexOf(`--${name}=`) === 0);
if (item) {
return item.indexOf(`--${name}=`) === 0 ? item.split('=', 2)[1] : true;
} else {
return undefined;
}
}
/**
* Reads option value by option alias from a list of command-line arguments.
* @param alias Option alias.
* @param args List of command-line arguments.
*/
function readOptionValueByAlias(alias: string, args: string[]) {
const index = alias ? args.indexOf(`-${alias}`) : -1;
if (index >= 0) {
const value = args[index + 1];
return /^-\w|^--\w/.test(value) ? true : value;
} else {
return undefined;
}
}
/**
* Reads command-line tail (string defined after `--`).
* @param args List of command-line arguments.
*/
function readTail(args: string[]) {
const index = args.indexOf('--');
return index >= 0 ? args.slice(index + 1).join(' ') : null;
}