lirantal / nodejs-cli-apps-best-practices
- воскресенье, 16 февраля 2020 г. в 00:21:37
The largest Node.js CLI Apps best practices list ✨
A collection of curated best practices on how to build successful, empathic and user-friendly Node.js Command Line Interface (CLI) applications.
A bad CLI can easily discourage users from interacting with it. Building successful CLIs requires attention to detail and empathy for the user in order to create a good user experience. It is very easy to get wrong.
In this guide I have compiled a list of best practices across areas of focus which aim to optimize for an ideal user experience when interacting with a CLI application.
My name is Liran Tal and I'm addicted to building command line applications.
Some of my recent work, building Node.js CLIs, includes the following open source projects:
|
Dockly Immersive terminal interface for managing docker containers and services |
npq safely install packages with npm/yarn by auditing them as part of your install process |
lockfile-lint Lint an npm or yarn lockfile to analyze and detect security issues |
is-website-vulnerable finds publicly known security vulnerabilities in a website's frontend JavaScript libraries |
This section deals with best practices concerned with creating beautiful and high-value user experience Node.js command line applications.
In this section:
Unix-like operating systems popularized the use of the command line and tools such as awk, sed. Such tools have effectively standardized the behavior of command line options (aka flags), options-arguments, and other operands.
Some examples of expected behavior:
[]) to indicate they are optional, or with angle brackets (<>) to indicate they are required.- may container one alphanumeric character.myCli -abc being equivalent to myCli -a -b -c.Command line power-users will expect your command line application to have similar conventions as other Unix apps.
A command line interface for your program is no different than a web user interface in the sense of doing as much as you can as the program author to ensure that it is being used successfully.
Optimize for successful interactions by building empathic CLIs that support the user. As an example, let's explore the case of the curl program that expects a URL as its primary data input, and the user failing to provide it. Such failure will lead to reading through a (hopefully) descriptive error messages or reviewing a curl --help output. However, an empathic CLI would have presented an interactive prompt to capture input from the user, resulting in a successful interaction.
It may happen that you find yourself needing to provide storage persistence for your CLI application, such as remembering a username, email, API token, or other preferences between multiple invocations of the CLI. Use a configuration helper that allows the app to persist such user settings.
Reference projects:
Most terminals used today to interact with command line applications support colored text such as these enabled by specially crafted ANSI encoded characters.
A colorful display in your command line application output may further contribute to a richer experience and increased interaction. That said, unsupported terminals may experience a degraded output in the form of garbled information on the screen. Furthermore, a CLI may be used in a continuous integration build job which may not support colored output.
Reference projects:
Rich interactivity can be introduced in the form of prompt inputs, which are more sophisticated than free text, such as dropdown select lists, radio button toggles, rating, auto-complete, or hidden password inputs.
Another type of rich interactivity is in the form of animated loaders and progress-bars which provide a better experience for users when asynchronous work is being performed.
Many CLIs provide default command line arguments without requiring any further interactive experience. Don't force your users to provide parameters that the app can work out for itself.
https://www.github.com), as well as source code (e.g: src/Util.js:2:75) - both of which a modern terminal is able to transform into a clickable link.
git.io/abc which requires your user to copy and paste manually.
If you are sharing links to URLs, or pointing to a file and a specific line number and column in the file, you can provide properly formatted links to both of these examples that, once clicked, will open up the browser, or an IDE at the defined location.
Aim to provide a "works out of the box" experience when running the CLI application.
Reference projects which are built around Zero configuration:
This section deals with best practices concerned with distributing and packaging a Node.js command line application in an optimal matter for consumers.
In this section:
A fast npm install for Node.js CLIs invoked with npx will provide a better user experience. This is made possible when the overall dependency, and transitive dependency, footprint is kept to a reasonable size.
A one-off global npm installation of a slow-to-install npm package will offer a one-off poor experience, but the use of npx by users to invoke executable packages will make the degraded performance, due to npx always fetching and installing packages from the registry, more significant and obvious.
npm-shrinkwrap.json as a lockfile to ensure that pinned-down dependency versions (direct and transitive) propagate to your end users when they install your npm package.
npm) will resolve them during installation, and transitive dependencies installed via version ranges may introduce breaking changes that you can't control, that may result in your Node.js CLI application failing to build or run.
Use the force shrinkwrap, Luke!
Typically, an npm package only defines its direct dependencies, and their version range, when being installed, and the npm package manager will resolve all the transitive dependencies' versions upon installation. Over time, the resolved versions of dependencies will vary, as new direct, and transitive dependencies will release new versions.
Even though Semantic Versioning is broadly accepted among maintainers, npm is known to introduce many dependencies for an average package being installed, which adds to the risk of a package introducing changes that may break your application.
The flip side of using npm-shrinkwrap.json is the security implications you are forcing upon your consumers. The dependencies being installed are pinned to specific versions, so even if newer versions of these dependencies are released, they won't be installed. This moves the responsibility to you, the maintainer, to stay up-to-date with any security fixes in your dependencies, and release your CLI application regularly with security updates. Consider using Snyk Dependency Upgrade to automatically fix security issues across your dependency tree. Full disclosure: I am a developer advocate at Snyk.
👍 TipUse
npm shrinkwrapcommand to generate the shrinkwrap lockfile, which is of the same format as that of apackage-lock.jsonfile.
References:
This section deals with best practices concerned with making your Node.js CLI seamlessly integrate with other command line tools, and following conventions that are natural for CLIs to operate in.
This section answers questions such as:
In this section:
$ curl -s "https://api.example.com/data.json" | your_node_cliIf the command line application works with data, such as performing some kind of task on a JSON file that is usually specified with --file <file.json> command line argument.
It is often useful for users of a command line application to parse the data and perform other tasks with it, such as using it to feed web dashboards, or email notifications.
Being able to easily extract the data of interest from a command line output provides a friendlier experience to users of your CLI.
Even though, from a program's perspective, the functionality isn't being stripped down and should execute well in different operating systems, some missed nuances may render the program inoperable. Let's explore several cases where cross-platform ethics must be honored.
You might need to spawn a process that runs a Node.js program. For example, you have the following script:
program.js
#!/usr/bin/env node
// the rest of your app codeThis works:
const cliExecPath = 'program.js'
const process = childProcess.spawn(cliExecPath, [])
This is better:
const cliExecPath = 'program.js'
const process = childProcess.spawn('node', [cliExecPath])
Why is it better? The program.js source code begins with the Unix-like Shebang notation, however Windows doesn't know how to interpret this due to the fact that Shebang isn't a cross-platform standard.
This is also true for package.json scripts. Consider the following bad practice
of defining an npm run script:
"scripts": {
"postinstall": "myInstall.js"
}
This is due to the fact that the Shebang in myInstalls.js will not help Windows
understand how to run this with the node interpreter.
Instead, follow the best practice of:
"scripts": {
"postinstall": "node myInstall.js"
}
Not all characters are treated the same across different shell interpreters.
For example, the Windows command prompt doesn't treat a single quote the same as a double quote, as would be expected on a bash shell, and so it doesn't recognize that the whole string inside a single quote belongs to the same string group, which will lead to errors.
This will fail in a Windows Node.js environment that uses the Windows command prompt:
package.json
"scripts": {
"format": "prettier-standard '**/*.js'",
...
}
To fix this so that this npm run script will indeed be cross-platform between Windows, macOS and Linux:
package.json
"scripts": {
"format": "prettier-standard \"**/*.js\"",
...
}
In this example we had to use double quotes and escape them with the JSON notation.
Paths are constructed differently across different platforms. When they are built manually by concatenating strings they are bound not to be interoperable between different platforms.
Let's consider the following bad practice example:
const myPath = `${__dirname}/../bin/myBin.js`It assumes that forward slash is the path separator where on Windows, for example, a backslash is used.
Instead of manually building filesystem paths, defer to Node.js's own path
module to do this:
const myPath = path.join(__dirname, '..', 'bin', 'myBin.js')Linux shells are known to support a semicolon (;) to chain commands to run
sequentially, such as: cd /tmp; ls. However, doing the same on Windows will fail.
Avoid doing the following:
const process = childProcess.exec(`${cliExecPath}; ${cliExecPath2}`)Instead, use the double ampersand or double pipe notations:
const process = childProcess.exec(`${cliExecPath} || ${cliExecPath2}`)Detect and support configuration setting using environment variables as this will be a common way in many toolchains to modify the behavior of the invoked CLI application.
Moreover, a CLI application may be invoked in a way that requires a dynamic environment variable setting to resolve configuration or flag values, in a way that doesn't allow passing command line arguments or simply makes defining this information via command line arguments veryrepetitive and cumbersome.
When both a command line argument and an environment variable configure the same setting, a precedence should be granted to environment variables to override the setting.
This section deals with best practices concerned with making a Node.js CLI application available to users who wish to consume it, but who are lacking the environment for which the maintainer designed the application.
In this section:
npm or npx available, and so won't be able to run your CLI application.
Installing Node.js CLI applications from the npm registry will typically be done with Node.js native toolchain such as npm or npx. These are common across JavaScript and Node.js developers, and are expected to be referenced within install instructions.
However, if you are targeting a CLI application to be consumed by the general public, regardless of their familiarity with JavaScript, or availability of this tooling, then distributing your CLI application only in the form of an install from the npm registry will be restricting. If your CLI application is intended to be used in a build or CI environment then those may also be required to install Node.js related toolchain dependencies.
There are many ways to package and distribute an executable, and containerizing it as a Docker container that is pre-bundled with your CLI application is an easily consumable alternative and dependency-free (aside of requiring a Docker environment).
It is common to provide a rich terminal display in the form of colorful output, ascii charts, or even animation on the terminal and powerful prompt mechanism. These may contribute to a great user experience for those who have a supported terminal, however it may display garbled text or be completely inoperable for those without it.
To enable users with an unsupported terminal to properly use the Node.js CLI application, you may choose to:
--json command line argument to force it to output raw data.👍 Tip
Supporting graceful degradation such as JSON output isn't only useful for
end-users and their unuspported terminals, but is also valuable for running
in continuous integration environments, as well as enabling users
the ability to connect your program's output with other program's input or
export data if needed.
Sometimes it may be necessary to specifically target older Node.js versions that are missing new ECAMScript features. For example, if you are building a Node.js CLI that is mostly geared towards DevOps or IT, they may not have an ideal Node.js environment with an up to date runtime. As a reference, Debian Stretch (oldstable) ships with Node.js 8.11.1.
If you do need to target old versions of Node.js such as Node.js 8, 6 or 4, all of which are End of Life, prefer to use a transpiler such as Babel to make sure the generated code is compliant with the version of the V8 JavaScript engine and the Node.js run-time shipped with those versions.
Another workaround is to provide a containerized version of the CLI to avoid old targets. See Section (4.1) Containerize the CLI.
Don't dumb down your program code to use an older ECMAScript language specification that matches unmaintained or EOL Node.js versions as this will only lead to code maintenance issues.
#!/usr/local/bin/node is only specific to your own environment and may render the Node.js CLI inoperable in other environments where the location of Node.js is different.
It may be an easy start to develop a Node.js CLI by running the entry point file as node cli.js, and later on adding #!/usr/local/bin/node to the top of the cli.js file, however the latter is still a flawed approach as that location of the node executable is not guaranteed for other users' environments.
It should be noted that specifying #!/usr/bin/env node as the best practice, is still making the assumption that the Node.js runtime is referenced as node and not nodejs or otherwise.
In this section:
As you choose to test the CLI by running it and parsing output, you may be inclined to grep for specific features to ensure that they exist in the output such as properly providing examples when the CLI is ran with no arguments. e.g:
const output = execSync(cli);
expect(output).to.contain("Examples:"));When tests will run on locales that aren't English-based, and if your CLI argument parsing library supports auto-detection of locales and adopting to it, then tests such as this will fail, due to language conversions from Examples to the locale-equivalent language being set as the default locale in the system.
This section deals with best practices concerned with making a Node.js CLI application available to users who wish to consume it but are lacking an ideal environment for which the maintainer designed the application.
In this section:
If possible, extend informational error messages to any information being displayed so these can be easily parsed and context is clear.
Ensure that, when error messages are returned, they include a reference number or specific error codes that can later be consulted. Much like HTTP status codes, so to do CLI applications require named or coded errors.
Example:
$ my-cli-tool --doSomething
Error (E4002): please provide an API token via environment variablesExample:
$ my-cli-tool --doSomething
Error (E4002): please provide an API token via environment variablesUse environment variables as well as command line arguments to set debug and turn on extended verbosity levels. Where it make sense in your code, plant debug messages that aid the user, and the maintainer, to understand the program flow, inputs and outputs, and other pieces of information that make problem solving easier.
Node.js CLI Apps Best Practices © Liran Tal, Released under CC BY-SA 4.0 License.
This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.