Node 开发命令行工具指南

前段日子为了工作上的方便, 我给自家公司的项目写了一个用于读写数据的 CLI 工具 shimo-cli. 这个项目可以用来方便的批量清理测试数据, 将线上的内容转换到本地开发环境中调试等等.

不同于 web 应用, 基于命令行(CLI)的应用与使用者的交互主要来源于用户终端的输入与输出. 在 nodejs 中主要是依靠 streamreadline 模块以及从 process.stdin / process.stdout 拿到的输入输出流来进行与终端的交互的.

常用库

在讨论具体的实现方式前, 我要先介绍一些很常用的好用的库:

Inquirer.js

Inquirer.js

这个库封装了许多常用的 CLI 交互方式, 例如单选多选, 输入等. 基本上可以满足绝大多数 CLI 应用的需要. 然而 shimo-cli 并没有直接用到 Inquirer, 不过参考了很多其中实现.

meow

meow 也是一个很常用的库. 他定义了一套 CLI 参数声明/解析规范. 并对外提供了 --help 方法.

chalk

chalk 用于对输出流的内容进行样式处理, 例如颜色,背景色,加粗等.

string-width

string-width可以获得文字的宽度, 并对 utf-8 字符与 emoji 做了特殊处理.

cli-width

cli-width 用来获得终端的宽度. 通常我们可以通过 process.stdout.columns 拿到终端宽度, 但是 cli-width 库中对许多奇奇怪怪的终端做了兼容性处理.

ink

ink 是一个用 React 组件编写 cli 应用的库. 类似于 ReactNative, ink 也是基于 yoga 处理布局的.

常见问题

如何接收用户输入

shimo-cli 的第一步就是完成登录鉴权,需要用户输入账号密码.

通过 process.stdin.resume 可以用来开启输入流的监听.

1
2
3
process.stdin.resume().on("data", chunk => {
process.stdin.pause();
});

对于密码的输入, 通常我们不期望回显在屏幕上, 通过 process.stdin.setRawMode(true) 可以避免输入的内容直接显示, 但对输入的接收也变成了原生模式, 需要手动检测用户输入的回车与中断符(Command + C)

Demo

按键交互

上述的 rawMode 可以捕获用户的原始输入(回车,方向键等). 利用这一点, 可以实现基于命令行的”加载更多”效果.

shimo-cli 会从 web 上获取含分页的列表, 我们期望每次显示一页的数据, 当用户输入回车时加载下一页的数据.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
async function more() {
process.stdout.write("-- Press Enter --\n");
return new Promise<boolean>(resolve => {
process.stdin.setRawMode!(true);
process.stdin.resume().on("data", (result: Buffer) => {
const char = result.toString("utf8");
switch (char) {
case "\n":
case "\r":
case "\u0004": {
process.stdin.setRawMode!(false);
process.stdin.pause();
clearLine();
resolve(true);
break;
}
case "\u0003": {
process.stdin.setRawMode!(false);
process.stdin.pause();
process.stdout.write("\n^C");
process.exit();
}
}
});
});
}

另外, 为了提示用户,每次数据显示完成后会显示 -- Press Enter --\n, 提示用户的交互行为. 出于洁癖的考虑, 当展示下一页的数据时, 希望数据之间可以连续, 因此我们需要将 -- Press Enter --\n 删除.

nodejs 提供了 readline 模块进行对终端的显示及光标的控制.

1
2
3
4
5
const readline = require("readline");
function clearLine() {
readline.moveCursor(process.stdout, 0, -1);
readline.clearLine(process.stdout, 0);
}

Demo

调用外部编辑器

调用外部编辑器可以直接使用 external-editor 库.

实现的方式也比较简单, 通过 childProcess 新建一个编辑器进程并在临时目录下创建一个文件用来记录编辑内容, 通过子进程的退出事件判断为输入的结束.

一般的编辑器可以根据后缀进行不同的高亮提示, 利用这点在创建文件时设置对应的后缀即可实现文本高亮.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
async function editFile(filePath: string) {
return new Promise<void>(resolve => {
const editor = process.env.EDITOR || "vi";
const child = spawn(editor, [filePath], {
stdio: "inherit"
});
child.on("exit", () => {
resolve();
});
});
}

async function getContentFromEditor(
initContent: string = "",
suffix: string = "js"
) {
const editTempFile = getOneTmpFilePath(suffix);
fs.writeFileSync(editTempFile, initContent);
await editFile(editTempFile);
const content = fs.readFileSync(editTempFile).toString();
fs.unlinkSync(editTempFile);
return content;
}

Demo

流式调用

在 Unix 下多个程序通常以管道的方式配合使用

1
git blame foo | grep bar

在 node 中管道的输入与用户输入一样, 直接使用 readStream.resume 即可开始监听

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
async function read() {
return new Promise((resolve, reject) => {
const clear = setTimeout(() => {
process.stdin.pause();
reject("timeout");
}, 500);
process.stdin.resume();
let data = "";
process.stdin.on("data", chunk => {
clearTimeout(clear);
data += chunk;
});
process.stdin.on("end", () => {
process.stdin.pause();
resolve(data.trim());
});
process.stdin.on("error", err => {
reject(err);
});
});
}

Demo