- How to create a real world Node CLI app with Node
- IO: Timber
- Translation from: Aliyun Translation Group
- Text link: github.com/dawn-teams/…
- Translator: Jing Xin
- Proofreader: Yashi, Linguma
Build command line applications using Node
In the JavaScript development world, command-line applications have not received enough attention. In fact, most development tools should provide a command-line interface for developers like us to use, and the user experience should be comparable to that of a well-created Web application, such as a beautiful design, easy-to-use menus, clear error feedback, loading prompts, and progress bars.
There aren’t a lot of practical tutorials on how to build a command line interface using Node, so this article will start with a step-by-step application called outside-cli, based on a basic Hello World command application. It provides the current weather and forecasts for the next 10 days anywhere.
Tip: There are several libraries that can help you build complex command line applications, such as OClif, Yargs, and Commander, but we’ll keep external dependencies as few as possible for you to better understand the principles behind it. Of course, we assume that you already have a basic knowledge of JavaScript and Node.
An introduction to
As with any JavaScript project, the best practice is to create package.json and an empty entry file. No dependencies are needed yet, keep it simple.
package.json
{" name ":" outside - cli ", "version" : "1.0.0", "license" : "MIT", "scripts" : {}, "devDependencies" : {}, "dependencies" : {}}Copy the code
index.js
module.exports = () => {
console.log('Welcome to the outside!')
}
Copy the code
We will use the bin file to run the new program, and we will add the bin file to the system directory so that it can be called anywhere.
#! /usr/bin/env node require('.. / ') ()Copy the code
Have you never seen #! /usr/bin/env node ? It’s called a shebang. It tells the system that this is not a shell script and indicates that a different interpreter should be used.
The bin file needs to be kept simple because it is intended only to be used to call main functions, and all of our code should be placed outside of this file so that it is modular and testable and can be called in other code in the future.
In order to be able to run bin files directly, we need to give proper file permissions. If you are on UNIX, you just need to execute chmod +x bin/outside. Windows users are on their own, Linux subsystem is recommended.
Next, we will add the bin file to package.json, and then when we install the package globally (NPM install -g outline-cli), the bin file will be automatically added to the system directory.
package.json
{" name ":" outside - cli ", "version" : "1.0.0", "license" : "MIT", "bin" : {" outside ":" bin/outside "}, "scripts" : {}, "devDependencies": {}, "dependencies": {} }Copy the code
/bin/outside will print out the welcome message. Execute NPM link in your project root directory and it will establish a soft connection between the system path and your binary so that the outside command can run anywhere.
CLI applications are made up of arguments and instructions, and arguments (or “flags”) are values prefixed with one or two hyphens (such as -d, –debug, or –env Production) that are useful to applications. Instructions are all other values without flags.
Unlike directives, parameters are not required to be in a particular order. For example, to run outside Today Brooklyn, you must specify that the second directive should represent only regions. To use –, you must run outside Today –location Brooklyn, You can easily add more options.
To make the application more practical, we need to parse instructions and parameters and convert them to literal objects. We can do this manually using process.argv, but for now we need to install the first dependency of the project and let minimist do it for us.
npm install --save minimist
Copy the code
index.js
const minimist = require('minimist')
module.exports = () => {
const args = minimist(process.argv.slice(2))
console.log(args)
}
Copy the code
Tip: Since the first two arguments to process.argv are the interpreter and the binary file name, we use.slice(2) to remove the first two arguments and only care about the other commands passed in.
Executing outside Today now outputs {_: [‘today’]}. Outside today –location “Brooklyn, NY” will output {_: [‘today’], location: ‘Brooklyn, NY’}. We won’t have to dig any deeper into the use of parameters until we actually use location, but we know enough to implement the first directive.
Parameters of the grammar
This article will help you understand parameter syntax better. Basically, a parameter can have one or two hyphens followed by its value, which defaults to true when left blank. Single-hyphen parameters can also be abbreviated (-a – b-c or -abc correspond to {a: true, b: true, c:). True}).
If the parameter value contains special characters or Spaces, it must be enclosed in quotation marks. For example –foo bar corresponds to {: [‘baz’], foo: ‘bar’}, –foo “bar baz” corresponds to {foo: ‘bar baz’}.
It is a best practice to split up the code for each instruction and load it into memory as it is called, which helps reduce startup time and avoid unnecessary loading. This can be done by simply using switch in the main instruction code. With this setup, we need to write each instruction to a separate file and export a function, while passing arguments to each instruction function for later use.
index.js
const minimist = require('minimist') module.exports = () => { const args = minimist(process.argv.slice(2)) const cmd = args._[0] switch (cmd) { case 'today': require('./cmds/today')(args) break default: console.error(`"${cmd}" is not a valid command! `) break } }Copy the code
cmds/today.js
module.exports = (args) => {
console.log('today is sunny')
}
Copy the code
Now if you do outside Today, you’ll see today is sunny, and if you do outside foobar, it’ll say “foobar” is not a valid command. The current prototype is pretty good, but we need to use the API to get real weather data.
There are some commands and arguments that we want to include in every command line application: help, –help, and -h to display the help list; –version and -v display the version information of the current application. We should also display the help list by default when the directive is not specified.
Minimist automatically resolves arguments to key-value pairs, so running outside –version makes args.version equal to true. Set CMD variables to save the results of the help and version parameters in the program, and add two processing statements in the switch statement to achieve the above function.
const minimist = require('minimist') module.exports = () => { const args = minimist(process.argv.slice(2)) let cmd = args._[0] || 'help' if (args.version || args.v) { cmd = 'version' } if (args.help || args.h) { cmd = 'help' } switch (cmd) { case 'today': require('./cmds/today')(args) break case 'version': require('./cmds/version')(args) break case 'help': require('./cmds/help')(args) break default: console.error(`"${cmd}" is not a valid command! `) break } }Copy the code
When implementing the new directive, the format needs to be the same as the TODAY directive.
cmds/version.js
const { version } = require('.. /package.json') module.exports = (args) => { console.log(`v${version}`) }Copy the code
cmds/help.js
const menus = {
main: `
outside [command] <options>
today .............. show weather for today
version ............ show package version
help ............... show help menu for a command`,
today: `
outside today <options>
--location, -l ..... the location to use`,
}
module.exports = (args) => {
const subCmd = args._[0] === 'help'
? args._[1]
: args._[0]
console.log(menus[subCmd] || menus.main)
}
Copy the code
Now if you execute outside Help Today or outside Toady -h, you’ll see help for the Today directive. The same goes for outside or outside-h.
The current project setup is pleasant because when you need to add a new command, you just create a new command file, add it to the switch statement, and set a help message.
cmds/forecast.js
module.exports = (args) => {
console.log('tomorrow is rainy')
}
Copy the code
index.js
* / /... * case 'forecast': require('./cmds/forecast')(args) break *// ... *Copy the code
cmds/help.js
const menus = {
main: `
outside [command] <options>
today .............. show weather for today
forecast ........... show 10-day weather forecast
version ............ show package version
help ............... show help menu for a command`,
today: `
outside today <options>
--location, -l ..... the location to use`,
forecast: `
outside forecast <options>
--location, -l ..... the location to use`,
}
// ...
Copy the code
Some instructions may take a long time to execute. If you’re doing fetching data from the API, generating content, writing files to disk, or anything else that takes more than a few milliseconds, then you need to provide some feedback to the user that your program is still responding. You can use a progress bar to show the progress of an action, or you can display a progress indicator directly.
For the current application, there is no way to know the progress of API requests, so we can use a simple spinner to indicate that the application is still running. We next install two dependencies, AXIos for network requests and ORA to implement spinner.
npm install --save axios ora
Copy the code
Get the data from the API
Let’s start by creating a utility function that uses the Yahoo Weather API to get the weather for an area.
Note: The Yahoo API uses a very concise YQL syntax, so you don’t need to understand it, just copy it. Also, it’s the only weather API I’ve found that doesn’t require an API key.
utils/weather.js
const axios = require('axios')
module.exports = async (location) => {
const results = await axios({
method: 'get',
url: 'https://query.yahooapis.com/v1/public/yql',
params: {
format: 'json',
q: `select item from weather.forecast where woeid in
(select woeid from geo.places(1) where text="${location}")`,
},
})
return results.data.query.results.channel.item
}
Copy the code
cmds/today.js
const ora = require('ora') const getWeather = require('.. /utils/weather') module.exports = async (args) => { const spinner = ora().start() try { const location = args.location || args.l const weather = await getWeather(location) spinner.stop() console.log(`Current conditions in ${location}:`) Console. log(' \t${weather.condition.temp}° ${weather.condition.text} ')} catch (err) {spiner.stop () console.error(err) }}Copy the code
Now when you execute Outside Today –location “Brooklyn, NY”, you will first see a rapidly rotating spinner appear during the application request, followed by the weather information.
If you want to artificially slow things down, you can prefix the weather utility function with “await new Promise” (resolve => setTimeout(resolve, 5000)).
Very good! Let’s copy the above code to implement the FORECAST directive and then simply modify the output format.
cmds/forecast.js
const ora = require('ora') const getWeather = require('.. /utils/weather') module.exports = async (args) => { const spinner = ora().start() try { const location = args.location || args.l const weather = await getWeather(location) spinner.stop() console.log(`Forecast for ${location}:`) . The weather forecast. ForEach (item = > console. The log (` \ t ${item. Date} - Low: ${item. Low} ° | High: The ${item. High} ° | ${item. The text}} `)) catch (err) {spinner. Stop () the console. The error (err)}}Copy the code
Now when you execute outside Forecast –location “Brooklyn, NY”, you will see your weather forecast for the next 10 days. Let’s add the icing on the cake by using a utility function we wrote to automatically get the location based on the IP address when location is not specified.
utils/location.js
const axios = require('axios')
module.exports = async () => {
const results = await axios({
method: 'get',
url: 'https://api.ipdata.co',
})
const { city, region } = results.data
return `${city}, ${region}`
}
Copy the code
cmds/today.js & cmds/forecast.js
* / /... * const getLocation = require('.. /utils/location') module.exports = async (args) => { *// ... * const location = args.location || args.l || await getLocation() const weather = await getWeather(location) *// ... *}Copy the code
Now when you execute the command without adding the location parameter, you will see the weather information corresponding to the current location.
Error handling
We won’t go into the details of optimal error handling in this article (that will be covered in a later tutorial), but the most important thing to remember is to use the correct exit code.
If there is a serious error in your command line application, you should use process.exit(1). The terminal will sense that the application is not fully executed and will be notified via a CI program.
Next we create a utility function that will throw the correct exit code when running a nonexistent instruction.
utils/error.js
module.exports = (message, exit) => {
console.error(message)
exit && process.exit(1)
}
Copy the code
index.js
* / /... * const error = require('./utils/error') module.exports = () => { *// ... * default: error(`"${cmd}" is not a valid command! `, true) break *// ... *}Copy the code
finishing
The final step was to publish the library we had written to a remote package management platform, and since we were using JavaScript, NPM was the perfect fit. Now, we need to add some additional information to package.json.
{"name": "outside-cli", "version": "1.0.0", "description": "A CLI app that gives you the weather forecast", "license": "MIT", "homepage": "https://github.com/timberio/outside-cli#readme", "repository": { "type": "git", "url": "git+https://github.com/timberio/outside-cli.git" }, "engines": { "node": ">=8" }, "keywords": [ "weather", "forecast", "rain" ], "preferGlobal": true, "bin": { "outside": "bin/outside" }, "scripts": {}, "devDependencies" : {}, "dependencies" : {" axios ":" ^ 0.18.0 ", "minimist" : "^ 1.2.0", "ora" : "^ 2.0.0"}}Copy the code
-
Setting up engine ensures that the consumer has a newer version of Node. Since we are using async/await directly without compiling, we require Node version 8.0 and above.
-
Setting preferGlobal will prompt the user at installation time that it is better to install the library globally rather than as a local dependency.
That’s it for now, and you can now publish to the remote side for others to download. If you want to take it one step further and distribute to other package management tools such as Homebrew, you can check out PKG or Nexe, which can help you package your application into a separate binary.
conclusion
This article describes the code directory structure that all command line applications on Timber follow to help maintain organization and modularity.
For speed readers, we’ve also provided some key takeaways from this tutorial:
-
The Bin file is the entry point to the entire command line application, and its only responsibility is to call the main function.
-
Instruction files should not be loaded into main functions if they are not executed.
-
Always include help and version directives.
-
Instruction files need to be kept simple; their main responsibility is to call other utility functions and then present information to the user.
-
Always include some running instructions to the user.
-
The application should exit with the correct exit code.
I hope you now have a better understanding of how to create and organize command-line applications using Node. This article is just the beginning, but we’ll go on to understand how to optimize your design, generate ASCII art, add color, and more. The source code for this article is available on GitHub.