共计 6386 个字符,预计需要花费 16 分钟才能阅读完成。
写在最前,其实真想写一写食谱来着,苦于烹饪能力有限,所以标题就是个谎言,哈哈 ^_~
今天咱们就来聊一聊命令行工具(即 CLI:command-line interface,以下都会以 CLI 来代替冗长的命令行工具名词)的开发。
阅读完本文,你会对从头到尾开发一个 CLI 有一个较全面的认识。
你也可以收藏下这篇文章,当你想开发一个 CLI 时,回来翻一翻,总会找到你想要的。
丹尼尔:花生可乐准备好了,坐等开始。
好勒,这就开始,Let’s go! <(~︶~)↗[GO!]
> 迈出第一步:初始化项目
创建一个空项目目录(接下来都是以 cook-cli
来作例子的,所以这里我们命名为 cook-cli
),然后在该目录下敲打命令进行初始化,过程如下:
$ mkdir cook-cli | |
$ cd cook-cli | |
$ npm init --yes |
通过 npm init
命令,会将该目录初始化为一个 Node.js
项目,它会在 cook-cli
目录下生成 package.json
文件。
加 --yes
会自动回答初始化过程中提问的所有问题,你可以试着将该参数去掉,自己一个一个问题进行回答。
> 主线打通:CLI 骨架代码
项目已初始完毕,接下来我们添加骨架代码,让 CLI 飞一会。
- 实现者
我们创建 src/index.js
文件,它负责实现 CLI 的功能逻辑,是实际干活的。代码如下:
export function cli(args) {console.log('I like cooking'); | |
} |
- 代言者
接着创建 bin/cook
文件,它是 CLI 的可执行入口文件,是 CLI 在可执行环境中的代言者。代码如下:
#!/usr/bin/env node | |
require = require('esm')(module /*, options*/); | |
require('../src').cli(process.argv); |
细心的你会发现这里用到了 esm
这个模块,它的作用是让我们可以在 js 源代码中直接使用 ECMAScript modules
规范加载模块,即直接使用 import
和 export
。上面 src/index.js
的代码中能直接写 export
得益于该模块。
(请在项目根目录运行 npm i esm
来安装该模块)
- 官宣
我们有代言者,但必须对外宣传才行。所以在 package.json
中增加 bin
的声明,对外宣布代言者的存在。如下:
{ | |
... | |
"bin": {"cook": "./bin/cook"}, | |
... | |
} |
> 时刻彩排:本地运行和调试
在 CLI 面世之前,本地开发调试是必不可少的,所以便捷的调试途径非常必要。
丹尼尔:开发 Web 应用,我可以通过浏览器来调试功能。那 CLI 昨弄呢?
CLI 最终是在终端运行的,所以我们要先把它注册为本地命令行。方法非常简单,在项目根目录运行以下命令即可:
$ npm link
该命令会在本地环境注册一个 cook
CLI,并将其执行逻辑代码链接到你的项目目录,所以你每次修改保存后即立即生效。
试着运行以下命令:
$ cook
丹尼尔:Nice!但我还有个问题,我想要在 vscode 中设置断点来调试,这样有时候会更容易排查问题
你说得没错。方法也是很简单的,在 vscode 加入以下配置即可,路径为: 调试 > 添加配置
。根据实际要调试的命令参数,修改 args
的值即可。
{ | |
"configurations": [ | |
{ | |
"type": "node", | |
"request": "launch", | |
"name": "Cook", | |
"program": "${workspaceFolder}/bin/cook", | |
"args": ["hello"] // Fill in the parameters you want to debug | |
} | |
] | |
} |
> 意图识别:入参解析
插个小插曲:虽然你们在工作中可能经常接触到各种 CLI,但这里还是有必要对 CLI 涉及到的一些术语作简短的介绍:
- 命令(command)和子命令(subcommand)
# cook 即为命令 | |
cook | |
# start 即为 cook 的 子命令 | |
cook start |
- 命令选项(options)
# -V 为简写模式(short flag)的选项(注意:只能一个字母,多个字母代表多个选项)cook -V | |
# --version 为全写模式(long name)的选项 | |
cook --version |
- 命令参数(argument)
# source.js 和 target.js 都为 cp 命令的参数 | |
cp source.js target.js |
其实,子命令也是命令的参数
Ok,从以上的介绍来看,我们要实现一个 CLI,对入参(包括 subcommand, options, argument)的解析是逃不掉的,那我们就直面它们吧。
commander:嘿,兄弟,别怕,有我呢!
是的,兄弟,有你真好。接下来我们通过使用 commander
这个模块来解析入参,过程和示例如下:
- 模块安装
npm i commander
- src/index.js 示例
...... | |
import program from 'commander'; | |
export function cli(args) {program.parse(args); | |
} |
一句搞定,就是这么干脆利落。
丹尼尔:入参呢?怎么用呢?
在接下来的例子中,我们就会用到这些解析完的入参对象。所以,请先稍安勿躁。
> 不能没有你:版本和帮助
版本和帮助信息是一个 CLI 必须提供的部分,不然就显得太不专业了。我们就来看下如何实现吧。
修改 src/index.js
,代码如下:
import program from 'commander'; | |
import pkg from '../package.json'; | |
export function cli(args) {program.version(pkg.version, '-V, --version').usage('<command> [options]'); | |
program.parse(args); | |
} |
通过 program.version
和 usage
的链式调用就搞定了,还是那么的冷酷。
试着运行以下命令:
$ cook -V
$ cook -h
> 添加大将:新增子命令
现在我们开始丰富 CLI 的功能,从增加一个子命令 start
开始。
它拥有一个参数 food
和 一个选项 --fruit
,代码如下:
...... | |
export function cli(args) { | |
..... | |
program | |
.command('start <food>') | |
.option('-f, --fruit <name>', 'Fruit to be added') | |
.description('Start cooking food') | |
.action(function(food, option) {console.log(`run start command`); | |
console.log(`argument: ${food}`); | |
console.log(`option: fruit = ${option.fruit}`); | |
}); | |
program.parse(args); | |
} |
上面例子演示了如何获取解析后的入参,在 action
中你可以取到你想要的一切,你想做什么,完全由你做主。
尝试运行子命令:
$ cook start pizza -f apple
> 寻求外援:调用外部命令
有些时候,我们需要在 CLI 中去调用外部命令,如 npm
之类的。
execa:该我上场表演了。┏ (^ω^)=☞
- 模块安装
npm i execa
- src/index.js 示例
...... | |
import execa from 'execa'; | |
export function cli(args) { | |
..... | |
program | |
.command('npm-version') | |
.description('Display npm version') | |
.action(async function() {const { stdout} = await execa('npm -v'); | |
console.log('Npm version:', stdout); | |
}); | |
program.parse(args); | |
} |
以上通过 execa
来调用外部命令 npm -v
。来,打印一下 npm
的版本号吧:
$ cook npm-version
> 促进交流:提供人机交互
有些时候我们希望 CLI 能通过一问一答的方式与用户互动,用户通过输入或选择的方式来提供我们想要的信息。
一阵大风吹过,
Inquirer.js
踏着七彩云飞奔而来。
- 模块安装
npm i inquirer
最常见的场景是:文本输入,是否选项,复选,单选。例子如下:
- src/index.js 示例
...... | |
import inquirer from 'inquirer'; | |
export function cli(args) { | |
...... | |
program | |
.command('ask') | |
.description('Ask some questions') | |
.action(async function(option) { | |
const answers = await inquirer.prompt([ | |
{ | |
type: 'input', | |
name: 'name', | |
message: 'What is your name?' | |
}, | |
{ | |
type: 'confirm', | |
name: 'isAdult', | |
message: 'Are you over 18 years old?' | |
}, | |
{ | |
type: 'checkbox', | |
name: 'favoriteFrameworks', | |
choices: ['Vue', 'React', 'Angular'], | |
message: 'What are you favorite frameworks?' | |
}, | |
{ | |
type: 'list', | |
name: 'favoriteLanguage', | |
choices: ['Chinese', 'English', 'Japanese'], | |
message: 'What is you favorite language?' | |
} | |
]); | |
console.log('your answers:', answers); | |
}); | |
program.parse(args); | |
} |
代码浅显,直接上效果图吧:
> 减少焦虑:等待提醒
人机交互体验很重要,如果不能马上完成的工作,就需要及时反馈用户当前工作的进度,这样可以减少用户的等待焦虑感。
ora
和listr
肩并着肩,迈着整齐的步伐,迎面而来。
首先上场的是 ora
- 模块安装
npm i ora
- src/index.js 示例
...... | |
import ora from 'ora'; | |
export function cli(args) { | |
...... | |
program | |
.command('wait') | |
.description('Wait 5 secords') | |
.action(async function(option) {const spinner = ora('Waiting 5 seconds').start(); | |
let count = 5; | |
await new Promise(resolve => {let interval = setInterval(() => {if (count <= 0) {clearInterval(interval); | |
spinner.stop(); | |
resolve();} else { | |
count--; | |
spinner.text = `Waiting ${count} seconds`; | |
} | |
}, 1000); | |
}); | |
}); | |
program.parse(args); | |
} |
话不多说,直接上图:
listr
随后而来。
- 模块安装
npm i listr
- src/index.js 示例
...... | |
import Listr from 'listr'; | |
export function cli(args) { | |
...... | |
program | |
.command('steps') | |
.description('some steps') | |
.action(async function(option) { | |
const tasks = new Listr([ | |
{ | |
title: 'Run step 1', | |
task: () => | |
new Promise(resolve => {setTimeout(() => resolve('1 Done'), 1000); | |
}) | |
}, | |
{ | |
title: 'Run step 2', | |
task: () => | |
new Promise((resolve) => {setTimeout(() => resolve('2 Done'), 1000); | |
}) | |
}, | |
{ | |
title: 'Run step 3', | |
task: () => | |
new Promise((resolve, reject) => {setTimeout(() => reject(new Error('Oh, my god')), 1000); | |
}) | |
} | |
]); | |
await tasks.run().catch(err => {console.error(err); | |
}); | |
}); | |
program.parse(args); | |
} |
依然话不多说,依然直接上图:
> 加点色彩:让生活不再单调
chalk
:我是文艺青年,我为艺术而活,这该非我莫属了。<(~ˇ~)/
- 模块安装
npm i chalk
- src/index.js 示例
..... | |
import chalk from 'chalk'; | |
export function cli(args) {console.log(chalk.yellow('I like cooking')); | |
..... | |
} |
有了色彩的 CLI,是不是让你心情更加愉悦:
> 门面装饰:加个边框
boxen
:这个是我的拿手好戏,看我的!<(ˉ^ˉ)>
- 模块安装
npm i boxen
- src/index.js 示例
...... | |
import boxen from 'boxen'; | |
export function cli(args) {console.log(boxen(chalk.yellow('I like cooking'), {padding: 1})); | |
...... | |
} |
嗯,看上去专业一些了:
> 公布成果:可以发表了
如果你是以 scope
方式发布,例如 @daniel-dx/cook-cli
。那么在 package.json
中增加以下配置可以让你顺利发布(当然,如果你是 npm 的付费会员,那这个配置是可以省的)
{ | |
"publishConfig": {"access": "public"}, | |
} |
临门一脚,发射:
npm publish
OK,已经对全世界发布了你的 CLI 了,现在你可以到 https://www.npmjs.com/ 去查询下你发布的 CLI 了。
> 温馨提醒:该升级了
update-notifier:终于到我了,我等到花儿已谢了。X﹏X
- 模块安装
npm i update-notifier
- src/index.js 示例
...... | |
import updateNotifier from 'update-notifier'; | |
import pkg from '../package.json'; | |
export function cli(args) {checkVersion(); | |
...... | |
} | |
function checkVersion() {const notifier = updateNotifier({ pkg, updateCheckInterval: 0}); | |
if (notifier.update) {notifier.notify(); | |
} | |
} |
为了本地调试,我们将本地的 CLI 降一个版本,把 package.json
的 version
修改为 0.0.9
,然后运行 cook
查看效果:
o(~︶~)o 完美!
以上详细地介绍了开发一个 CLI 的一些必备或常用的步骤。
当然,如果你只想快速开发一个 CLI,就像一些领导经常说的:不要跟我说过程,我只要结果。那完全可以使用如 oclif
这些专为开发 CLI 而生的框架,开箱即用。
而我们作为程序员,对于解决方案的来龙去脉,前世今生的了解,还是需要为些付出些时间和精力的,这样可以让我们更踏实,走得更远。
好了,今天就聊到这了,再见我的朋友们!
差点忘了,附上示例的源码:https://github.com/daniel-dx/…
┏(^0^)┛ ByeBye!