从零开始开发一个node-cli工具

什么是CLI

命令行界面(英语:command-line interface,缩写:CLI)是在图形用户界面得到普及之前使用最为广泛的用户界面,它通常不支持鼠标,用户通过键盘输入指令,计算机接收到指令后,予以执行。也有人称之为字符用户界面(character user interface, CUI)。

CLI能做什么

我们在项目开发时,经常会用到一些cli工具,比如vue-clinpm init等等,这些CLI工具我们常常用做项目初始化、代码检查、模板创建等交互相对简单,且重复性较多的工作。

准备开发

实现CLI工具开发的方式和语言有很多,本文只介绍基于node的实现方案。

Hello World

依照惯例,我们第一步还是从Hello World开始:

首先,进入工作区,创建并进入项目目录hello-cli,执行npm初始化命令:

1
2
3
mkdir hello-cli
cd hello-cli
npm init

输入或选择一系列项目配置

1
2
3
4
5
6
7
8
9
package name: (hello-cli) 
version: (1.0.0)
description: hello world
entry point: (index.js)
test command: test
git repository:
keywords: cli
author: mulianju
license: (ISC)

npm会自动创建好项目配置文件package.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"name": "hello-cli",
"version": "1.0.0",
"description": "hello world",
"main": "index.js",
"scripts": {
"test": "test"
},
"keywords": [
"cli"
],
"author": "mulianju",
"license": "ISC"
}

在项目根目录,创建bin文件夹,并在bin文件夹内创建hello-cli.js文件,文件中写入:

1
2
#!/usr/bin/env node
console.log('Hello World!')

注意文件第一行的“注释”,这行“注释”并不是普通的“注释”,他是用来声明此CLI工具的开发语言,所以千万不要删掉。

package.json里添加bin字段,用来创建一个命令,并声明命令指向的执行文件即可:

1
2
3
"bin": {
"hello-cli": "bin/hello-cli.js"
},

执行本地安装:

1
npm link

至此,我们的第一个CLI工具就开发完成了。我们新建个终端窗口,执行我们自定义的命令,即可看到效果:

1
hello-cli

输出结果:

1
Hello World!

CLI交互

CLI工具最关键的一个点,就是用户交互,简单的交互可以极大扩展我们的CLI的能力,比如以上我们用到的npm init,一些项目信息都需要在我们初始化项目过程中,通过CLI输入或选择。

用来实现CLI交互的,主要依赖以下两个包:

  • commander:完整的node.js命令行解决方案。详细资料
  • inquirer:常见交互式命令行用户界面的集合。详细资料

注意:inquirer9.0.0版本开始,模块化方式改为native esm modules,言下之意,如果你的项目中使用的是CMD模块化方式,则需要限制inquirer的版本低于9.0.0,否则将会抛出以下错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
internal/modules/cjs/loader.js:1102
throw new ERR_REQUIRE_ESM(filename, parentPath, packageJsonPath);
^

Error [ERR_REQUIRE_ESM]: Must use import to load ES Module: /mnt/d/work/work/2022/08/hello-cli/node_modules/inquirer/lib/inquirer.js
require() of ES modules is not supported.
require() of /mnt/d/work/work/2022/08/hello-cli/node_modules/inquirer/lib/inquirer.js from /mnt/d/work/work/2022/08/hello-cli/bin/hello-cli.js is an ES module file as it is a .js file whose nearest parent package.json contains "type": "module" which defines all .js files in that package scope as ES modules.
Instead rename inquirer.js to end in .cjs, change the requiring code to use import(), or remove "type": "module" from /mnt/d/work/work/2022/08/hello-cli/node_modules/inquirer/package.json.

at new NodeError (internal/errors.js:322:7)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:1102:13)
at Module.load (internal/modules/cjs/loader.js:950:32)
at Function.Module._load (internal/modules/cjs/loader.js:790:12)
at Module.require (internal/modules/cjs/loader.js:974:19)
at require (internal/modules/cjs/helpers.js:101:18)
at Object.<anonymous> (/mnt/d/work/work/2022/08/hello-cli/bin/hello-cli.js:3:18)
at Module._compile (internal/modules/cjs/loader.js:1085:14)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:1114:10)
at Module.load (internal/modules/cjs/loader.js:950:32) {
code: 'ERR_REQUIRE_ESM'
}

关于以上两个包的更多介绍,基于篇幅原因,我们这里就不详细展开来说了,我们这里只讲一个最简单的应用,其他的功能,待您亲自去尝试~

项目实例

以下应用完成了,通过用户输入的项目信息和一些默认信息,来初始化一个package.json项目配置文件的功能:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#!/usr/bin/env node

const { program } = require("commander")
const inquire = require("inquirer")
const fs = require("fs")

const projectInfo = [
{
type: "input",
message: "请输入项目名称",
name: "name",
default: "project",
},
{
type: "input",
message: "请输入项目描述",
name: "description",
},
{
type: "input",
message: "请输入项目作者",
name: "author",
},
{
type: "input",
message: "请输入项目git仓库",
name: "git",
},
{
type: "list",
message: "请选择开源协议",
name: "license",
choices: ["ISC", "BSD", "GPL", "Apache Licence 2.0", "LGPL", "MIT"],
default: "GPL",
},
]

const defaultInfo = {
version: "1.0.0",
scripts: {},
}

const initAction = () => {
inquire.prompt(projectInfo).then((answers) => {
const info = Object.assign({}, defaultInfo, answers)
fs.writeFile("package.json", JSON.stringify(info), function (err) {
if (err) {
res.status(500).send("写入错误")
} else {
console.log("项目初始化成功,您的项目信息为:\n", info)
}
})
})
}

switch (process.argv[2]) {
case "init":
program
.command("init")
.description("初始化项目")
.action(initAction)
.parse(process.argv)
break
default:
program
.usage("<command>")
.command("init", "初始化项目")
.parse(process.argv)
break
}

扩展能力

以上例子,我们实现了自动创建package.json文件,CLI当然不只这点能耐,利用一些npm包,我们还可以实现更丰富的功能。

自动克隆远程git仓库

首先,安装依赖工具:

1
npm install shelljs --save

编写功能:

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
27
28
29
30
31
32
const inquire = require("inquirer")
const shell = require("shelljs")

const projectInfo = [
{
type: "input",
message: "请输入项目名称",
name: "name",
default: "project",
}
]

const gitRepository = 'https://github.com/mulianju/hello-cli.git'

const initWithGit = () => {
inquire.prompt(projectInfo).then((answers) => {
console.log('项目正在创建...')
const { name = 'project' } = answers
shell.exec(`
rm -rf ./hello-cli
git clone ${gitRepository}
rm -rf ./hello-cli/.git
mv hello-cli ${name}
cd ${name};
`)
})
}

module.exports = {
initWithGit
}

运行:

1
2
3
4
5
6
7
hello-cli initWithGit

## CLI交互及输出
? 请输入项目名称 project
项目正在创建...
Cloning into 'hello-cli'...

自动创建模板

首先,安装依赖工具:

1
npm install art-template chalk --save

注意:chalk5.0.0版本开始,模块化方式也变更了,和inquirer相似,参考

编写功能:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
const inquirer = require("inquirer")
const fs = require("fs")
const template = require("art-template")
const chalk = require("chalk")
const path = require('path')
const {
capitalize,
camelize,
mkdirsSync
} = require('./utils')

const rootDir = '../../../..'

const choices = [
{
title: "页面(page)",
value: "page",
},
{ title: "组件(component)", value: "component" },
]

const promptInfo = [
{
type: "list",
name: "type",
message: "请选择需要创建的类型?",
prefix: "[?]",
choices: choices.map((item) => item.title),
filter(val) {
return choices.find((item) => item.title == val).value
},
},
{
type: "input",
name: "name",
message: `请输入名称(支持多级路径, 如:xxx/xxx)?`,
prefix: "[?]",
default: "index",
},
]

const checkTemplatesExistsSync = async () => {
console.log(__dirname)
const results = [
fs.existsSync(path.resolve(__dirname, rootDir, './templates/component.vue.art')),
fs.existsSync(path.resolve(__dirname, rootDir, './templates/page.vue.art')),
]
.filter((isExist) => !isExist)
.map((_, index) => choices[index].title)

if (results.length) {
console.log(
`${chalk.green(results.join(","))}${chalk.red(
"模板不存在,请先创建模板"
)}`
)
} else {
return true
}
}

const add = async () => {
if (await checkTemplatesExistsSync()) {
inquirer
.prompt(promptInfo)
.then(async (answers) => {
const { type, name: inputName } = answers

const nameMap = inputName.split('/')
const name = capitalize(camelize(nameMap.pop()))
const dirname = path.resolve(__dirname, rootDir, `./${type}s/${nameMap.join('/')}`)
const templateDir = path.resolve(__dirname, rootDir, `./templates/${type}.vue.art`)

if (!fs.existsSync(path.resolve(dirname, `./${name}.vue`))) {
mkdirsSync(dirname)
fs.writeFileSync(path.resolve(dirname, `./${name}.vue`), template(templateDir, {
name
}), 'utf8')
} else {
const role = choices.find(item => item.value == type)
console.log(`${chalk.red(role.title)}: ${chalk.green(name)} ${chalk.red('已经存在,换个名字再试试吧')}`)
}
console.log(answers)
})
}
}

module.exports = {
add
}

运行:

1
2
3
4
5
6
7
8
9
## 注意:若使用此功能,请将hello-cli项目放置到项目node_modules文件夹,并执行npm link本地安装
## 并且项目根目录需创建templates文件夹
## 来存放component.vue.art和page.vue.art两个art-template模板文件
hello-cli add

## CLI交互
[?] 请选择需要创建的类型? 组件(component)
[?] 请输入名称(支持多级路径, 如:xxx/xxx)? index/test_component

运行后,会在项目根目录自动创建components/index/TestComponent.vue文件

以上,简单做两个例子,更多功能期待你们探索

结语

本文案例,均存放在开源项目: hello-cli

本文永久地址:从零开始开发一个node-cli工具

本文永久链接: https://www.mulianju.com/develop-node-cli/