今天学习一下如果开发Node命令行工具(CLI)
目录
准备
没有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中的第几个了。
其实,已经有很多大神开发了用于处理命令行参数的包,比较流行的如:commander、yargs
这里使用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
其他
开发命令行工具可能会用到的其他包(这里就不写例子了)
执行bash命令的包:shelljs
也可以用内置的 child_process.exec() 、child_process.execSync() (同步会阻塞nodejs事件循环)