using the cross-runtime template.
This is a super-lightweight framework for building text-based programs, like CLI applications.
There are two types of text-based apps:
-
Single Purpose (a command) These are tools which may have multiple configuration options, but ultimately only do one thing. Examples include node-tap, mocha, standard, prettier, etc.
For example, node-tap can be run on a file, using syntax like
tap [options] [<files>]
. Ultimately, this utility serves one purpose. It just runs a configured tap process. -
Multipurpose (a shell for multiple commands) Other tools do more than configure a script. Consider npm, which has several subcommands (like
install
,uninstall
,info
, etc). Subcommands often behave like their own single purpose tool, with their own unique flags, and even subcommands of their own. Docker is a good example of this, which has an entire series of management subcommands.
tl;dr Use this library to create multipurpose tools. Use @author.io/arg to create single purpose tools.
This framework is designed to support multipurpose CLI tools. At the core, it provides a clean, easily-understood, repeatable pattern for building maintainable multipurpose CLI applications.
Multipurpose tools require a layer of organizational overhead to help isolate different commands and features. This overhead is unnecessary in single purpose tools. Single purpose tools just need argument parsing, which the @author.io/arg does very well.
@author.io/arg
is embedded in this framework, making @author.io/shell
capable of creating single purpose tools, but it's a bit redundant.
Think about how your tooling evolves...
Sometimes single purpose tools grow into multipurpose tools over time. Tools which start out using the @author.io/arg
library be transitioned into multipurpose tools using @author.io/shell
, with reasonable ease. After all, they use the same code, just nicely separated by purpose.
npm install @author.io/node-shell
Please note, you'll need a verison of Node that support ESM Modules. In Node 12, this feature is behind the --experimental-modules
flag. It is available in Node 13+ without a flag, but your package.json
file must have the "type": "module"
attribute.
If you need to use the older CommonJS format (i.e. require
), run npm install @author.io/node-shell-legacy
instead.
CDN
import { Shell, Command } from 'https://cdn.pika.dev/@author.io/browser-shell/v1'
Also available from jsdelivr and unpkg.
npm options
If you wish to bundle this library in your build process, use the version most appropriate for your target runtimes:
npm install @author/shell
(source)npm install @author/browser-shell
(Minified ES Module)npm install @author/browser-shell-es6
(IIFE Minified Module - globally accessible)
Each distribution has a corresponding -debug
version that should be installed alongside the main module (the debugging is an add-on module). For example, npm install @author.io/node-shell-debug --save-dev
would install the debugging code for Node.
There is a complete working example of a CLI app (with a mini tutorial) in the examples directory.
This example imports the library for Node. Simply swap the Node import for the appropriate browser import if you're building a web utility. Everything else is the same for both Node and browser environments.
import { Shell, Command } from '@author.io/node-shell'
// Define a command
const ListCommand = new Command({
name: 'list',
description: 'List the contents of the directory.',
alias: 'ls',
// Any flag parsing options from the @author.io/arg library can be configured here.
// See https://github.com/author/arg#configuration-methods for a list.
flags: {
l: {
description: 'Long format'.
type: 'boolean',
default: false
},
rootDir: {
description: 'The root directory to list.',
aliases: ['input', 'in', 'src'],
single: true
}
},
handler (metadata, callback) {
// ... this is where your command actually runs ...
// Data comes from @author.io/arg lib. It looks like:
// {
// command: <Command>,
// input: 'whatever user typed after "command"',
// flags: {
// recognized: {},
// unrecognized: [
// 'whatever',
// 'user',
// 'typed'
// ]
// },
// valid: false,
// violations: [],
// flag (name) { return String }
// }
console.log(metadata)
// A single flag's value can be retrieved with this helper method.
console.log(metadata.flag('l))
// Execution callbacks are optional. If a callback is passed from the
// execution context to this handler, it will run after the command
// has finished processing
// (kind of like "next" in Express).
// Promises are also supported.
callback && callback()
}
})
const shell = new Shell({
name: 'myapp',
version: '1.0.0',
description: 'My demo app.',
commands: [
// These can be instances of Command...
list,
// or just the configuration of a Command
{
name: 'find',
description: 'Search metadoc for all the things.',
alias: 'search',
flags: {
x: {
type: 'string',
required: true
}
},
handler: (data, cb) => {
console.log(data)
console.log(`Mirroring input: ${data.input}`)
cb && cb()
}
// Subcommands are supported
// , commands: [...]
}
]
})
// Run a command
shell.exec('find "some query"')
// Run a command using a promise.
shell.exec('find "some query"').then(() => console.log('Done!))
// Run a command using a callback (the callback is passed to the command's handler function)
shell.exec('find "some query"', () => console.log('Handled!'))
// Run a command, pass a callback to the handler, and use a promise to determine when everything is done.
shell.exec('find "some query"', () => console.log('Handled!')).then(() => console.log('Done!))
// Output the shell's default messages
console.log(shell.help)
console.log(shell.usage)
console.log(shell.description)
Each command has a handler function, which is responsible for doing something. This command receives a reference to the parsed flags.
{
command: <Command>,
input: 'Raw string of flags/arguments passed to the command',
flags: {
recognized: {},
unrecognized: [
'whatever',
'user',
'typed'
]
},
flag (name) { return <Value> },
valid: false,
violations: []
}
- command is the command name.
- input is the string typed in after the command (flags)
- flags contains the parsed flags from the @author.io/arg library.
flag()
is a special method for retrieving the value of any flag (recognized or unrecognized). See below.- valid indicates whether the input conforms to the parsing rules.
- violations is an array of strings, where each string represents a violation of the parsing rules.
The flag()
method is a shortcut to help developers create more maintainable and understandable code. Consider the following example that does not use the flag method:
const cmd = new Command({
name: 'demo',
flags: {
a: { type: String },
b: { type: String }
},
handler: metadata => {
console.log(`A is "${metadata.flags.recognized.a}"`)
console.log(`B is "${metadata.flags.recognized.b}"`)
}
})
Compare the example above to this cleaner version:
const cmd = new Command({
name: 'demo',
flags: {
a: { type: String },
b: { type: String }
},
handler: metadata => {
console.log(`A is "${metadata.flag('a')}"`) // <-- Here
console.log(`B is "${metadata.flag('b')}"`) // <-- and here
}
})
While the differences aren't extreme, it abstracts the need to know whether a flag is recognized or not (or even exists).
When a command is called, it's handler function is executed. Sometimes it is desirable to pre-process one or more commands. The shell middleware feature supports "global" middleware and "assigned" middleware.
This middleware is applied to all handlers, unilaterally. It is useful for catching syntax errors in commands, preprocessing data, and anything else you may want to do before the actual handler is executed.
For example, the following middleware checks the input to determine if all of the appropriate flags have been set. If not, the violations are displayed and the handler is never run. If everything is correct, the next()
method will continue processing.
shell.use(function (metadata, next) {
if (!metadata.valid) {
metadata.violations.forEach(violation => console.log(violation))
} else {
next()
}
})
No matter which command the user inputs, the global middleware methods are executed.
This middleware is assigned to one or more commands. For example:
shell.useWith('demo', function (metadata, next) {
if (metadata.flag('a') === null) {
console.log('No "a" flag specified. This may slow down processing.')
}
next()
})
The code above would only run when the user inputs the demo
command (or and demo
subcommand).
It is possible to assign middleware to more than one command at a time, and it is possible to target subcommands. For example:
shell.useWith(['demo', 'command subcommand'], function (metadata, next) {
if (metadata.flag('a') === null) {
console.log('I hope you know what you are doing!')
}
next()
})
Notice the array as the first argument of the useWith
method. This middleware would be assigned to demo
command, all demo
subcommands, the subcommand
of command
, and all subcommands of subcommand
. If this sounds confusing, just know that middleware is applied to commands, including nested commands.
One development goal of this framework is to remain as lightweight and unopinionated as possible. Another is to be as simple to use as possible. These two goals often conflict with each other (the more features you add, the heavier it becomes). In an attempt to find a comfortable balance, some additional middleware libraries are available fo those who want a little extra functionality.