Code Monkey home page Code Monkey logo

bandersnatch's Introduction

bandersnatch npm

program()
  .default(
    command()
      .description("What is bandersnatch?")
      .action(() => console.log(description)),
  )
  .run();
$ ./bandersnatch
Simple and intuitive yet powerful and versatile framework for Node.js CLI programs

Features

  • 🌊 Fluid syntax, intuitive to use
  • 🔜 Autocompletion of commands, arguments, options and choices
  • ➰ Built-in REPL for interactive programs with 🔙 Command history
  • 💬 Can prompt for missing arguments
  • 🤯 Built for TypeScript, command arguments are fully typed
  • 🪆 Supports singular (e.g. foo) and nested commands (e.g. foo bar)
  • 🏃 Argument types are guaranteed at runtime (coming soon)

Table of contents

Getting started

Installation

# Add dependency
yarn|npm add bandersnatch

Note

We recommend using an Active LTS or Maintenance LTS Node.js version. Current versions are tested, but not guaranteed to work.

Simple example

Let's create a simple program foo.js:

import { program, command } from "bandersnatch";

const cmd = command()
  .default()
  .description('Outputs "bar".')
  .action(() => console.log("bar"));

const app = program().description("foo").add(cmd);

app.run();

This creates a new program, adds a default command which logs "bar" to the stdout, and runs the program.

Now try your program by running it:

$ node foo.js
bar

Try running node foo.js --help to see the auto-generated help output:

$ node foo.js help
foo.js

Outputs "bar".

Commands:
  foo.js     Outputs "bar".                                            [default]

Options:
  --help     Show help                                                 [boolean]
  --version  Show version number                                       [boolean]

Error handling

We first create a new program called cat.js which is a naive version of the cat program we all know:

import { readFileSync } from "fs";
import { program, command } from "bandersnatch";

const cat = command("cat")
  .description("Concatenate files")
  .argument("files", { variadic: true })
  .action(({ files }) =>
    console.log(
      files.reduce((str, file) => str + readFileSync(file, "utf8"), ""),
    ),
  );

program().default(cat).run();

Now try your program by running it:

$ node cat.js somefile
contents of somefile

However, when somefile doesn't exist, we get are faced with an ugly unhandled promise rejection warning/error (depending on the Node.js version you're using).

Let's fix that:

-program().default(cat).run()
+program()
+  .default(cat)
+  .run()
+  .catch((err) => {
+    console.error(`There was a problem running this command:\n${String(err)}`)
+    process.exit(1)
+  })

Which will yield:

$ node cat.js somefile
There was a problem running this command:
Error: ENOENT: no such file or directory, open 'somefile'

REPL

A program can also show an interactive REPL to make interacting with more complex programs easier and to enable autocompleting of commands and arguments.

Let's create a new program dice.js with a command to roll a dice:

import { program, command } from "bandersnatch";

async function rng(bounds) {
  const [min, max] = bounds;
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

const dice = program().add(
  command("roll")
    .option("min", { default: 1 })
    .option("max", { default: 6 })
    .action(async (args) => {
      console.log(await rng([args.min, args.max]));
    }),
);

dice.repl();

This code defines a program dice and a command roll with two options, both of which will inherit a default value. When the command is executed, it calls an async random number generator (async only for illustrative purposes) and writes its results to stdout.

The last line in our code runs the program as a interactive REPL, which means it won't accept any arguments on the command line, but render a prompt instead. This prompt will read any user input, parse it, and execute matching commands.

Try rolling the dice:

$ node dice.js
> roll
5

The REPL can autocomplete commands, arguments, options and choices. Try typing only the letter r and then hit TAB. This works for options as well:

$ node dice.js
> r
[TAB]
> roll -
[TAB]
> roll --m
[TAB] [TAB]
--min   --max

Prompt

Bandersnatch can also ask a user for input if arguments were not provided on the command line:

Let's say we want to write a program pizza.js which takes pizza orders:

import { program, command } from "bandersnatch";

const cmd = command()
  .argument("address", {
    prompt: "Your address",
  })
  .option("name", {
    description: "Your name",
    default: "anonymous",
    prompt: true,
  })
  .option("size", {
    description: "Choose pizza size",
    choices: ["small", "medium", "large"],
    default: "medium",
    prompt: true,
  })
  .option("toppings", {
    description: "Pick some toppings",
    choices: ["mozzarella", "pepperoni", "veggies"],
    default: ["mozzarella"],
    prompt: true,
  })
  .option("confirmed", {
    description: "Order pizza?",
    default: true,
    prompt: true,
  })
  .action((args) => {
    console.log(args);
  });

program().description("Order a pizza").default(cmd).run();

And run it:

$ node pizza.js
? Your address The Netherlands
? Your name Joram
? Choose pizza size small
? Pick some toppings veggies
? Order pizza? Yes
{
  name: 'Joram',
  size: 'small',
  toppings: [ 'veggies' ],
  confirmed: true,
  address: 'The Netherlands'
}

You can choose to specify parameters on the command line, in which case you won't get a prompt for these options:

$ node pizza.js "The Netherlands" --name Joram --confirmed
? Choose pizza size small
? Pick some toppings veggies
? Order pizza? Yes
{
  name: 'Joram',
  size: 'small',
  toppings: [ 'veggies' ],
  confirmed: true,
  address: 'The Netherlands'
}

Warning

Please note that even though --confirmed was specified on the command line, it was still being prompted. This is a known issue. In this case, the default value was the same as the input, in which case bandersnatch doesn't know whether a value was explicitly passed in or inherited from the default value.

TypeScript

Bandersnatch works perfectly well with non-TypeScript codebases. However, when you do use TypeScript the command arguments are fully typed.

Let's rename the example program to pizza.ts and add some minor type hints to illustrate this:

   .option('size', {
     description: 'Choose pizza size',
-    choices: ['small', 'medium', 'large'],
+    choices: ['small', 'medium', 'large'] as const,
     default: 'medium',
     prompt: true,
   })
   .option('toppings', {
     description: 'Pick some toppings',
-    choices: ['mozzarella', 'pepperoni', 'veggies'],
+    choices: ['mozzarella', 'pepperoni', 'veggies'] as const,
     default: ['mozzarella'],
     prompt: true,
   })

The first argument passed to the action handler function is now typed like this:

type Args = {
  address: string;
  name: string;
  size: "small" | "medium" | "large";
  toppings: ("mozzarella" | "pepperoni" | "veggies")[];
  confirmed: boolean;
};

Tip

More examples in the examples directory.

API

All methods are chainable unless the docs mention otherwise.

program(options)

Creates a new program. Options (object, optional) can contain these keys:

  • description (string, optional) is used in help output.
  • prompt (string, default: > ) use this prompt prefix when in REPL mode.
  • help (boolean, default: true) adds help and --help to the program which displays program usage information.
  • version (boolean, default: true) adds version and --version to the program which displays program version from package.json.
  • historyFile (string | null, default: {homedir}/.bandersnatch_history) is a path to the app history file. Set to NULL to disable.
  • exit (boolean | () => void, default: () => process.exit()) Specifies whether to add a default behaviour for an exit command. false disables the default implementation, a custom function will be installed as the actual handler.
  • parserConfiguration (object, optional) can be used to modify the parser configuration. For available options, see

program.description(description)

Sets the program description (string, required) used in help output.

program.prompt(prompt)

Use this prompt prefix (string, required) when in REPL mode.

program.add(command)

Adds a command to the program.

program().add(command(...))

program.default(command)

Adds a default command to the program. Shorthand for:

program().add(command(...).default())

program.run(command)

Uses process.argv or passed in command (string, optional) to match and execute command. Returns promise.

program()
  .add(command(...))
  .run()

program.repl()

Start a read-eval-print loop. Returns promise-like REPL instance.

program()
  .add(command(...))
  .repl()

program.runOrRepl()

Invokes run() if process.argv is set, repl() otherwise. Returns promise or promise-like REPL instance.

program()
  .add(command(...))
  .runOrRepl()

program.isRepl()

Returns true if program is running a REPL loop, false otherwise.

program.on(event, listener)

Attaches a listener function for the event. Currently, these events are supported:

// Fired before a command action is invoked
program().on("run", (cmd) => logger.debug(`Running ${cmd}`));

command(name, options)

Creates a new command.

  • Name (string, optional) is used to invoke a command. When not used as the default command, a name is required.
  • Options (object, optional) can contain these keys:
    • description (string) is used in help output.
    • hidden (boolean) hide command from help output and autocomplete.

command.description(description)

Sets the command description (string, required) used in help output.

command.hidden()

Hide command from help output and autocomplete.

command.argument(name, options)

Adds a positional argument to the command.

  • Name (string, required) is used to identify the argument.
  • Options can be provided to change the behavior of the argument. Object with any of these keys:
    • description (string) is used in help output.
    • optional (boolean) makes this argument optional.
    • variadic (boolean) eagerly take all remaining arguments and parse as an array. Only valid for the last argument.
    • type (string) one of "boolean"|"number"|"string" which determines the runtime type of the argument.
    • default (any) default value for the argument.
    • choices (array) any input value should be included in the array, or it will be rejected.
    • prompt (boolean|string) prompts for missing arguments. If it is true, it will use the arguments description or name as the question text. If it is a string, it will be used as the question text.
    • alias (string|array) alias or aliases for the argument.
    • coerce (function) transform function for this argument value (untyped).
    • requires (string|array) make another option or argument required if the argument is present
    • excludes (string|array) exclude another options or argument if the argument is present

command.option(name, options)

Adds an option to the command.

  • Name (string, required) is used to identify the option.
  • Options (object, optional) can be provided to change the behavior of the option. Object with any of these keys:
    • description (string) is used in help output.
    • type (string) one of "array"|"boolean"|"count"|"number"|"string" which determines the runtime type of the argument. Use count for the number of times an option was provided (e.g. verbosity levels).
    • default (any) default value for the argument.
    • choices (array) any input value should be included in the array, or it will be rejected.
    • prompt (boolean|string) prompts for missing arguments. If it is true, it will use the arguments description or name as the question text. If it is a string, it will be used as the question text.
    • alias (string|array) alias or aliases for the option.
    • coerce (function) transform function for this option value (untyped).
    • required (boolean) makes the option required.
    • requires (string|array) make another option or argument required if the option is present
    • excludes (string|array) exclude another options or argument if the option is present

command.add(command)

Adds a sub-command to the command.

command.default()

Mark command as default. Default commands are executed immediately and don't require a name.

command.action(function)

Function which executes when the command is invoked. Is called with these arguments:

  1. Args (object) is an object containing key/value pairs of parsed arguments and options.
  2. Command runner (function) can be invoked with one (string) parameter to execute another command.

Design principles

In general, bandersnatch is designed to create twelve-factor apps.

Errors

The bandersnatch API allows to catch errors in a promise-like way. The run and repl program methods return either a promise or promise-like object which can be used to handle program errors:

program()
  .default(
    command()
      .description("This command will always fail")
      .action(function () {
        throw new Error("Whoops");
      }),
  )
  .runOrRepl()
  .catch((error) => {
    console.error("[failed]", String(error));

    if (!app.isRepl()) {
      process.exit(1);
    }
  });

Output

Programs are encouraged to use the following conventions with regards to output, based on the POSIX standard.

  • When a program is designed to be used in a scripting environment and its output should be available as stdin for other programs, use stdout for printing output and stderr for diagnostic output (e.g. progress and/or error messages).
  • When a program is designed to be used as a service (twelve-factor app), use stdout/stderr as a logging mechanism for informative messages/error and diagnostic messages.

Bandersnatch has no built-in method for writing to stdout/stderr. Node.js provides everything you need.

Bundle

There are many options to bundle your application for distribution. We'll discuss a common pattern.

Tip

An example can be found in the examples/bundle directory.

Init a package.json if needed:

mkdir echo && cd echo
yarn init

Install dependencies:

yarn add bandersnatch
yarn add typescript @types/node pkg --dev

And create an example app in src/cli.ts:

import { program, command } from "bandersnatch";

export default program().default(
  command("echo")
    .description("Echo something in the terminal")
    .argument("words", { description: "Say some kind words", variadic: true })
    .action(console.log),
);

Building your app with TypeScript is very powerful, but runtime compilation is slow so we compile the code ahead of time.

Add a tsconfig.json, similar to:

{
  "include": ["./src"],
  "compilerOptions": {
    "target": "es2017",
    "module": "commonjs",
    "lib": ["es2017"],
    "declaration": true,
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "moduleResolution": "node"
  }
}

Add these scripts to your package.json:

 {
   "name": "echo",
   "version": "1.0.0",
   "main": "index.js",
   "license": "MIT",
+  "scripts": {
+    "prepublishOnly": "yarn build",
+    "build": "tsc",
+  },
   "dependencies": {
     "bandersnatch": "^1.0.0"
   },
   "devDependencies": {
     "pkg": "^5.3.1",
     "typescript": "^4.4.2"
   }
 }

And compile now by running yarn build.

Next, we need to create a simple entry point echo.js, which can be run with node:

#!/usr/bin/env node

require('./dist/cli').default.run()

To run your app, users may want to run yarn global add echo. For this to work, we need to make a small adjustment to package.json:

 {
   "name": "echo",
   "version": "1.0.0",
-  "main": "index.js",
+  "bin": "echo.js",
+  "files": [
+    "dist"
+  ],
   "license": "MIT",
   "scripts": {
     "prepublishOnly": "yarn build",
     "build": "tsc",
   },
   "dependencies": {
     "bandersnatch": "^1.0.0"
   },
   "devDependencies": {
     "pkg": "^5.3.1",
     "typescript": "^4.4.2"
   }
 }

You can now npm publish.

To create a binary (your app with Node.js bundled), add this script to package.json:

 {
   "name": "echo",
   "version": "1.0.0",
   "bin": "echo.js",
   "files": [
     "dist"
   ],
   "license": "MIT",
   "scripts": {
     "prepublishOnly": "yarn build",
     "build": "tsc",
+    "bundle": "yarn build && pkg -t host ."
   },
   "dependencies": {
     "bandersnatch": "^1.0.0"
   },
   "devDependencies": {
     "pkg": "^5.3.1",
     "typescript": "^4.4.2"
   }
 }

Tip

Omit -t host to create binaries for all platforms.

Run yarn bundle and then ./echo --help. 💪

Optionally deploy to GitHub, S3, etc. using your preferred CD method if needed.

Todo

See TODO.md

Contributing

Contributions are very welcome. Please use conventional commits.

Local install

# Clone and install
git clone [email protected]:hongaar/bandersnatch.git
cd bandersnatch
yarn install
yarn husky install

# Run an example
yarn start examples/foo.ts

Devcontainer

A devcontainer configuration is included in this repo to get started quickly.

Credits

©️ Copyright 2022 Joram van den Boezem
♻️ Licensed under the MIT license
💡 Inspired by vorpal
⚡ Powered by yargs and enquirer

bandersnatch's People

Contributors

axelrindle avatar dependabot[bot] avatar hongaar avatar huan avatar jq170727 avatar khill-fbmc avatar lgtm-com[bot] avatar renovate[bot] avatar semantic-release-bot avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar

bandersnatch's Issues

Action Required: Fix Renovate Configuration

There is an error with this repository's Renovate configuration that needs to be fixed. As a precaution, Renovate will stop PRs until it is resolved.

Error type: Cannot find preset's package (github>whitesource/merge-confidence:beta)

boolean type is not inferred from default

When specifying an option:

command.option("flag", {
  default: false
})

Typing works, but yargs type is not inferred.

e.g. when running with node app.js --flag x, the args.flag has a value of "x" instead of throwing an error (as it would with "type": "boolean")

Errors are printed to stdout even with a custom error handler

Hi,

When an error is thrown by the command action the error is logged to stdout before the custom catch function of the program.

This is the line which is printing the error before it is catched.

console.error(err)

I think it would be good if this could be turned off to be able to hide raw errors from the user.
Especially the default errorHandler is also logging errors with console.error in the repl.ts

console.error(reason)

Without a custom errorHandler the error is printed twice.

What do you think?

Error: command option with prompt leads to an Unknown argument Error

Hello,

if I try to run the following command in repl mode:

const connectCmd = command('connect', { description: 'connect' })
  .option('host', { description: 'hostname', type: 'string', prompt: true })
  .action(async (args) => {
      console.log('do something with args ' + args);
  });
 
 const repl = program({ help: true })
        .add(connectCmd);
 repl.repl();

It prompts me to add a value correctly but then it fails with the StackTrace below:
`

connect
? hostname ‣ asd
Error: Unknown argument: asd
at Program.failHandler (/node_modules/bandersnatch/lib/program.js:165:19)
at Object.fail (/node_modules/bandersnatch/node_modules/yargs/build/index.cjs:692:25)
at Object.unknownArguments (node_modules/bandersnatch/node_modules/yargs/build/index.cjs:1500:19)
at Object.runValidation [as _runValidation] /node_modules/bandersnatch/node_modules/yargs/build/index.cjs:2821:26)
at Object.parseArgs [as _parseArgs] (/node_modules/bandersnatch/node_modules/yargs/build/index.cjs:2764:26)
at Object.parse (/node_modules/bandersnatch/node_modules/yargs/build/index.cjs:2278:29)
at /node_modules/bandersnatch/lib/program.js:96:40
at new Promise ()
at Program.run (/node_modules/bandersnatch/lib/program.js:95:16)
at Repl.eval (/node_modules/bandersnatch/lib/repl.js:75:47)
✔ hostname · asd
do something with args [object Object]
`

Version:
1.3.8

Bundles example doesn't work with typed-emitter

Running the bundle example from the readme (also in the examples/bundle dir) yields this error:

[email protected]
Error! This experimental syntax requires enabling one of the following parser plugin(s): 'flow, typescript' (1:7)
bandersnatch/examples/bundle/node_modules/typed-emitter/index.d.ts`

This is caused by andywer/typed-emitter#12 and has since been fixed with andywer/typed-emitter#16 but it's still pending a NPM release. Once that's in, we should upgrade typed-emitter and that should resolve this issue.

Related to #295

Thanks @arbue for reporting this!

Explicitly provided args with default values are still being prompted

As mentioned in the readme (https://github.com/hongaar/bandersnatch#prompt):

⚠ Please note that even though --confirmed was specified on the command line, it was still being prompted. This is a known issue. In this case, the default value was the same as the input, in which case bandersnatch doesn't know whether a value was explicitly passed in or inherited from the default value.

See if we can somehow detect provided args with default values and skip prompt in that case.

`choices` as an array of objects?

image

Is this possible? I am currently showing the user a list like

123 - guy
456 - girl
789 - dog

and then in my action I am splitting that .split(" - ") to get what I need.

Is it possible to hide the ID like in the screenshot? so the user just sees the choices and not the IDs?

REPL history is not available

Due to the change in #188, REPL history is now lost after each command execution. Using ARROW-UP key doesn't work as expected.

Fix or add a global history object (so history remains available even after program restart)

[Feature Request] Missing the `pipe` feature

The pipe feature is one of my favorite of the Vorpal because it can let us chain the commands so that we can use simple commands to do complex tasks.

However, it seems that our Bandersnatch did not implement the pipe feature at all.

It would be very nice if we can have this feature!

Default to help if no command was provided

Pretty much what it says in the title, it would be nice if there was an option to have the program output the generated help if there was nothing passed to either the REPL or argv.

Command argument is not typed correctly

Hi there,

Argument type inferred from argument() is optional by default. As an example, in the below code, count is inferred as number | undefined. Shouldn't this be number since there is also optional option as well?

command("step", { description: "Tells the stepper motor to turn X steps" })
    .argument("count", {
        type: "number",
        description: "Number of steps to turn",
        prompt: "Enter the number of steps",
    })
    .option("delay_ms", { type: "number" })
    .action(async ({ count, delay_ms }) => await step(count, delay_ms));

Error: Not enough non-option arguments: got 0, need at least 1

Hi team.

Let me first say that this package is awesome. Simple, customisable, strongly typed... ❤️ This is nothing short of amazing.

I found a minor issue with it though: when an REPL is executed, if the user type the Up Arrow before any command is typed (which should theorically do nothing since the hirtoy is empty), the app throws the following error:

Error: Not enough non-option arguments: got 0, need at least 1

Here is the code I use to create my REPL:

import {program, command} from 'bandersnatch'

const rng = (bounds: [number, number]) => {
    const [min, max] = bounds;

    return Promise.resolve(Math.floor(Math.random() * (max - min + 1)) + min);
}

const dice = program({
    prompt: `${america('scrambledeggs')}:${componentName}$ `
}).add(command('roll')
    .option('min', {default: 1})
    .option('max', {default: 6})
    .action((args) => {
        rng([args.min, args.max]).then((r) => {
            console.log(r);
        })
    })
);

dice.repl();

It is just an async variant of the official dice example.

Async `choices` for `.argument()`?

Is it possible to fetch a list of users let say, and have that displayed as the choices? I get that this would need to be typed, by my own interface perhaps, but would make it easy do work based on the results of a fs or api call.

Automatically coerce types

Since all options/arguments can be typed, automatically coerce into the proper type and throw if it can't be done.

e.g.

command.option("count", {
  type: "number",
})

called with node app.js --count x returns NaN for args.count while we should throw here (invalid arg).

Sub Commands?

Before I start with my "issue" I want to thank you for all your work on this! It's fantastic and has really accelerated my CLI tool making. This is my second project using this library and I have found the need for sub-commands. I saw in the documentation that there is support for sub commands?

but when attempting this in vscode, I am getting no hints for .command()

I skimmed through the command.ts file to see if I could find it... No dice....

Suggestions?

Feature Request: Yargs parser configuration

The yargs package allows to configure the args parser to change the default behavior. https://github.com/yargs/yargs/blob/main/docs/advanced.md#customizing

It would be good if we could also pass the configuration via the ProgramOptions to yargs.

I have a concrete case for this in our project. We have options which are in camelCase e.g."--helloWorld". The yargs parser in the default configuration adds an alias "hello-world" to the args object:

{
"helloWorld": "Greetings",
'hello-world': "Greetings"
}

This can be avoided with the yargs parser config 'strip-dashed': true

For now I have patched the createYargsInstance() method like:

    createYargsInstance() {
         // omitted
        yargs.parserConfiguration({
            'strip-dashed':true
        });
        // Non-configurable options
          // omitted
    }

Apparently this breaks the autocompletion function. Maybe there is a better way...

But I think this feature is also useful to others as well. So long story short maybe the ProgramOptions can be extended with the yargs parser config options and pass it to the yargs parser.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.