Nodejs 开发命令行工具(CLI)

今天学习一下如果开发Node命令行工具(CLI)

目录

准备

创建项目

初始化项目

创建可执行脚本

注册命令行命令

命令行参数处理

commander 设置命令参数选项

commander 设置子命令

命令行终端交互

inquirer 安装

inquirer 语法

question object

answer object

inquirer 使用示例

input 用户输入型

confirm 确认会话型

list 无序列表

rawlist 有序列表

expand 扩展列表

checkbox 复选框选择型

password 密码输入型

发布命令包

其他

参考资料


准备

没有Node环境的,自行去官网下载安装:下载 | Node.js

安装Node后,自带npm。以下是我的环境:

创建项目

创建项目并切换到项目目录

F:\Chen\Nodejs>mkdir baby && cd baby

初始化项目

npm init 初始化项目,-y或--yes,指定项目走默认配置

F:\Chen\Nodejs\baby>npm init -y
Wrote to F:\Chen\Nodejs\baby\package.json:

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

创建可执行脚本

创建一个bin文件夹,用来存放可执行命令对应的文件

F:\Chen\Nodejs\baby>mkdir bin && cd bin

创建可执行命令文件 baby.js

#!/usr/bin/env node
// baby.js
console.log("There is a new baby.");

#! /usr/bin/env node        首行shebang语句,指定使用node解释程序执行脚本。

在不同的操作系统中,node 命令行的位置不同,因此使用 env node 找到路径并执行

注册命令行命令

使用node命令执行脚本(node [script],script需要指定脚本路径)

F:\Chen\Nodejs\baby\bin>node baby
There is a new baby.

直接使用node命令执行脚本,每次执行脚本都需要指定脚本的具体位置,这显然很不方便。

可以在 package.json 中添加bin字段映射命令关键字与可执行文件,如下:

{
  ...
  "bin": {
    "baby": "./bin/baby.js"
  },
  ...
}

在通过npm install -g全局安装的时候,npm会symlink可执行文件到{prefix}文件夹(Windows系统),如果通过npm install本地安装的时候,npm会symlink可执行文件到./node_modules/.bin 文件夹。

但是npm install无论是全局安装还是本地安装,都是指从npm仓库下载并安装,我们开发的模块,并没有发布到npm仓库,如果想要调试或使用本地开发的模块,可以使用 npm link 安装本地模块。

F:\Chen\Nodejs\baby>npm prefix -g
C:\Users\cyinl\AppData\Roaming\npm

F:\Chen\Nodejs\baby>npm link

added 1 package, and audited 3 packages in 3s

found 0 vulnerabilities

F:\Chen\Nodejs\baby>dir C:\Users\cyinl\AppData\Roaming\npm

 C:\Users\cyinl\AppData\Roaming\npm 的目录

2023/04/05  10:21    <DIR>          .
2023/04/05  10:21    <DIR>          ..
2023/04/05  10:21               318 baby
2023/04/05  10:21               330 baby.cmd
2023/04/05  10:21               825 baby.ps1
2023/04/05  10:21    <DIR>          node_modules

F:\Chen\Nodejs\baby>dir C:\Users\cyinl\AppData\Roaming\npm\node_modules

 C:\Users\cyinl\AppData\Roaming\npm\node_modules 的目录

2023/04/05  10:21    <DIR>          .
2023/04/05  10:21    <DIR>          ..
2023/04/05  10:21    <JUNCTION>     baby [F:\Chen\Nodejs\baby]

在baby根目录下,执行npm link后,会在{prefix}/node_modules/<package>文件夹创建一个symlink链接,示例中是创建一个baby的symlink链接  F:\Chen\Nodejs\baby。同时会为package.json中bin字段指定的可执行文件创建一个可执行命令 存放在{prefix}/{name},示例中是 C:\Users\cyinl\AppData\Roaming\npm 的目录下的baby几个命令。

package.json 模块配置文件,各字段详细解释:package.json

bin 字段的详细用法,详细用法: package-json#bin

npm link 为本地模块创建全局链接,详细用法:npm-link

npm link 使用全局前缀,可以使用 npm prefix -g 查看全局前缀

npm prefix 详细用法:npm-prefix

关于npm使用的目录结构介绍,可参考:folders | npm Docs

此时我们就使用cmd窗口,在任何位置指定baby命令了,示例如下:

C:\Users\cyinl>baby
There is a new baby.

如果想取消链接,我们可以使用 npm unlink -g <package> 来删除链接,它会删除 npm link 生成的内容,当然你也可以手动删除这些文件,这里就不演示了。

命令行参数处理

我们使用过的很多命令行,都是可以接受参数,如 npm -v 可以查看安装的npm版本号,npm -h 可以查看npm的使用帮助信息。那么使我们自己的命令也有类型的功能呢?

在node脚本中,我们可以通过系统变量 process.argv 获取到命令行参数信息。

接下来验证下,修改下 baby.js 如下:

#!/usr/bin/env node
console.log(process.argv);

执行baby -v,输出信息如下: 

C:\Users\cyinl>baby -v
[
  'D:\\Program Files\\nodejs\\node.exe',
  'C:\\Users\\cyinl\\AppData\\Roaming\\npm\\node_modules\\baby\\bin\\baby.js',
  '-v'
]

其中process为node进程中的全局变量,process.argv为一数组,数组内存储着命令行的各个部分,argv[0]为node的安装路径,argv[1]为主模块文件路径,剩下为子命令或参数。

我们可以通过 package.json里的version拿到版本信息。再修改 baby.js 如下:

#!/usr/bin/env node
const pkg = require('../package.json');
const opt = process.argv[2];
switch (opt) {
    case '-v':
        console.log(pkg.version);
        break;
    default:
        //TODO
        break;
}

再执行baby -v命令,输出信息如下:

C:\Users\cyinl>baby -v
1.0.0

使用process.argv结合switch处理一些简单的命令参数足够了,但是当命令接收多参数或子命令时,参数或子命令的顺序甚至不固定,那么就有可能弄不清想要处理的参数是argv中的第几个了。 

其实,已经有很多大神开发了用于处理命令行参数的包,比较流行的如:commanderyargs

这里使用commander库进行举例,commander 详细用法请参考:commander

安装commander

F:\Chen\Nodejs\baby>npm install commander
added 1 package, and audited 2 packages in 4s
found 0 vulnerabilities

commander 设置命令参数选项

修改baby.js

#!/usr/bin/env node
const pkg = require('../package.json');
const { program } = require('commander');
program
    .version(pkg.version)
    .option('-i, --info','display info of the baby.')
    .option('-e, --eat <food>','baby eats something.')
    .action(opts => {
        if(opts.info){console.log('The baby is 18 months old.')}
        if(opts.eat){console.log(`The baby is eating ${args.eat}.`)}
    })
// program.helpOption(false);    //可以关闭默认生成的-h帮助信息选项
program.parse();
// const opts = program.opts();
// console.log(opts);

Commander 使用.options()方法来定义选项,同时可以附加选项的简介。每个选项可以定义一个短选项名称(-后面接单个字符)和一个长选项名称(--后面接一个或多个单词),使用逗号、空格或|分隔。解析后的选项可以通过program.opts()方法获取。另外program.action()接收的回调函数的参数跟program.opts()获得的参数选项一致

在命令行窗口测试baby命令

C:\Users\cyinl>baby -h
Usage: baby [options]

Options:
  -V, --version     output the version number
  -i, --info        display info of the baby.
  -e, --eat <food>  baby eats something.
  -h, --help        display help for command

C:\Users\cyinl>baby -V
1.0.0

C:\Users\cyinl>baby -i
The baby is 18 months old.

C:\Users\cyinl>baby -e
error: option '-e, --eat <food>' argument missing

C:\Users\cyinl>baby -e milk
The baby is eating milk.

commander 设置子命令

修改baby.js

#!/usr/bin/env node
const pkg = require('../package.json');
const { program } = require('commander');

// .command() 不带描述参数的子命令
// .option 是为该子命令添加option选项
program
    .command('info')
    .description('display info of the baby.')
    .option('-a, --age','age info')     //子命令info的option选项
    .option('-s, --sex','sex info')     //子命令info的option选项
    .action((opts,command) => {
        //对于仅有option参数的命令,fn的第1个参数就是子命令的传参,第2个参数是该命令对象本身
        if(opts.age){console.log('The baby is 18 months old.')}
        if(opts.sex){console.log('The baby is a boy.')}
    })

// .command()的第一个参数为命令名称。命令参数可以跟在名称后面,也可以用.argument()单独指定。
// 参数可为必选的(尖括号表示)、可选的(方括号表示)或变长参数(点号表示,如果使用,只能是最后一个参数)
program
    .command('eat')
    .description('baby eats something.')
    .argument('<food>','something food')                //子命令eat的argument选项参数
    .argument('[quantity]','quantity of food','250ml')  //子命令eat的argument选项参数
    .option('-l, --list','list of food.')               //子命令eat的option选项
    .action((fst,sec,opts,command) => {
        //对于有argument参数的命令,fn的前n个参数依次与argument对应的传参,倒数第2个参数是option的传参,最后1个是该命令对象本身
        console.log(fst);
        console.log(sec);
        // console.log(opts);
        if(opts.list){console.log('[milk,water,congee]')}
        // console.log(command);
        console.log(`The baby eat ${sec} of ${fst}.`);
    })

// 当.command()带有描述参数时(第2个参数),就意味着使用独立的可执行文件作为子命令。
// Commander 会尝试在入口脚本的目录中搜索名称组合为 command-subcommand 的文件,搜索包括尝试常见的文件扩展名,如.js。
// 可以使用 executableFile 配置选项指定自定义名称(和路径)。
// 也可以使用 .executableDir() 为子命令指定自定义搜索目录。
program
    .command('sleep [options]', 'go to sleep.',{executableFile:"./sleep.js"})
    .command('play [options]','play time.')
    .executableDir("F:/Chen/Nodejs/baby/bin")
// 对于独立可执行文件的子命令来说,选项及选项参数只能在.command()内指定。
// 可以在可执行文件里处理(子)命令的选项及选项参数,而不必在顶层声明它们
program.parse();

在 bin 目录下,新建一个 sleep.js 文件,内容如下:

#!/usr/bin/env node
console.log("baby is sleep now.");
console.log(process.argv);

在命令行窗口测试baby的各子命令:

C:\Users\cyinl>baby -h
Usage: baby [options] [command]

Options:
  -V, --version                    output the version number
  -h, --help                       display help for command

Commands:
  info [options]                   display info of the baby.
  eat [options] <food> [quantity]  baby eats something.
  sleep [options]                  go to sleep.
  play [options]                   play time.
  help [command]                   display help for command

C:\Users\cyinl>baby info -h
Usage: baby info [options]

display info of the baby.

Options:
  -a, --age   age info
  -s, --sex   sex info
  -h, --help  display help for command

C:\Users\cyinl>baby info -a -s
The baby is 18 months old.
The baby is a boy.

C:\Users\cyinl>baby eat -h
Usage: baby eat [options] <food> [quantity]

baby eats something.

Arguments:
  food        something food
  quantity    quantity of food (default: "250ml")

Options:
  -l, --list  list of food.
  -h, --help  display help for command

C:\Users\cyinl>baby eat -l milk 180ml
milk
180ml
[milk,water,congee]
The baby eat 180ml of milk.

C:\Users\cyinl>baby sleep -h
baby is sleep now.
[
  'D:\\Program Files\\nodejs\\node.exe',
  'F:\\Chen\\Nodejs\\baby\\bin\\sleep.js',
  '-h'
]

C:\Users\cyinl>baby play
node:internal/modules/cjs/loader:1050
  throw err;
  ^

Error: Cannot find module 'C:\Users\cyinl\baby-play'
    at Module._resolveFilename (node:internal/modules/cjs/loader:1047:15)
    at Module._load (node:internal/modules/cjs/loader:893:27)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:81:12)
    at node:internal/main/run_main_module:23:47 {
  code: 'MODULE_NOT_FOUND',
  requireStack: []
}

Node.js v18.14.0

命令行终端交互

在使用命令行命令时,有时候需要用户在终端输入一些信息,来完成相应的功能。而 inquirer.js 致力于为Node.js提供一个易于嵌入且美观的命令行接口,详细介绍及使用请参考:inquirer

inquirer 安装

F:\Chen\Nodejs\baby>npm install inquirer@8.*

注意:

inquirer 从v9版本开始,是ESM模块类型,如果你的项目仍是 CJS 模块类型,请安装 v9 以下版本,如 npm install inquirer@8.*,将安装v8的最新版本,我这里执行完该命令后安装的是最新的v8.2.5版本。关于在CJS和ESM两种模块规范,可参考: CJS 与 ESM

inquirer 语法

// import inquirer from 'inquirer'        // inquirer v9版本开始使用的ESM模块规范
const inquirer = require('inquirer');     // 我这里使用的仍是 CJS模块规范,安装的 inquirer v9.2.5

inquirer
    .prompt([
        /* Pass your questions in here */
        // questions (Array) containing Question Object
    ])
    .then((answers) => {
        // Use user feedback for... whatever!!
        // answers (object) contains values of already answered questions
    })
    .catch((error) => {
        if (error.isTtyError) {
            // Prompt couldn't be rendered in the current environment
        } else {
            // Something else went wrong
        }
    });

question object

inquirer 支持多种交互类型,可以通过以下参数进行交互设置:

  • type:(String)提问类型,默认 input,枚举值:input、number、confirm、list、rawlist、expand、checkbox、password、editor
  • name:(String)存储答案使用的变量名,在answers中通过变量名使用答案,如果名称包含句点,它将在答案哈希中定义一个路径
  • message:(String|Function)提示信息。如果定义为函数,则第一个参数将是当前询问者会话的答案。默认为name的值(后面跟一个冒号)
  • default:(String|Number|Boolean | Array|Function)如果未输入任何值,则使用默认值,或返回默认值的函数。如果定义为函数,则第一个参数将是当前询问者会话的答案
  • choice:(Array|Function)选项数组或返回选项数组的函数。如果定义为函数,则第一个参数将是当前询问者会话的答案。数组值可以是简单的数字、字符串或包含名称(显示在列表中)、值(保存在应答散列中)和short?(选择后显示)的对象。选项数组也可以包含一个分隔符。
  • validate:(Function)对用户的回答进行校验。如果值有效,则应返回true,否则返回错误消息(String)。如果返回false,则提供默认的错误消息
  • filter:(Function)对用户的回答进行过滤处理,返回筛选后的值。返回的值将被添加到Answers散列中
  • transformer:(Function)对用户回答的显示效果进行处理(如:修改回答的字体或背景颜色),但不会影响最终的答案的内容
  • when:(Function,Boolean)根据前面问题的回答,判断当前问题是否需要被回答
  • pageSize:(Number)更改数据呈现的行数。type为list、rawList、expand或checkbox时有效
  • prefix:(String)更改默认的前缀消息
  • suffix:(String)更改默认的后缀消息
  • askAnswered:(Boolean)如果答案已经存在,则强制提示问题
  • loop:(Boolean)启用列表循环。默认值:true
  • waitUserInput:(Boolean)用于启用/禁用在打开系统编辑器之前等待用户输入的标志-默认值:true

answer object

每个key/value对应着客户端答案:
key:问题对象的name属性
value:(取决于问题类型)

  • confirm:(Boolean)
  • input:用户输入(如果定义了过滤器,则进行过滤)(String)
  • number:用户输入(如果定义了过滤器,则进行过滤)(Number)
  • rawlist,list:选定的选择值(如果未指定值,则为name)(String)

inquirer 使用示例

inquirer Github官网 给了一些示例代码可供参考:inquirer/examples | Github

接下来我介绍几种常见的交互形式,更多用法可去Github官网学习

在bin目录下,新建一个test.js用来测试下面代码

(下边这些代码跟baby项目也没啥直接关系,哈哈哈。。。)

input 用户输入型

示例代码:

#!/usr/bin/env node
const inquirer = require('inquirer')

const questions = [{
    type: 'input',
    message: '请输入用户名:',
    name: 'name',
    default: "test_user"     // 默认值
},{
    type: 'input',
    message: '请输入手机号:',
    name: 'phone',
    validate: function (val) {
        let re = /^1[3-9][0-9]{9}$/;
        let result = re.test(val);
        if(!result) {
            return '请输入正确的手机号!'
        }
        return true;
    }
}]

inquirer
    .prompt(
        questions
    )
    .then(answers => {
        console.log(JSON.stringify(answers,null,'  '))
    })
    .catch(error => {
        if (error.isTtyError) {
            console.log('isTtyError: ',error)
        } else {
            console.log('Others err: ',error)
        }
    })

交互效果:

F:\Chen\Nodejs\baby>node bin\test.js
? 请输入用户名: test_user
? 请输入手机号: 15522226666
{
  "name": "test_user",
  "phone": "15522226666"
}

confirm 确认会话型

修改test.js里的questions部分的代码

const questions = [{
        type: "confirm",
        message: "是否使用监听?",
        name: "watch",
        prefix: "前缀"
    },{
        type: "confirm",
        message: "是否进行文件过滤?",
        name: "filter",
        suffix: "后缀",
        when: function(answers) {   // 当watch为true的时候才会提问当前问题
            return answers.watch
        }
    }]

交互效果:

F:\Chen\Nodejs\baby>node bin\test.js
前缀 是否使用监听? Yes
? 是否进行文件过滤?后缀 No
{
  "watch": true,
  "filter": false
}

list 无序列表

修改test.js的questions部分的代码

const questions = [{
        type: 'list',
        message: '请选择一种水果:',
        name: 'fruit',
        choices: [
            "Apple",
            "Pear",
            "Banana"
        ],
        filter: function (val) {    // 使用filter将回答变为小写
            return val.toLowerCase();
        }
    }]

交互效果:

选择前

F:\Chen\Nodejs\baby>node bin\test.js
? 请选择一种水果: (Use arrow keys)
> Apple
  Pear
  Banana

选择后

F:\Chen\Nodejs\baby>node bin\test.js
? 请选择一种水果: Banana
{
  "fruit": "banana"
}

rawlist 有序列表

修改test.js的questions部分的代码

const questions = [{
    type: 'rawlist',
    message: '请选择一种水果:',
    name: 'fruit',
    choices: [
        "Apple",
        "Pear",
        "Banana"
    ]
}]

交互效果:

选择前(可以上下移动选择,也可以输入数字选择):

F:\Chen\Nodejs\baby>node bin\test.js
? 请选择一种水果:
  1) Apple
  2) Pear
  3) Banana
  Answer: 2 

选择后:

F:\Chen\Nodejs\baby>node bin\test.js
? 请选择一种水果: Pear
{
  "fruit": "Pear"
}

expand 扩展列表

修改test.js的questions部分的代码

const questions = [{
    type: "expand",
    message: "请选择一种水果:",
    name: "fruit",
    choices: [
        {
            key: "a",
            name: "Apple",
            value: "apple"
        },
        {
            key: "O",
            name: "Orange",
            value: "orange"
        },
        {
            key: "p",
            name: "Pear",
            value: "pear"
        }
    ]
}]

交互效果:

选择前:

F:\Chen\Nodejs\baby>node bin\test.js
? 请选择一种水果: (aopH) h
>> Help, list all options

输入h或空,回车

F:\Chen\Nodejs\baby>node bin\test.js
? 请选择一种水果: (aopH)
  a) Apple
  o) Orange
  p) Pear
  h) Help, list all options
  Answer: o 

选择后:

F:\Chen\Nodejs\baby>node bin\test.js
? 请选择一种水果: Orange
{
  "fruit": "orange"
}

checkbox 复选框选择型

修改test.js的questions部分的代码

const questions = [{
    type: "checkbox",
    message: "选择颜色:",
    name: "color",
    choices: [
        {
            name: "red"
        },
        new inquirer.Separator(),                 // 添加分隔符
        {
            name: "blue",
            checked: true                         // 默认选中
        },
        {
            name: "green"
        },
        new inquirer.Separator("=============="), // 自定义分隔符
        {
            name: "yellow"
        }
    ]
}]

交互效果:

勾选前:

F:\Chen\Nodejs\baby>node bin\test.js
? 选择颜色: (Press <space> to select, <a> to toggle all, <i> to invert selection, and <enter> to proceed)
>( ) red
 ──────────────
 (*) blue
 ( ) green
 ==============
 ( ) yellow

勾选后:

F:\Chen\Nodejs\baby>node bin\test.js
? 选择颜色: blue, green
{
  "color": [
    "blue",
    "green"
  ]
}

password 密码输入型

修改test.js的questions部分的代码

const questions = [{
    type: "password",   // 密码为密文输入
    message: "请输入密码:",
    name: "pwd"
}];

交互效果:(输入过程不显示,回车后结束输入)

F:\Chen\Nodejs\baby>node bin\test.js
? 请输入密码: [hidden]
{
  "pwd": "1234567"
}

发布命令包

使用 npm publish 命令,将 baby 包发布到npm仓库,这里我就不演示了。npm publish 命令详情请参考:npm-publish | npm Docs

注意:发布之前需要 npm login,登录到 npm registory

其他

开发命令行工具可能会用到的其他包(这里就不写例子了)

终端信息彩色输出:chalkcolor

执行bash命令的包:shelljs

也可以用内置的 child_process.exec() 、child_process.execSync() (同步会阻塞nodejs事件循环)

参考资料

浅谈node.js 命令行工具(cli)

https://docs.npmjs.com/cli/v9

commander 使用手册

inquirer 使用手册

inquirer.js —— 一个用户与命令行交互的工具

猜你喜欢

转载自blog.csdn.net/B11050729/article/details/129874835