工程结构

工程目录结构如下
/bin # —— 命令执行文件
/lib # —— 工具模块
package.json

使用 commander.js 开发命令行工具

nodejs 内置了对命令行操作的支持,node 工程下 package.json 中的 bin 字段可以定义命令名和关联的执行文件。

1
2
3
4
5
6
7
8
{
"name": "demo-cli",
"version": "1.0.0",
"description": "我的cli",
"bin": {
"demo": "./bin/demo.js"
}
}

在bin目录下创建一个 demo.js 文件,用于处理命令行的逻辑。
在 demo.js 中编写命令行的入口逻辑

1
2
3
4
5
6
7
8
9
#!/usr/bin/env node

const program = require('commander') // npm i commander -D

program.version('1.0.0')
.usage('<command> [项目名称]')
.command('hello', 'hello')
.parse(process.argv)

接着,在 bin 目录下创建 demo-hello.js,放一个打印语句

1
2
touch ./bin/demo-hello.js
echo "console.log('hello, commander')" > ./bin/demo-hello.js

这样,通过 node 命令测试一下

1
node ./bin/demo.js hello

可以在终端上看到一句话:hello, commander。

commander 支持 git 风格的子命令处理,可以根据子命令自动引导到以特定格式命名的命令执行文件,文件名的格式是 [command]-[subcommand],例如:

1
2
macaw hello => macaw-hello
macaw init => macaw-init

定义init子命令
我们需要通过一个命令来新建项目,按照常用的一些名词,我们可以定义一个名为 init 的子命令。
对 bin/demo.js 做一些改动。

1
2
3
4
5
6
const program = require('commander')

program.version('1.0.0')
.usage('<command> [项目名称]')
.command('init', '创建新项目')
.parse(process.argv)

在 bin 目录下创建一个 init 命令关联的执行文件
添加如下代码

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
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
#!/usr/bin/env node

const fs = require('fs');
const glob = require('glob'); // npm i glob -D
const path = require('path');
const chalk = require('chalk');
const inquirer = require('inquirer'); // npm i inquirer -D
const program = require('commander');
const download = require('../lib/download');
const generator = require('../lib/generator');
const logSymbols = require('log-symbols');
// 这个模块可以获取node包的最新版本
const latestVersion = require('latest-version'); // npm i latest-version -D

program.usage('<project-name>')
.option('-r, --repository [repository]', 'assign to repository')
.parse(process.argv);

// 根据输入,获取项目名称
let projectName = program.args[0];

if (!projectName) { // project-name 必填
// 相当于执行命令的--help选项,显示help信息,这是commander内置的一个命令选项
program.help();
return;
}

const list = glob.sync('*'); // 遍历当前目录
let next = null;
let rootName = path.basename(process.cwd());
if (list.length) { // 如果当前目录不为空
if (list.filter((name) => {
const fileName = path.resolve(process.cwd(), path.join('.', name));
const isDir = fs.statSync(fileName).isDirectory();
return name.indexOf(projectName) !== -1 && isDir;
}).length !== 0) {
console.log(`项目${projectName}已经存在`);
return;
}
next = Promise.resolve(projectName);
} else {
next = Promise.resolve(projectName);
}

next && go();

function go() {
next.then((projectRoot) => {
/*if (projectRoot !== '.') {
fs.mkdirSync(projectRoot);
}*/
const repUrl = program.repository ? program.repository : 'http://git.mamaezhan.com/lvfan/epoch-ui-cli.git';
return download(projectRoot, repUrl).then((target) => {
return {
name: projectRoot,
root: projectRoot,
downloadTemp: target,
};
}, (err) => {
console.error(err)
}).then((context) => {
return inquirer.prompt([
{
name: 'myEpochProjectName',
message: '项目的名称',
default: context.name,
},
{
name: 'myEpochProjectVersion',
message: '项目的版本号',
default: '1.0.0',
},
{
name: 'myEpochProjectDescription',
message: '项目的简介',
default: `A project named ${context.name}`,
},
{
name: 'myEpochAuthor',
message: '项目的作者',
default: 'root',
},
]).then((answers) => {
// return latestVersion('epoch-ui').then((version) => {
// answers.supportUiVersion = version;
return {
...context,
metadata: {
...answers,
},
};
// })
}, (err) => {
console.error(err);
});
}, (err) => {
console.error(err);
}).then((context) => {
return generator(context.metadata, `${context.downloadTemp}/.DOWN`, context.downloadTemp);
}, (err) => {
console.error(err);
}).then((context) => {
console.log(logSymbols.success, chalk.green('创建成功:)'));
console.log();
console.log(chalk.green('cd ' + projectRoot + '\nyarn\nyarn run server'));
}, (err) => {
console.error(logSymbols.error, chalk.red(`创建失败:${err.message}`));
});
});
}

注意第一行 #!/usr/bin/env node 是干嘛的,有个关键词叫 Shebang

在 lib 目录下创建 download.js 和 generator.js 文件

download.js 用来从远程仓库下载代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const shell = require('shelljs');
const ora = require('ora/index');
const simpleGit = require('simple-git');

module.exports = function (target, repUrl) {
return new Promise((resolve, reject) => {
const spinner = ora(`正在下载项目模板,源地址:${repUrl}`);
// const url = 'direct:' + repUrl;
spinner.start();
simpleGit().clone(repUrl, process.cwd() + `/${target}/.DOWN`, (err, result) => {
if (err) {
spinner.fail();
reject(err);
} else {
shell.rm('-rf', process.cwd() + `/${target}/.DOWN/.git`);
spinner.succeed();
resolve(target);
}
});
})
};

generator.js 用来把控制台输入的信息填入新项目的 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
// npm i handlebars metalsmith -D
const shell = require('shelljs');
const Metalsmith = require('metalsmith');
const Handlebars = require('handlebars');

module.exports = function (metadata = {}, src, dest = '.') {
if (!src) {
return Promise.reject(new Error(`无效的source:${src}`));
}

return new Promise((resolve, reject) => {
Metalsmith(process.cwd())
.metadata(metadata)
.clean(false)
.source(src)
.destination(dest)
.use((files, metalsmith, callback) => {
const meta = metalsmith.metadata();
Object.keys(files).forEach(fileName => {
if (fileName.indexOf('package.json') !== -1) {
const t = files[fileName].contents.toString();
files[fileName].contents = Buffer.from(Handlebars.compile(t)(meta))
}
});
callback();
})
.build(err => {
shell.rm('-rf', src);
err ? reject(err) : resolve();
})
})
};