Clirio
A mini framework for node.js command-line interfaces based on TypeScript, decorators, DTOs
NOTE This lib is alpha quality. There is no guarantee it will be reliably. The documentation also needs to be corrected
Installation
npm install clirio
yarn add clirio
Peer dependencies
reflect-metadata@^0.1 joi@^17
Quick Start
For example to emulate git status
cli command with options - 3 easy steps to build an app
- Create Dto
import { Option } from 'clirio';
class GitStatusDto {
@Option('--branch, -b')
readonly branch?: string;
@Option('--ignore-submodules')
readonly ignoreSubmodules?: 'none' | 'untracked' | 'dirty' | 'all';
@Option('--short, -s')
readonly short?: boolean;
}
- Create module
import { Module, Command, Options } from 'clirio';
@Module()
export class GitModule {
@Command('git status')
public status(@Options() options: GitStatusDto) {
console.log(options);
}
}
- Configure a base class
import { Clirio } from 'clirio';
const clirio = new Clirio();
clirio.addModule(GitModule);
clirio.build();
Result
$ cli git status -b master --ignore-submodules all --short
{ branch: 'master', ignoreSubmodules: 'all', short: true }
Docs
The application structure should consist of the following parts:
- the base class -
Clirio
- modules (custom classes)
- actions (methods in class-modules with decorators )
Examples here
A starter kit is in progress
The base class
Clirio
- is the base class which configures the application and links modules
const cli = new Clirio();
cli.setModules([HelloModule, CommonModule, GitModule, MigrationModule]);
cli.build();
Methods
setConfig
Setting global configuration
cli.setConfig({
nullableOptionValue: true,
validateOptionsWithoutDto: true,
});
Param | Description | Default |
---|---|---|
nullableOptionValue | value conversion of command line options that have no value e.g. --verbose - initially it is null after conversion will be true
|
true |
validateOptionsWithoutDto | if dto options are not specified but options will be passed in the command, then there may be a validation error | true |
addModule
Adding one module
cli.addModule(PingModule);
setModules
Setting several modules
cli.setModules([HelloModule, CommonModule]);
setArgs
Arguments will be determined automatically but it is possible to set them manually
cli.setArgs(['git', 'add', 'test.txt', 'logo.png']);
onError
Callback for handling error
import chalk from 'chalk';
cli.onError((err: ClirioError) => {
console.log(chalk.red(err.message));
process.exit(9);
});
onSuccess
Callback for handling an success result
cli.onSuccess((data: ClirioSuccess) => {
const successMessage = 'The command has been executed successfully!';
console.log(chalk.green(data.message ?? successMessage));
process.exit(0);
});
onWarning
Callback for handling an warning result
cli.onWarning((data: ClirioWarning) => {
console.log(chalk.yellow(data.message));
process.exit(0);
});
onComplete
Callback for handling an complete result
cli.onComplete((data: ClirioComplete) => {
const message = data.message ?? 'Thanks!';
console.log(chalk.blue(message));
process.exit(0);
});
onDebug
Callback for handling an debugging error
cli.onDebug((err: ClirioDebug) => {
const output = err.format();
console.log(chalk.red(output));
process.exit(5);
});
Modules
Modules are custom classes with the @Module()
decorator (they can be considered as controllers).
An application can have either one or many modules. Each module contains actions (patterns for commands)
Example of common module
@Module()
export class CommonModule {
@Command('hello there')
public helloThere() {
// ...
}
@Command('migration run')
public migrationRun() {
// ...
}
}
As a result 2 commands will be available:
$ cli hello there
$ cli migration run
Using different modules to separate unrelated commands and structure the code
@Module('hello')
export class HelloModule {
@Command('there')
public helloThere() {
// ...
}
}
@Module('migration')
export class MigrationModule {
@Command('run')
public run() {
// ...
}
}
Actions
The actions are methods in class-modules with decorators
@Module()
export class HelloModule {
@Command('hello')
public helloThere() {
console.log('Hello! It works!');
}
@Command('bye')
public help() {
console.log('Bye! See you later');
}
@Empty()
public empty() {
console.log(chalk.yellow('You have not entered anything'));
}
@Failure()
public failure() {
console.log(chalk.red('You have entered a non-existent command'));
}
}
Decorator for command pattern
The @Command()
decorator takes command pattern
@Module()
export class MigrationModule {
@Command('migration init')
public initMigration() {}
@Command('migration run|up')
public runMigration() {}
@Command('migration to <name>')
public migrateTo() {}
@Command('migration merge <name1> <name2>')
public mergeMigrations() {}
@Command('migration delete <...names>')
public deleteMigrations() {}
}
Exact match
The pattern of one or more space-separated arguments. Exact match will work
@Command('hello')
@Command('hello there')
@Command('hello my friends')
Match variants
Using the |
operator to select match variants
@Command('hello|hey|hi')
@Command('migration run|up')
Pattern with value masks
Using the < >
operator to specify a place for any value
@Command('hello <first-name> <last-name>')
@Command('set-time <time>')
Pattern with rest values mask
Using the <... >
operator to specify a place for array of values
This kind of mask can be only one per command pattern
@Command('hello <...all-names>')
@Command('message <...words>')
To get the entered values you should use the @Params()
decorator and DTO, that is described in more detail below
Option match
This pattern is designed for special cases like "help" and "version". This is an exact match of the option key and value. Match variants can be separated by comma
@Command('--help, -h')
@Command('--version, -v')
@Command('--mode=check')
Decorator for empty input
The @Empty()
action decorator is a way to catch the case when nothing is entered
Each module can have its own @Empty()
decorator in an action
@Module()
export class CommonModule {
@Command('hello')
public hello() {}
@Empty()
public empty() {
console.log("You haven't entered anything");
}
}
$ cli
You haven't entered anything
When a module has a command prefix, it will be matched and ranked
@Module('migration')
export class MigrationModule {
@Command('init')
public initMigration() {}
@Empty()
public empty() {
console.log(
'The migration module requires additional instruction. Type --help'
);
}
}
$ cli migration
The migration module requires additional instruction. Type --help
Decorator for failure input
The @Failure()
action decorator is a way to catch the case when the specified command patterns don't match.
Each module can have its own @Failure()
decorator in an action
if this decorator is not specified, then a default error will be displayed
@Module()
export class CommonModule {
@Command('hello')
public hello() {}
@Failure()
public failure() {
console.log('There is no such a command!');
}
}
$ cli goodbye
There is no such a command!
When a module has a command prefix, it will be matched and ranked
@Module('migration')
export class MigrationModule {
@Command('init')
public initMigration() {}
@Failure()
public failure() {
console.log('The migration module got the wrong instruction');
}
}
$ cli migration stop
The migration module got the wrong instruction
Injecting data
Using special decorators to pass input data
@Module()
export class LocatorModule {
@Command('get-location <city>')
public getLocation(@Params() params: unknown, @Options() options: unknown) {
console.log(params);
console.log(options);
}
}
$ cli get-location Prague --format=DMS --verbose
{ city: "Prague" }
{ format: "DMS", verbose: true }
Instead of unknown types, you should use a DTOs in which the properties also have special decorators to have type checking and input validation. More detailed below
Passing command params
The "Params" term mean the values of the masks in the command pattern
The @Params()
decorator provided
For example
@Module()
export class HelloModule {
@Command('hello <first-name> <last-name>')
public hello(@Params() params: HelloParamsDto) {
console.log(params);
}
}
Here the second and third parts are masks for any values that the user enters
The hello
method will be called if the user enters a three-part command. The last 2 parts are passed to the params argument as keys and values
Params Dto
The @Param()
decorator for dto properties provided. It can take a key in an param mask to map DTO properties
export class HelloParamsDto {
@Param('first-name')
readonly firstName?: string;
@Param('last-name')
readonly lastName?: string;
}
$ cli hello Alex Smith
{ firstName: "Alex", lastName: "Smith" }
The @Param()
decorator may have no arguments. In this case there will be no key mapping
export class HelloParamsDto {
@Param()
readonly 'first-name'?: string;
@Param()
readonly 'last-name'?: string;
}
$ cli hello Alex Smith
{ "first-name": "Alex", "last-name": "Smith" }
Example with rest values mask
@Module()
export class GitModule {
@Command('git add <...all-files>')
public add(@Params() params: AddParamsDto) {
// Type checking works for "params" variable
console.log(params.allFiles);
}
}
class AddParamsDto {
@Param('all-files')
readonly allFiles: string[];
}
$ cli git add test.txt logo.png
['test.txt', 'logo.png']
Passing command options
The "Options" term mean arguments starting with a dash.
Each option is either a key-value or a key. If in the beginning 2 dashes is a long key if one dash is a short key which must be 1 character long: --name=Alex
, --name Alex
, -n Alex
, --version
, -v
The @Options()
decorator provided
@Module()
export class GitModule {
@Command('git status')
public status(@Options() options: GitStatusOptionsDto) {
console.log(options);
}
}
Options Dto
The @Option()
decorator for dto properties provided. It can accept key aliases (comma separated) to map DTO properties
class GitStatusOptionsDto {
@Option('--branch, -b')
readonly branch?: string;
@Option('--ignore-submodules, -i')
readonly ignoreSubmodules?: 'none' | 'untracked' | 'dirty' | 'all';
@Option('--short, -s')
readonly short?: boolean;
}
$ cli git status --branch=master --ignore-submodules=all --short
$ cli git status --branch master --ignore-submodules all --short
$ cli git status -b master -i all -s
Each input data will lead to one result:
{ branch: 'master', ignoreSubmodules: 'all', short: true }
The @Option()
decorator may have no arguments. In this case there will be no key mapping and no aliases
class GitStatusOptionsDto {
@Option()
readonly branch?: string;
@Option()
readonly 'ignore-submodules'?: 'none' | 'untracked' | 'dirty' | 'all';
@Option()
readonly short?: boolean;
}
$ cli git status --branch=master --ignore-submodules=all --short
{ branch: 'master', "ignore-submodule": 'all', short: true }
Array of values in options
By default, the command parser cannot determine whether an option is an array. You can specify this, in which case the same option names will be collected in an array, even if there is only one option
@Module()
export class Module {
@Command('model')
public model(@Options() options: ModelOptionsDto) {
console.log(options);
}
}
class ModelOptionsDto {
@Option('--name, -n', {
isArray: true,
})
readonly names: string[];
}
$ cli model --name Ford
{ names: ['Ford'] }
$ cli model --name Ford -n Tesla
{ names: ['Ford', 'Tesla'] }
Variable values in options
There is a special case for using variables. All variables will be collected in an object
@Module()
export class Module {
@Command('connect')
public connect(@Options() options: DbConnectOptionsDto) {
console.log(options);
}
}
class DbConnectOptionsDto {
@Option('--env, -e', {
variable: true,
})
readonly envs: Record<string, string>;
}
$ cli connect -e DB_NAME=db-name -e DB_USER=db-user
{ envs: { DB_USER: 'db-name', DB_USER: 'db-user' } }
Using Joi
All values that come out as a results of parsing the command are either strings or booleans To validate and convert to the desired type - use Joi and DTO type annotations
Clirio uses and re-exports the joi-class-decorators
package https://www.npmjs.com/package/joi-class-decorators
Joi options validation
import { Module, Command, Options } from 'clirio';
@Module()
export class GitModule {
@Command('git status')
public status(@Options() options: GitStatusDto) {
console.log(options);
}
}
import Joi from 'joi';
import { Option, JoiSchema } from 'clirio';
class GitStatusDto {
@Option('--branch, -b')
@JoiSchema(Joi.string().required())
readonly branch: string;
@Option('--ignore-submodules')
@JoiSchema(
Joi.string()
.valid('none' | 'untracked' | 'dirty' | 'all')
.optional()
)
readonly ignoreSubmodules?: 'none' | 'untracked' | 'dirty' | 'all';
@Option('--short, -s')
@JoiSchema(Joi.boolean().optional())
readonly short?: boolean;
}
$ cli git status --ignore-submodules
"branch" is required
$ cli git status --log=true
"log" is an unknown key
$ cli git status -b master --ignore-submodules all --short
{ branch: 'master', ignoreSubmodules: 'all', short: true }
Joi params validation
@Module()
export class GitModule {
@Command('git checkout <branch>')
public checkout(@Params() params: CheckoutParamsDto) {
console.log(params);
}
}
class CheckoutParamsDto {
@Param('branch')
@JoiSchema(Joi.string().required())
readonly branch: string;
}
$ cli git checkout develop
{ branch: 'develop' }
Joi validating and converting
Joi validates and converts input values that are originally string. That is a very useful feature.
Summation and concatenation examples
@Module()
export class SumModule {
@Command('sum <first> <second>')
public sum(@Params() params: SumParamsDto) {
console.log(params.first + params.second);
}
}
Without Joi
class SumParamsDto {
@Param()
readonly first: unknown;
@Param()
readonly second: unknown;
}
$ cli sum 5 15
'515'
$ cli sum 5 rabbits
'5rabbits'
With Joi
class SumParamsDto {
@Param()
@JoiSchema(Joi.number().required())
readonly first: number;
@Param()
@JoiSchema(Joi.number().required())
readonly second: number;
}
$ cli sum 5 15
20
$ cli sum 5 rabbits
"second" is not a number
Special cases
Help mode
Special case for the command as an option designed
@Module()
export class CommonModule {
@Command('-h, --help')
public help() {
console.log('Description of commands is here');
}
}
$ cli --help
Description of commands is here
Of course you can use other options and params
@Command('-m, --man')
@Command('help|h')
@Command('man <command>')
Helper decorator
The @Helper()
decorator provided to handle help mode
import { Module, Command, Description, Helper, ClirioHelper } from 'clirio';
@Module()
export class CommonModule {
@Command('hello there')
@Description('Say hello there')
public helloThere() {
// ...
}
@Command('-h, --help')
public help(@Helper() helper: ClirioHelper) {
const moduleDescription = helper.describeAllModules();
console.log(ClirioHelper.formatModuleDescription(moduleDescription));
}
}
The @Description()
decorator for module action provided to describe the command
The ClirioHelper
class provides methods for getting descriptions of commands and formatting
The helper.describeAllModules()
method provides description for all commands
helper.describeAllModules
The method returns array of objects
Key | Type | Description |
---|---|---|
command | string | full formed command |
moduleCommand | string | null | module-bound command |
actionCommand | string | action-bound command |
description | string | text from the @Description() decorator |
optionDescription | array of objects | description of command options in action |
optionDescription
Key | Type | Description |
---|---|---|
options | array of string | option aliases |
description | string | text from the @Description() decorator |
type | string | type of variable |
itemType | string | null | type of item variable if the base type is array |
You can format the received data custom or use the ClirioHelper.formatModuleDescription
static method
Hidden command in Helper
The @Hidden()
decorator for module action provided to hide description of the command
import { Module, Command, Hidden, Description } from 'clirio';
@Module()
export class Module {
@Command('debug')
@Hidden()
public debug() {
// ...
}
@Command('hello there')
@Description('Say hello there')
public helloThere() {
// ...
}
}
The helper.describeAllModules()
method will not provide description of the debug
command in this case
Version
import { Module, Command } from 'clirio';
@Module()
export class CommonModule {
@Command('-v, --version')
public version() {
console.log('1.3.1');
}
}
$ cli --version
1.3.1
Exceptions
Special exceptions designed to complete the script with the desired result
Exception | Description | Handler |
---|---|---|
new ClirioError(message: string) |
Error | cli.onError(callback) |
new ClirioSuccess(message?: string) |
Success | cli.onSuccess(callback) |
new ClirioWarning(message: string) |
Warning | cli.onWarning(callback) |
new ClirioComplete(message?: string) |
Complete | cli.onComplete(callback) |
new ClirioDebug(message: string) |
Debugging | cli.onDebug(callback) |
By default, all handlers in Clirio are configured, but you can override to your own callback for each ones Use one of the available exceptions to throw the desired event, after that the related callback will be called and the script will end
Examples
import { Module, Command, ClirioError } from 'clirio';
@Module()
export class CommonModule {
@Command('check')
public check() {
throw new ClirioError('Not working!');
}
}
const cli = new Clirio();
cli.addModule(CommonModule);
cli.onError((err: ClirioError) => {
console.log(chalk.red('An error occurred: ' + err.message));
});
cli.build();
$ cli check
An error occurred: Not working!
import { Module, Command, ClirioSuccess } from 'clirio';
@Module()
export class CommonModule {
@Command('start')
public start() {
throw new ClirioSuccess();
}
}
const cli = new Clirio();
cli.addModule(CommonModule);
cli.onSuccess((data: ClirioSuccess) => {
if (data.message) {
console.log(chalk.green(message));
} else {
console.log(chalk.green('Successfully!'));
}
});
cli.build();
$ cli start
Successfully!
Receipts
Array of values in options
import { Module, Command, Options } from 'clirio';
@Module()
export class Module {
@Command('model')
public model(@Options() options: ModelOptionsDto) {
console.log(options);
}
}
import Joi from 'joi';
import { Option, JoiSchema } from 'clirio';
class ModelOptionsDto {
@Option('--name, -n', {
isArray: true,
})
@JoiSchema(Joi.array().items(Joi.string()).required())
readonly names: string[];
}
Variable values in options
import { Module, Command, Options } from 'clirio';
@Module()
export class Module {
@Command('connect')
public connect(@Options() options: DbConnectOptionsDto) {
console.log(options);
}
}
import Joi from 'joi';
import { Option, JoiSchema } from 'clirio';
class DbConnectOptionsDto {
@Option('--env, -e', {
variable: true,
})
@JoiSchema(Joi.object().pattern(Joi.string(), Joi.string()))
readonly envs: Record<string, string>;
}
Example with rest values mask
import { Module, Command, Params } from 'clirio';
@Module()
export class GitModule {
@Command('git add <...all-files>')
public add(@Params() params: AddParamsDto) {
console.log(params.allFiles);
}
}
import Joi from 'joi';
import { Param, JoiSchema } from 'clirio';
class AddParamsDto {
@Param('all-files')
@JoiSchema(Joi.array().items(Joi.string()).required())
readonly allFiles: string[];
}
Contributing
Contributing flow is in progress