10月10, 2020

如何从零开始开发一个 node.js 命令行(cli)工具

如今基于 node.jscli 工具层出不穷,尤其以前端脚手架工具更迭最为频繁,纯构建工具有 gruntgulpwebpackparcelrullup 等,各前端框架也有各自的 cli 工具 create-react-appvue-cliangular-cli,各大厂的集成化解决方案 fisjdfumi 等。但是每个团队的应用场景不同,以上工具不一定完全满足需求,是时候自己动手开发一个定制化的 cli 工具了。

主要内容

  • cli 是如何执行命令的
  • 创建 npm
  • 创建可执行文件
  • 关于命令行参数
  • 运行环境检查
  • 更新检查
  • 测试和发布

要开发 cli 工具首先要知道 cli 是什么,以及如何在 cli 里执行自己开发的命令行工具。

cli 是如何执行命令的

命令行界面(英文名称:Command Line Interface),是一种通过命令行来交互的工具或者应用。不同的操作系统内置了不同的 cli

  • unix-like
    • bash
    • sh
    • csh
    • zsh
  • windows
    • cmd.exe
    • PowerShell

通常情况下,一个命令对应了系统中的一个可执行文件,比如:cdls。当用户在 cli 中输入要执行的命令时,cli 会去预先配置好的查找路径中去查找同名的可执行文件并运行它。查找路径通常存在一个叫 PATH 的环境变量里。

比如 osx 系统可以通过命令 $PATH 知道其内容:

$ $PATH
-bash: /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin

接下来看看如何创建这个可执行文件。

创建 npm

首先通过 npm init 命令来创建一个 npm 包:

$ mkdir cli-test
$ cd cli-test
$ npm init -y
Wrote to /cli-test/package.json:

{
  "name": "cli-test",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

创建可执行文件

  • cli-test 根目录新建一个文件 bin/index.js
#!/usr/bin/env node

...

注意第一行的 #!/usr/bin/env node#! 表示要指定脚本文件的解析程序,/usr/bin/env 表示要去哪里找解析程序,node 是解析程序的名字(表示这个要文件由 node.js 来运行)。

  • 并且在 package.json 中添加配置项:
{
  "bin": {
    "cli-test": "bin/index.js"
  }
}

指定在 cli 中的名称为 cli-test,可执行文件为 bin/index.js。当用户通过 npm install 安装这个模块的时候,npm 会根据不同的操作系统自动创建一个可执行文件,比如在 osx 中就会创建 /usr/local/bin/cli-test 文件并关联到该可执行文件。

  • bin/index.js 添加可执行权限(unix-like 系统):
chmod +x bin/index.js

通过上面的步骤,一个 cli 命令行工具就创建完成了,接下来就可以编写工具的具体执行逻辑了,bin/index.js 也是一个 node.js 模块,可以引用其它 node.js 模块而且不需要添加执行权限:

#!/usr/bin/env node

require('../cli.js')

关于命令行参数

通常命令行工具都会有很多功能,每个功能通过不同的命令来调用,并且每个功能还会接收不同的参数,比如常用的 vue-cli,在终端输入 vue 并回车就可以看到 vue-cli 所支持的所有命令及其可选参数:

$ vue
Usage: vue <command> [options]

Options:
  -V, --version                              output the version number
  -h, --help                                 output usage information

Commands:
  create [options] <app-name>                create a new project powered by vue-cli-service
  add [options] <plugin> [pluginOptions]     install a plugin and invoke its generator in an already created project
  invoke [options] <plugin> [pluginOptions]  invoke the generator of a plugin in an already created project
  inspect [options] [paths...]               inspect the webpack config in a project with vue-cli-service
  serve [options] [entry]                    serve a .js or .vue file in development mode with zero config
  build [options] [entry]                    build a .js or .vue file in production mode with zero config
  ui [options]                               start and open the vue-cli ui
  init [options] <template> <app-name>       generate a project from a remote template (legacy API, requires @vue/cli-init)
  config [options] [value]                   inspect and modify the config
  outdated [options]                         (experimental) check for outdated vue cli service / plugins
  upgrade [options] [plugin-name]            (experimental) upgrade vue cli service / plugins
  info                                       print debugging information about your environment

  Run vue <command> --help for detailed usage of given command.

process.argv

node.js 中可以使用 process.argv 数组来获取命令行输入的参数:

// bin/index.js
console.log(process.argv);
$ cli-test one two=three four
['/usr/local/bin/node', '/path/to/cli-test', 'one', 'two=three', 'four']

但是 process.argv 得到的内容不好区分命令参数以及参数的值,所以这里需要引入一个第三方模块 yargs-parser

const yArgsParser = require("yargs-parser");

// 这是命令
const command = process.argv[2];
console.log(command);
// one

// 这是参数
const args = yArgsParser(process.argv.slice(3));
console.log(args);
// args 是一个对象:
// { two: 'three', four: true }

帮助信息

每个命令行工具都应该有一个帮助信息展示功能,例如上文中输入 vue 不加任何命令和参数(也可以添加参数 -h 或者 --help),输出的内容就是 vue-cli 的帮助信息,里面包含了支持的所有 命令可选参数

if (!command) {
  console.log('显示帮助信息');
  return;
}

关于路径

在开发命令行工具时,需要关注两个路径信息:

  • 可执行脚本文件的路径
  • node.js 进程的当前工作路径

可执行脚本文件的路径 即当前脚本文件路径,比如 bin/index.js 的路径,通过 __dirname 可以获得该路径的值。

// bin/index.js
console.log(__dirname);
$ cli-test
/path/to/bin

通过这个路径可以读取或写入在本命令行工具安装路径里的文件,比如配置信息,缓存等。

node.js 当前工作路径 即用户在终端执行命令行工具的命令时所处的路径,通过 process.cwd() 可以获得该路径的值。

// bin/index.js
console.log(process.cwd());
$ cd /path/to/test
$ cli-test
/path/to/test

通过这个路径可以知道用户想要在什么位置使用该命令行工具,并且可以读取用户自定义的配置文件,比如 vue 的配置文件 vue.config.js

commander.js

如果觉得以上处理 命令参数 的方式很繁琐,又想快速上手制作出一个命令行工具,可以使用第三方模块 commander.js

commander.js 是一个完整的 node.js 命令行解决方案,灵感来自 Rubycommander。它有很详细的说明文档,并且有官方中文版,这里就不做过多介绍了。

用户行为交互

为了给用户提供更加友好的使用体验,通常会增加一些交互行为反馈功能,比如任务执行等待提示、输出结果高亮提示和用户输入提示等。

任务执行等待提示

ora 是一个不错的等待提示工具,也很容易上手:

const Ora = require("ora");
// 实例化一个 spinner
const spinner = new Ora();
// 设置文案
spinner.text = "Loading";
// 开始显示等待提示
spinner.start();
setTimeout(() => {
  // 显示成功信息并退出 spinner
  spinner.succeed();
}, 3000);

输出结果高亮

chalk 可以在命令行终端里定义字符串样式的模块,通过这个模块可以在终端输出各种样式的文本信息:

const chalk = require("chalk");
const log = console.log;

// 输出有带颜色的文本
log(chalk.blue("Hello") + " World" + chalk.red("!"));

// 输出带颜色和背景色的文本
log(chalk.blue.bgRed.bold("Hello world!"));

// 多段文本输出
log(chalk.blue("Hello", "World!", "Foo", "bar", "biz", "baz"));

// 嵌套样式
log(chalk.red("Hello", chalk.underline.bgBlue("world") + "!"));

// 还有更多样式比如:加粗、中划线、斜体等请参考官方文档

用户输入交互

inquirer 模块集成了常用的命令行工具的用户交互功能,比如:input, number, confirm, list, rawlist, expand, checkbox, password, editor 等。

  • 文本输入
const inquirer = require("inquirer");

(async function () {
  const answers = await inquirer.prompt([
    { name: "username", message: `Please enter your username` },
  ]);
  console.log(answers.username);
})();
  • 单项选择
const inquirer = require("inquirer");

(async function () {
  const answers = await inquirer.prompt([
    {
      name: "gender",
      message: `Please enter your gender`,
      type: "list",
      choices: ["male", "female"],
    },
  ]);
  console.log(answers.gender);
})();

更多功能请参考官方文档: https://github.com/SBoudrias/Inquirer.js#documentation

运行环境检查

有些命令行工具需要安装第三方工具或者在特殊的环境(比如:node.js 的版本在 v10 及以上)才能运行,所以在运行任何程序之前,需要检查用户当前的运行环境是否能够正常使用本工具。

node.js 版本检查

npm 提供了在 package.json 文件中配置 engines 来声明 node.js 的版本,也可以声明 npm 的版本:

{
  "engines": {
    "node": ">= 10.13.0",
    "npm": "~6.9.0"
  }
}

当用户在安装本命令行工具时,npm 检查当前的 node.js 版本,如果不满足要求则会警告提示,如果带上 engine-strict 参数则会直接报错。

当然,开发者也可以在运行命令行工具时用程序去检查,这里借助第三方模块 node-semver

const semver = require("semver");

const requiredVersion = require("../package.json").engines.node;
const nodeVersion = process.version;

if (!semver.satisfies(nodeVersion, requiredVersion)) {
  console.log("node.js 版本必须满足 " + requiredVersion + " 才能运行本工具");
  return;
}

其它环境检查

如果本工具还需要依赖其它环境,也可以使用类似上面的检查方法,只是各个工具的版本号获取方法可能稍有不同。也可以参考一些复杂的命令行工具,添加一个 doctor 命令(比如 cli-test doctor)专门做环境检查用。

更新检查

通常一个应用程序都不会只发布一个版本,而是会不断的迭代新的版本,当 npm 包发布新版本时用户是无法感知到的,所以需要在用户每次使用这个工具的时候,主动去检查线上最新的版本,如果用户使用的版本太旧就主动提示用户升级。

获取当前 npm 包的版本很容易,可以读取 package.json 文件中的 version 拿到。获取线上的最新版本号稍麻烦些,需要用到 node.jshttp 模块发起一个 http 请求到http://registry.npmjs.org/[模块名],返回结果是一个 json 字符串,里面包含了 dist-tags.latest 字段可以获取当前最新版本。

为了方便举例,这里使用到一个第三方 http 请求库 axios

const axios = require("axios");

module.exports = async (pkgName, currentVersion) => {
  const { data, status } = await axios.get(
    `http://registry.m.jd.com/${pkgName}`,
    {
      timeout: 1000,
    }
  );

  if (status === 200) {
    const latest = data["dist-tags"]["latest"].split(".");
    const current = currentVersion.split(".");

    if (latest[0] !== current[0]) {
      console.log("该模块进行了 Mayor 升级");
    } else if (latest[1] !== current[1] || latest[2] !== current[2]) {
      console.log("该模块版本已更新,请升级");
    }
  }
};

测试和发布

测试

命令行工具开发完成后需要测试它的功能是否正确,npm 提供了 npm link 命令模拟安装本地模块,也可以使用 yarn link

在发布之前通常要对源代码进行编译并且完整地跑一遍单元测试,编译和单元测试的内容因项目而异,不在这里做过多介绍。需要注意的一点时,为了防止发布时忘记了编译和测试代码,可以通过 package.json 中配置 scripts.prepublish 来解决。

{
  "scripts": {
    "compile": "run compile",
    "test": "run test",
    "prepublish": "npm run compile && npm run test"
}

发布

npm 提供了 dist-tag 来标记当前发布的版本,默认是 latest,可以自定义。比如要发布一个测试版本,可以使用 npm publish --tag next,通常情况下 npm install cli-test 只会安装 latest 标记的最新版本,要安装到 --tag next 的版本,需要指明安装的版本号:npm install cli-test@x.x.x或执行 npm install cli-test@next 指定要安装 --tag next 的最新版本。

参考资料

本文链接:https://www.chenliqiang.cn/post/node-js-cli-start-up.html

-- EOF --