diff --git a/README.md b/README.md index 6a775b9e..8fb5cef6 100755 --- a/README.md +++ b/README.md @@ -43,7 +43,6 @@ intuitive to work with. - [`program.eval(command)`](#programevalcommand) - [`program.run(command)`](#programruncommand) - [`program.repl()`](#programrepl) - - [`program.yargsInstance()`](#programyargsinstance) - [`command(name, description)`](#commandname-description) - [`command.argument(name, description, options)`](#commandargumentname-description-options) - [`command.option(name, description, options)`](#commandoptionname-description-options) @@ -51,13 +50,15 @@ intuitive to work with. - [`command.default()`](#commanddefault) - [`command.action(function)`](#commandactionfunction) - [`runner`](#runner) + - [`runner.print(printer)`](#runnerprintprinter) - [`runner.then(function)`](#runnerthenfunction) - [`runner.catch(function)`](#runnercatchfunction) - - [`runner.print(printer)`](#runnerprintprinter) + - [`runner.print(printer)`](#runnerprintprinter-1) - [`printer`](#printer) - [`printer.write(string)`](#printerwritestring) - [`printer.error(Error)`](#printererrorerror) - [Bundle](#bundle) +- [Todo](#todo) - [Contributing](#contributing) - [License](#license) @@ -77,21 +78,61 @@ import { program, command } from 'bandersnatch' const echo = command('echo', 'Echo something in the terminal') .argument('words', 'Say some kind words', { variadic: true }) - .action(args => args.words.join(' ')) + .action(args => args.words.map(word => `${word}!`).join(' ')) program() - .add(echo) + .default(echo) .run() ``` And run with: ```bash -ts-node echo.ts +$ ts-node echo.ts Hello world +Hello! world! ``` _👆 Assuming you have `ts-node` installed._ +Let's dive right into some more features. This simple app has a single default +command which pretty prints JSON input. When invoked without input, it'll show +an interactive prompt: + +```ts +import { program, command } from '../src' + +const app = program('JSON pretty printer').default( + command() + .argument('json', 'Raw JSON input as string') + .option('color', 'Enables colorized output', { type: 'boolean' }) + .action(async args => { + const json = JSON.parse(args.json) + args.color + ? console.dir(json) + : console.log(JSON.stringify(json, undefined, 4)) + }) +) + +process.argv.slice(2).length ? app.run() : app.repl() +``` + +And run with: + +```bash +$ ts-node pretty.ts +> [0,1,1,2,3,5] +[ + 0, + 1, + 1, + 2, + 3, + 5 +] +``` + +Now, try typing `[0,1,1,2,3,5] --c` and then hit `TAB`. 😊 + ℹ More examples in the [examples](https://github.com/hongaar/bandersnatch/tree/alpha/examples) directory. ## API @@ -167,23 +208,26 @@ program() Start a read-eval-print loop. -#### `program.yargsInstance()` - -Returns internal `yargs` instance. Use with caution. +```ts +program() + .add(command(...)) + .repl() +``` ### `command(name, description)` Creates a new command. -- Name (string, optional) is used to invoke a command. When - not used as default command, name is required. +- Name (string, optional) is used to invoke a command. When not used as default + command, name is required. - Description (string, optional) is used in --help output. #### `command.argument(name, description, options)` Adds a positional argument to the command. -- Name (string, required) is used to identify the argument. +- Name (string, required) is used to identify the argument. Can also be an array + of strings, in which case subsequent items will be treated as command aliases. - Description (string, optional) is used in --help output. - Options can be provided to change the behaviour of the argument. Object with any of these keys: @@ -220,6 +264,30 @@ object containing key/value pairs of parsed arguments and options. Returned from `program().eval()`, can't be invoked directly. +#### `runner.print(printer)` + +Prints resolved and rejected command executions to the terminal. Uses the +built-in printer if invoked without arguments. + +```ts +const runner = program() + .default( + command().action(() => { + throw new Error('Test customer printer') + }) + ) + .eval() + +runner.print({ + write(str: any) { + str && console.log(str) + }, + error(error: any) { + console.error(`${red('‼')} ${bgRed(error)}`) + } +}) +``` + #### `runner.then(function)` Function is invoked when command handler resolves. @@ -398,8 +466,16 @@ Run `yarn bundle` and then `./echo --help`. 💪 Optionally deploy to GitHub, S3, etc. using your preferred CD method if needed. +## Todo + +- [ ] Better code coverage +- [ ] Choices autocompletion in REPL mode + ## Contributing +Contributions are very welcome. Please note this project is in a very early +stage and the roadmap is a bit foggy still... + ```bash # Clone and install git clone git@github.com:hongaar/bandersnatch.git @@ -410,6 +486,8 @@ yarn yarn start examples/simple.ts ``` +Please use [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/). + ## License Copyright (c) 2019 Joram van den Boezem. Licensed under the MIT license. diff --git a/examples/pretty.ts b/examples/pretty.ts new file mode 100644 index 00000000..433e1365 --- /dev/null +++ b/examples/pretty.ts @@ -0,0 +1,15 @@ +import { program, command } from '../src' + +const app = program('JSON pretty printer').default( + command() + .argument('json', 'Raw JSON input as string') + .option('color', 'Enables colorized output', { type: 'boolean' }) + .action(async args => { + const json = JSON.parse(args.json) + args.color + ? console.dir(json) + : console.log(JSON.stringify(json, undefined, 4)) + }) +) + +process.argv.slice(2).length ? app.run() : app.repl() diff --git a/examples/printer.ts b/examples/printer.ts index 45eadd30..0df56891 100644 --- a/examples/printer.ts +++ b/examples/printer.ts @@ -1,5 +1,5 @@ import { program, command } from '../src' -import { blue, bgMagenta } from 'ansi-colors' +import { red, bgRed } from 'ansi-colors' // All failures vanish into the void const failWithMeaning = () => process.exit(42) @@ -10,7 +10,7 @@ const printer = { str && console.log(str) }, error(error: any) { - const str = `${bgMagenta('Oh noes!')}\n${blue(error)}` + const str = `${red('‼')} ${bgRed(error)}` console.error(str) } } diff --git a/examples/repl.ts b/examples/repl.ts index 33f36ec8..835d378e 100644 --- a/examples/repl.ts +++ b/examples/repl.ts @@ -1,17 +1,19 @@ import { program, command } from '../src' +import { blue, red, dim } from 'ansi-colors' -const say = command('say', 'Say something to the terminal') - .argument('word', 'The word to say') - .argument('any', 'Maybe another', { optional: true }) - .argument('some', 'Say some words', { variadic: true, type: 'number' }) - .option('cache', 'Use cache', { type: 'boolean', demandOption: true }) +const echo = command(['echo', 'say'], 'Echo something to the terminal') + .argument('words', { variadic: true }) + .option('blue', { type: 'boolean' }) + .option('red', { type: 'boolean' }) .action(async function(args) { - console.log('Executing with', { args }) + const str = args.words.join(' ') + console.log(args.blue ? blue(str) : args.red ? red(str) : str) }) -program('simple cli app') - .add(say) +const app = program('simple repl app') + .add(echo) .withHelp() .withVersion() - .prompt('command:') - .repl() + .prompt(`${dim('command')} ${blue('$')} `) + +app.repl() diff --git a/package.json b/package.json index c9d4fc2c..56879714 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@types/yargs": "^13.0.3", "ansi-colors": "^4.1.1", "inquirer": "^7.0.0", + "string-argv": "^0.3.1", "yargs": "^15.0.2" }, "devDependencies": { diff --git a/src/autocompleter.ts b/src/autocompleter.ts new file mode 100644 index 00000000..aeac4926 --- /dev/null +++ b/src/autocompleter.ts @@ -0,0 +1,30 @@ +import { Program } from './program' + +export function autocompleter(program: Program) { + return new Autocompleter(program) +} + +export class Autocompleter { + constructor(private program: Program) {} + + completions(argv: string[]) { + return this.yargsCompletions(argv) + } + + private yargsCompletions(argv: string[]) { + return new Promise((resolve, reject) => { + const yargs = this.program.createYargsInstance() + + // yargs.getCompletion() doesn't work for our use case. + yargs.parse( + ['$0', '--get-yargs-completions', '$0', ...argv], + {}, + (err, argv, output) => { + if (argv.getYargsCompletions) { + resolve(output ? output.split('\n') : []) + } + } + ) + }) + } +} diff --git a/src/command.ts b/src/command.ts index d65eac12..0f4fd5d1 100644 --- a/src/command.ts +++ b/src/command.ts @@ -25,20 +25,21 @@ function isCommand(obj: Argument | Option | Command): obj is Command { return obj.constructor.name === 'Command' } -export function command(command?: string, description?: string) { +export function command( + command?: string | string[], + description?: string +) { return new Command(command, description) } export class Command { - private command?: string - private description?: string private args: (Argument | Option | Command)[] = [] private handler?: HandlerFn - constructor(command?: string, description?: string) { - this.command = command - this.description = description - } + constructor( + private command?: string | string[], + private description?: string + ) {} /* * This is shorthand for .add(argument(...)) @@ -162,7 +163,9 @@ export class Command { .join(' ') if (args !== '') { - return `${this.command} ${args}` + return Array.isArray(this.command) + ? [`${this.command[0]} ${args}`, ...this.command.slice(1)] + : `${this.command} ${args}` } return this.command diff --git a/src/index.ts b/src/index.ts index e1a2d621..e3c4be31 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export * from './argument' +export * from './autocompleter' export * from './command' export * from './option' export * from './printer' diff --git a/src/program.ts b/src/program.ts index 27ae3d68..725caaa3 100644 --- a/src/program.ts +++ b/src/program.ts @@ -1,5 +1,5 @@ import { Argv } from 'yargs' -import yargs from 'yargs/yargs' +import createYargs from 'yargs/yargs' import { yellow } from 'ansi-colors' import { Command, command } from './command' import { Repl, repl } from './repl' @@ -7,63 +7,94 @@ import { Arguments } from './command' import { isPromise } from './utils' import { runner, Runner } from './runner' -export function program(description?: string) { - return new Program(description) +type FailFn = (msg: string, err: Error, args: Arguments, usage?: string) => void + +type ProgramOptions = { + help?: boolean + version?: boolean + fail?: FailFn + prompt?: string + exitOnError?: boolean } -type FailFn = (msg: string, err: Error, args: Arguments, usage?: string) => void +export function program(description?: string, options: ProgramOptions = {}) { + return new Program(description, options) +} export class Program { - private yargs = yargs() - private promptPrefix: string | undefined - private failFn?: FailFn + private commands: Command[] = [] private replInstance?: Repl private runnerInstance?: Runner - constructor(description?: string) { - if (description) { - this.yargs.usage(description) - } + constructor( + private description?: string, + private options: ProgramOptions = {} + ) {} + + /** + * Create a new yargs instance. Not intended for public use. + */ + createYargsInstance() { + const yargs = createYargs() + + this.description && yargs.usage(this.description) + + // Help accepts boolean + yargs.help(!!this.options.help) + + // Version must be false or undefined + !!this.options.version ? yargs.version() : yargs.version(false) - // Some defaults - this.yargs.help(false) - this.yargs.version(false) - this.yargs.recommendCommands() - this.yargs.strict() - this.yargs.demandCommand() + // Non-configurable options + yargs.recommendCommands() + yargs.strict() + yargs.demandCommand() + + // Hidden completion command + yargs.completion('completion', false) // Custom fail function. // TODO current yargs types doesn't include the third parameter. - this.yargs.fail(this.failHandler.bind(this) as any) + yargs.fail(this.failHandler.bind(this) as any) + + // Exit on errors? + yargs.exitProcess(!!this.options.exitOnError) + + // Add commands + this.commands.forEach(command => { + command.toYargs(yargs) + }) + + return yargs } add(command: Command) { - command.toYargs(this.yargs) + this.commands.push(command) return this } default(command: Command) { - command.default().toYargs(this.yargs) + this.commands.push(command.default()) return this } prompt(prompt: string) { - this.promptPrefix = prompt + this.options.prompt = prompt return this } withHelp() { - this.yargs.help(true) + this.options.help = true return this } withVersion() { - this.yargs.version() + this.options.version = true return this } fail(fn: FailFn) { - this.failFn = fn + this.options.fail = fn return this } @@ -76,26 +107,30 @@ export class Program { // Set executor to promise resolving to the return value of the command // handler. this.runnerInstance = runner((resolve, reject) => { - this.yargs.parse(cmd, {}, (err, argv: Arguments, output) => { - // Output is a string for built-in commands like --version and --help - if (output) { - console.log(output) - } - - // TODO When is err defined? - if (err) { - console.error(err) - } - - if (isPromise(argv.__promise)) { - // Delegate resolve/reject to promise returned from handler - argv.__promise.then(resolve).catch(reject) - } else { - // Resolve with void if promise is not available, which is the case - // with e.g. --version and --help - resolve() + this.createYargsInstance().parse( + cmd, + {}, + (err, argv: Arguments, output) => { + // Output is a string for built-in commands like --version and --help + if (output) { + console.log(output) + } + + // TODO when is err defined? + if (err) { + console.error(err) + } + + if (isPromise(argv.__promise)) { + // Delegate resolve/reject to promise returned from handler + argv.__promise.then(resolve).catch(reject) + } else { + // Resolve with void if promise is not available, which is the case + // with e.g. --version and --help + resolve() + } } - }) + ) }) return this.runnerInstance.eval() @@ -112,10 +147,10 @@ export class Program { * Run event loop which reads command from stdin. */ repl() { - this.replInstance = repl(this, this.promptPrefix) + this.replInstance = repl(this, this.options.prompt) // Don't exit on errors. - this.yargs.exitProcess(false) + this.options.exitOnError = false // Add exit command this.add( @@ -124,28 +159,24 @@ export class Program { }) ) - this.replInstance.loop() - } - - /** - * Allow tweaking the underlaying yargs instance. - */ - yargsInstance() { - return this.yargs + this.replInstance.start() } private failHandler(msg: string, err: Error, yargs: Argv) { + // TODO needs more use-cases: only do something when msg is set, and have + // errors always handled in the runner? + if (this.replInstance) { - // In case we're in a REPL session, we don't want to exit the process - // when an error occurs. - this.replInstance.setError(msg ?? err.stack ?? err.message) + // In case we're in a REPL session, forward the message which may + // originate from yargs. Errors are handled in the runner. + msg && this.replInstance.setError(msg) } else { const args = yargs.argv const usage = (yargs.help() as unknown) as string const cb = () => { - if (this.failFn) { + if (this.options.fail) { // Call custom fail function. - this.failFn(msg, err, args, usage) + this.options.fail(msg, err, args, usage) } else { // Call default fail function. this.defaultFailFn(msg, err, args, usage) diff --git a/src/repl.ts b/src/repl.ts index db9437a4..bade7c4e 100644 --- a/src/repl.ts +++ b/src/repl.ts @@ -1,65 +1,65 @@ -import { createPromptModule, PromptModule } from 'inquirer' -import { Program } from './program' +import nodeRepl, { REPLServer } from 'repl' +import { CompleterResult } from 'readline' +import { Context } from 'vm' +import { parseArgsStringToArgv } from 'string-argv' import { red } from 'ansi-colors' +import { Program } from './program' +import { autocompleter, Autocompleter } from './autocompleter' -export function repl(program: Program, prefix: string = '>') { +export function repl(program: Program, prefix: string = '> ') { return new Repl(program, prefix) } export class Repl { - private program: Program - private prefix: string - private prompt: PromptModule + private server?: REPLServer private lastError: string | null = null + private autocompleter: Autocompleter - constructor(program: Program, prefix: string = '>') { - this.program = program - this.prefix = prefix - this.prompt = createPromptModule() + constructor(private program: Program, private prompt: string = '> ') { + this.autocompleter = autocompleter(program) } - async loop() { - await this.tick() - await this.loop() + async start() { + this.server = nodeRepl.start({ + prompt: this.prompt, + eval: this.eval.bind(this), + completer: this.completer.bind(this), + ignoreUndefined: true + }) } setError(err: string) { - // Only display one error per tick - if (!this.lastError) { - this.lastError = err - console.error(red(err)) - } + this.lastError = err } - private async tick() { - const stdin = await this.read() - this.lastError = null - await this.eval(stdin) - } + private async completer( + line: string, + cb: (err?: null | Error, result?: CompleterResult) => void + ) { + const argv = parseArgsStringToArgv(line) + const current = argv.slice(-1).toString() + const completions = await this.autocompleter.completions(argv) + let hits = completions.filter(completion => completion.startsWith(current)) - /** - * Prompt the user for a command. - */ - private async read() { - // Inquirers default behaviour is to prefix the message with a space. - // See https://github.com/SBoudrias/Inquirer.js/issues/677 - const answers = await this.prompt([ - { - type: 'input', - name: 'stdin', - message: this.prefix, - prefix: '', - suffix: '' - } - ]) - return answers.stdin as string - } + // Add trailing space to each hit + hits = hits.map(hit => `${hit} `) - private async eval(stdin: string) { - return this.program.run(stdin) + // Show all completions if none found + cb(null, [hits.length ? hits : completions, current]) } - private async print() { - // Here just for completeness. + private async eval( + line: string, + context: Context, + file: string, + cb: (err: Error | null, result: any) => void + ) { + this.lastError = null + const result = await this.program.run(line.trim()) + if (this.lastError) { + console.error(red(this.lastError)) + } + + cb(null, result) } } diff --git a/tests/autocomplete.spec.ts b/tests/autocomplete.spec.ts new file mode 100644 index 00000000..ea7ce6fb --- /dev/null +++ b/tests/autocomplete.spec.ts @@ -0,0 +1,17 @@ +import { + autocompleter as createAutocompleter, + Autocompleter, + program as createProgram, + command +} from '../src' + +test('autocompleter should return new Autocompleter object', () => { + const program = createProgram() + expect(createAutocompleter(program)).toBeInstanceOf(Autocompleter) +}) + +test('autocompleter should complete commands', () => { + const program = createProgram().add(command('test')) + const autocompleter = createAutocompleter(program) + expect(autocompleter.completions(['t'])).resolves.toContain('test') +}) diff --git a/tests/runner.spec.ts b/tests/runner.spec.ts index 895e5c79..899039e9 100644 --- a/tests/runner.spec.ts +++ b/tests/runner.spec.ts @@ -1,7 +1,5 @@ import { runner as createRunner } from '../src' -// TODO runner seems to be broken - test('eval (resolves)', () => { expect( createRunner(resolve => { @@ -42,15 +40,18 @@ test('catch', () => { ).resolves.toBe('bar') }) -test('finally', () => { - expect( - createRunner(resolve => { - resolve('test') +test('finally', async () => { + let counter = 0 + const result = await createRunner(resolve => { + resolve('test') + }) + .eval() + .finally(() => { + counter++ }) - .eval() - .finally(() => 'foo') - .promise() - ).resolves.toBe('foo') + .promise() + expect(result).toBe('test') + expect(counter).toBe(1) }) test('uses custom printer (resolves)', async () => { diff --git a/yarn.lock b/yarn.lock index 8f0d96a7..b4990cc8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3784,6 +3784,11 @@ stealthy-require@^1.1.1: resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b" integrity sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks= +string-argv@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da" + integrity sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg== + string-length@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/string-length/-/string-length-2.0.0.tgz#d40dbb686a3ace960c1cffca562bf2c45f8363ed"