使用 Node.js 创建一个命令行(CLI)工具 (翻译)

1,511 阅读15分钟

原文链接:Creating a CLI tool with Node.js - LogRocket Blog

什么是 CLI 工具?

CLI 工具(命令行工具)允许你直接在你的终端或命令行提示符中运行某些任务或操作。可以使用不同的程序语言来创建 CLI 工具,方法之一是使用 Node.js。

本文中,你将会学习如何使用 Node.js 创建一个 CLI 工具,如何测试它,然后在 npm 上发布它。

我们将会创建一个名为 todos-cli 的工具,这个工具允许用户查看它们的 to-do 列表,可以向其中添加项目,然后将这些项目勾掉。

你可以在 GitHub Repository 上找到本教程的全部代码。

创建项目

首先,创建一个文件夹来放置 CLI 工具:

mkdir todos-cli
cd todos-cli

接下来,初始化我们的 Node.js 项目:

npm init // 会增加一个 package.json 文件

你需要根据要求输入一些信息。你也可以选择跳过这些步骤,只需要在命令后传入一个 -y 的参数就可以了:

npm init -y

使用 -y 命令会使用默认值来设置这些信息。完成之后,我们需要安装一些包来帮助创建 CLI 工具。它们是:

  1. commander:这个包使得创建 CLI 工具更简单。它提供的许多函数允许我们设置命令,选项等等。
  2. chalk:这个包允许我们在控制台输出不同颜色的信息。这将帮助我们的 CLI 工具看起来更好用更漂亮。
  3. conf:这个包允许我们在用户电脑上保存持久的信息。我们将使用它来保存用户的待办事项列表。

安装这些包需要执行:

npm i commander chalk conf

这些包安装完毕之后,我们就可以准备开始开发 CLI 工具了。

创建一个 CLI 工具

在项目根目录创建一个 index.js 文件。这将是 CLI 工具初始化它拥有的命令的主要入口。

注意:如果你使用 Windows 进行开发,确保行尾的结束字符设置为 LF 而不是 CRLF,否则工具将不能使用。大部分代码编辑器对此是有设置选项的。此外,在 Windows 中测试也可能无法进行,因此我建议可以使用其它操作系统,或使用 Windows 子系统——Linux(WSL)。不过,你最后发布的工具,将会在各种操作系统上运行,这没有任何问题。

为了确保你的 CLI 工具正确运行,在 index.js 文件的开头添加下列代码:

#! /usr/bin/env node

接下来,要创建具有基本配置和功能的 CLI,可以使用 commander。首先,让我们从 commander 引入 program

const { program } = require('commander')

要声明一个命令,我们需要使用下列函数:

  1. program.command:接收一个定义命令格式的字符串
  2. program.description:为用户描述命令。当用户使用选项 --help 执行工具时,会很有帮助
  3. program.option:这个命令如果有可使用的选项,这个功能指的就是这些可使用的选项
  4. program.action:该命令执行的操作,将会是个函数

我们将会使用这些函数来声明我们的命令。但是我们此次要创建的 CLI 工具都需要哪些命令呢?

我们需要下列命令:

  1. todos list:这个命令将会列出用户待办事项列表中的所有任务
  2. todos add:这个命令将会向用户的待办事项列表中添加一项新的任务
  3. todos mark-done:这个命令将会标记特定的任务或将列表中所有的任务标记为完成

创建 list 命令

list 命令只会展示用户之前添加的任务。它没有任何选项。用户只需在终端执行下列命令就能够运行它:

todos list

index.js 中,在我们之前添加的代码下添加下列代码:

program.command('list').description('List all the TODO tasks').action(list)

如你所见,我们使用 command 函数在 CLI 工具中声明命令。你传入的参数是一个字符串,表示这个命令期待的格式。当用户使用 --help 选项运行我们的工具时,我们使用 description 函数向用户描述这个命令。最后,我们将动作分配给一个名为 list 的函数,我们后续很快就会创建这个函数。

将我们的命令放置在不同的文件中可以使我们的代码更易读且更好维护。

现在,创建 commands/list.js 文件。此文件将保存用户在终端执行 todos list 命令时运行的函数。这个函数将从配置中检索任务列表并展示它们。

为了存储和检索任务项,我们将使用 conf 包。它包含以下两个方法:

  1. set:这将把我们需要的信息设置在一个特定的密钥下
  2. get:这将获取我们之前通过特定密钥设置的信息

因此,让我们开始在 commands/list.js 中请求和实例化 conf 吧:

const conf = new (require('conf'))()

接下来,我们需要实现 list 函数,我们将导出这个函数,以便在 index.js 中使用:

function list() {
}

list 函数不需要任何参数,因为 list 命令没有任何选项或参数。

在 list 函数内部,我们将只检索 todo-list 键下的数据,这个数据将是一个展示每个 TODO 任务的数组。todo-list 将是下列格式的一个对象数组:

{
    text, // 字符串,todo 任务的内容
    done, // 布尔值,todo 任务被标记为完成或未完成
}

既然我们已经知道了我们数据的结构,让我们回到 list 函数。我们需要做的第一件事就是获取待办事项任务的列表:

const todoList = conf.get('todo-list')

接着,如果用户的待办事项列表中有任务,我们将循环列表,并且使用绿色展示已完成任务,使用黄色展示未完成任务。我们还将使用蓝色来告知用户,每种颜色代表的含义。

如果用户的待办事项列表中没有任何任务,我们将给用户展示红色的指示消息,表明他们的列表中没有任务。

正如我们前面提到的,我们将使用 chalk 来在用户的终端中用不同的颜色区分不同的信息。因此,让我们在 commands/list.js 文件的开头,引入 conf 之后,再引入 chalk

const conf = new (require('conf'))()
const chalk = require('chalk')

// 其余代码

接着,在 list 函数内部,让我们添加上面提到的 if 条件:

const todoList = conf.get('todo-list')

if(todoList && todoList.length) {
    // 用户待办列表中有任务
} else {
    // 用户待办列表中没有任务
}

让我们先处理 else 部分。我们需要显示一条信息来告诉用户,你的待办列表中没有任务。我们需要使用 chalk 将此信息展示成红色:

else {
    // 用户待办列表中没有任务
    console.log(
        chalk.red.bold('You don\'t have any tasks yet.')
    )
}

从代码可以看出,我们可以用 chalk 中的 chalk.COLOR 输出不同颜色的信息,也可以使用 chalk.COLOR.bold 来加粗信息。COLOR 可以是红色,蓝色,黄色,绿色等等。

list 函数的一部分已经完成。另一部分是当用户有待办列表时展示其中的任务。首先,我们将给用户展示一段详细说明任务颜色含义的信息:

if(todoList && todoList.length){
    console.log(
        chalk.blue.bold('Tasks in green are done. Tasks in yellow are still not done.')
    )
}

在这里,我们用蓝色粗体展示它。

接下来,我们将循环 todoList,检查每项任务的完成状态,如果已完成,用绿色展示,如果未完成,用黄色展示:

todoList.forEach((task, index) => {
    if (task.done) {
        console.log(
            chalk.greenBright(`${index}.${task.text}`)
        )
    } else {
        console.log(
            chalk.yellowBright(`${index}.${task.text}`)
        )
    }
})

我们的 list 函数完成!最后,为了能在 index.js 中使用它,让我们导出这个函数:

module.exports = list

commands/list.js 文件中全部代码如下:(原文写的 command/list.js 应该是错了)

const conf = new (require('conf'))()
const chalk = require('chalk')

function list() {
    const todoList = conf.get('todo-list')
    
    if(todoList && todoList.length) {
        // has
        console.log(chalk.blue.bold('Task in green are done. Tasks in yellow are still not done.'))
        todoList.forEach((task, index) => {
            if (task.done) {
                console.log(
                    chalk.greenBright(`${index}.${task.text}`)
                )
            } else {
                console.log(
                    chalk.yellowBright(`${index}.${task.text}`)
                )
            }
        })
    } else {
        // does not have
        console.log(chalk.red.bold('You don\'t have any tasks yet.'))
    }
}

module.exports = list

让我们回到 index.js 文件。我们只需要引入 list 函数:

const list = require('./commands/list')

然后,在文件末尾添加下句:

program.parse()

这对于 commander 来说是必须的。一旦我们完成命令声明,我们就需要解析用户输入,以便 commander 能找出用户正在运行哪个命令并且执行它。

测试 CLI 工具

我们的 CLI 工具现在可以测试了。测试的第一步就是向 package.json 文件中添加下面的 key

"bin": {
    "todos": "./index.js"
}

当通过我们的 todos-cli 命令行运行命令时,将在终端使用 todos 命令。你可以把它改成任何你想要的命令。我们将它指向 index.js,因为这是我们主要入口文件。

这个步骤不仅对工具测试很重要,对后续的发布也很重要。因此,确保一定要添加进去。

接下来,我们将运行下面的命令,来在我们的机器上全局安装软件包:

npm i -g

一旦安装完成,我们就可以在终端运行我们的工具了!让我们通过下面的命令来测试:

todos --help

你将会看到有关 CLI 工具的信息,并且你也能看到位于 Command 下的 list 命令:

image.png

现在,让我们试着运行我们的 list 命令:

todos list

它将向我们显示,当前没有任何任务。让我们现在开始实现一个添加任务的新命令。

add 命令

add 命令接收一个入参,就是任务的文本内容。下面就是这个命令执行时的样例。

todos add "Make Dinner"

Make Dinneradd 命令的入参,代表新增任务的文本内容。由于入参中间有空格,因此在入参两边使用双引号。你也可以尝试使用 \ 来避免空格(todos add Make\dinner)果任务文本不包含空格,那么引号就是非必须的。

为了新添加一个命令,在 index.js 文件中,list 命令的定义下,program.parse() 之前,添加下列语句:

program.command('add <task>').description('Add a new TODO task').action(add)

如你所见,我们给 command 函数传递参数 add <task><task> 是用户传递的入参。在 commander 中,当入参为必选时,我们使用 <ARG_NAME>,如果它是一个可选入参(可以不传),则使用 [ARG_NAME]。另外,你传递的参数的名称 task 就是传递给 action 函数的参数名称。

现在,我们需要实现 add 函数。就如我们之前创建 list 函数那样,让我们创建一个新文件 commands/add.js,文件内容如下:

const conf = new (require('conf'))()
const chalk = require('chalk')

function add(task) {
}

module.exports = add

注意,我们将 task 传递给 add 函数,这个 task 其实是用户传递进来的。

add 函数接收 task 的值,并且通过 conf 将任务存储在 todos-list 数组中。然后,我们通过 chalk 向用户展示一条绿色的成功消息。

我们首先通过 conf 获取 todo-list,然后向数组中 push 一条新任务,然后使用 conf.settodo-list 设置一个新值。

下面是 add 函数的全部代码:

function add(task) {
    // 获取当前 todo-list 列表
    let todosList = conf.get('todo-list')

    if(!todosList) {
        // 给 todos-list 一个默认值
        todosList = []
    }

    // 向 todos-list push 一个新任务
    todosList.push({
        text: task,
        done: false
    })

    // 调用 conf 中的 set 设置 todos-list 的新值
    conf.set('todo-list', todosList)

    // 向用户展示信息
    console.log(chalk.green.bold('Task has been added successfully!'))
}

非常简单!创建 list 命令之后,一切就变得十分简洁且便于理解了。

现在,我们回到 index.js 文件,并且使用 require 引入我们刚刚创建的 add 函数:

const add = require('./commands/add')

让我们来测试它。在你的终端执行:

todos add "Make Dinner"

我们将获得绿色的信息“Task has been added successfully!”。为了缺人任务是否被正确添加,在终端运行:

todos list

你会看到你刚刚添加的任务。尝试再添加几个任务看看列表是否变长。

最后一个需要添加的命令就是 mark-done,这个命令会把任务标记为 done 已完成。

mark-done 命令

mark-done 命令默认情况下将所有任务标记为已完成。然而,如果我们传入 --tasks 选项,后面跟着至少一个想要标记为已完成的任务的索引,就只会把这些传入的任务标记为已完成。

下面是例子。

todos mark-done --tasks 1 2

为了教程的简单性,我们只使用任务的索引来标记已完成任务。在实际应用中,你应该给任务分配一个独一无二且随机的 ID 号。

让我们在 add 命令下声明一个新的命令:

program
    .command('mark-done')
    .description('Mark tasks done')
    .option('-t, --tasks <tasks...>', 'The tasks to mark done. If not specified, all tasks will be marked done.')
    .action(markDone)

上面的 description 个人认为应该是 Mark tasks done,而不应该是 Mark commands done

这个命令和之前两个命令最大的不同就是使用了 option 选项。第一个参数代表选项的格式。-t, --tasks 意思是用户可以使用 -t 或者 --tasks 来传递选项参数。<tasks...> 意味着可以是多个任务,但是由于我们使用了 <>,意思是至少包含一个。第二个参数是选项的描述。当用户执行 todos mark-done --help 时就可以展示描述。

接下来,我们创建 markDone 函数。就像我们之前做的一样,让我们创建 commands/markDone.js 文件,内容如下:

const conf = new (require('conf'))()
const chalk = require('chalk')

function markDone({tasks}) {
}

module.exports = markDone

如你所见,markDone 接受一个包含 tasks 属性的对象。如果用户传递 -t 或者 --tasks 选项,tasks 将会是包含用户传入任务的数组。否则,将会是 undefined。

我们在 markDone 函数内部需要做的就是从 conf 中获取 todo-list。如果结果非空,循环它。如果 tasks 是一个至少包含一个项目的数组,则只把用户传入的索引的任务标记为已完成。如果 tasks 是 undefined,则所有的任务都被标记为已完成。

下面是 markDone 函数的内容:

let todosList = conf.get('todo-list')

if(todosList) {
    // 循环 todo list 任务列表
    todosList = todosList.map((task, index) => {
        // 检查用户是否指定了要标记的任务
        if(tasks) {
            // 检查当前任务是否用户指定要标记的任务
            if(tasks.indexOf(index.toString()) !== -1) {
                // 仅把用户指定的任务标记为已完成
                task.done = true
            }
        } else {
            // 如果用户未指定任务,将所有指定为已完成
            task.done = true
        }

        return task
    })

    // 为 todo-list 设置新值
    conf.set('todo-list', todosList)
}

// 给用户展示信息
console.log(chalk.green.bold('Tasks have been marked as done successfully'))

我们在 map 中循环 todosList(如果非空)。然后,我们检查 tasks 是否有定义(意味着用户指定了要标记为已完成的任务)。

如果 tasks 有定义,通过检查当前任务项的索引是否存在于传入的 tasks 数组中,来判断,迭代中的当前任务项是否是用户传入的任务项。注意,我们使用 index.toString() 是因为 tasks 数组是用字符串来保存用户输入的索引的。如果索引存在于 tasks 数组中,标记为已完成,否则,不会有改变。

然而,如果 tasks 是未定义,那么如之前提到的,我们将把所有任务标记为已完成。一旦循环完成,所有列表更新完毕,就使用 conf.set 方法将这个新的待办数组储存在 todo-list 中。最后,给用户展示成功信息。

最终,让我们回到 index.js 文件,并且使用 require 引入 markDone 函数:

const markDone = require('./commands/markDone')

现在,可以测试它们了。执行下面的语句,将所有任务标记为已完成:

todos mark-done

如果一切正确,当你执行 todos list 命令时,你会看到所有的任务项都是绿色的。

接着,是这添加几个新的任务项,然后使用它们的索引,将某个任务标记为已完成,将单个任务标记为已完成的代码如下:

todos mark-done -t 1

或者同时标记多个任务:

todos mark-done -t 1 3 6

你可以尝试其它组合,然后用 todos list 命令检查哪些标记了已完成,哪些没有标记。

我们的 CLI 工具完成啦!todos-cli 现在允许用户添加任务,查看任务,并且标记任务为已完成。

下一个也是最后一步,就是发布你的 CLI 工具。

发布 CLI 工具

使用 Node.js 构建的 CLI 工具会作为一个包在 NPM 上发布。因此,如果你没有 NPM 账号的花,需要创建一个。

在你创建好 NPM 账号之后,在你项目的目录下的终端里,执行下面命令:

npm login

你会被要求输入你的用户名,密码,和邮箱。如果都正确,你就能正确登录。

接下来,执行下面的命令:

npm publish

这个命令将会把你的 CLI 工具公开的发布到 npm 仓储上。如果存在另一个具有相同名称的包,则可能会出错。如果出错了,你需要修改 package.json 中的 name 属性:

"name": "PACKAGE_NAME"

需要注意的是,PACKAGE_NAME 跟 CLI 的名字是不同的。PACKAGE_NAME 用于在你的机器上安装这个工具,但是在 bin 中指定的键名是用来在终端访问这个工具的。(比如 todos list 中的 todos 就是在 bin 中指定的名字,在终端输入 todos 就是访问这个待办事项的 CLI 工具)

如果 npm 中没有同名包,你的包将会是公开的,可以使用。执行下面语句来安装你的包:

npm i -g <PACKAGE_NAME>

PACKAGE_NAME 是你为包选择的名称。注意,如果在开发时你在包中使用了 npm i -g 命令,那么为了安装你发布的包,最好在工具目录下用 npm remove -g 命令暂时的移除它。

更新 CLI 工具包

如果后续你需要更新你的 CLI 工具包,你可以执行下面的语句:

npm version <UPDATE_TYPE>

<UPDATE_TYPE> 可以是下列之一:

  1. path:小更改。它只会增加版本号的最后一位数字。通常用来修复 bug,或者做出小的修正,这类不影响最终用户使用的工具或包
  2. minor:微小变更。它会增加版本号的第二位数字。通常用于包或者工具的微小更改,比如增加一些功能,但是原来功能保持不变
  3. major:主要更改。它会增加版本号的第一位数字。通常用于包或者工具的重大更改,这些一般会影响用户的最终使用

你可以在 About semantic versioning 了解更多版本控制的信息。

总结

恭喜,你已经学会了如何使用 Node.js 创建一个 CLI 工具。可能性无极限,所以去创造一些更棒的东西吧!