使用 Node.js 开发 CLI | moq

引言

通过 Node.js 编写一个 全局可用 CLI,用于日常生活。

功能如下:

  1. 实现执行下方语句,将用于笔记本的Hexo文章中公开文章复制到 用于博客的 Hexo 文章中:
1
moq hexop './' '../yiyungent.github.io'

npm 初始化 项目

新建文件夹 moq

1
mkdir moq

进入文件夹

1
cd moq

npm 初始化项目

1
npm init

输入项目描述

完成 package.json 的创建

自定义命令

package.json 添加 bin

1
2
3
"bin": {
"moq": "index.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
{
"name": "moq",
"version": "0.1.0",
"description": "a CLI tool for daily life.",
"main": "index.js",
"bin": {
"moq": "index.js"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/yiyungent/moq"
},
"keywords": [
"cli"
],
"author": "yiyun <yiyungent@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/yiyungent/moq/issues"
},
"homepage": "https://github.com/yiyungent/moq#readme"
}

bin 使得 moq 成为一个可执行命令,如 npm init 中的 npm,而命令所执行文件即是 ./index.js

测试

新建 index.js,内容如下:

1
2
3
#!/usr/bin/env node

console.log("执行成功")

!/usr/bin/env node 表明 当前文件需以 Node.js 脚本执行

完成后,即可全局安装 moq,在项目所在目录执行:

1
npm install -g

此时全局安装成功,下面测试命令:

1
moq

测试成功

交互式命令行

这里依赖两个库进行开发

1
npm install commander
1
npm install inquirer

index.js 添加

1
2
const { program } = require('commander');
const inquirer = require('inquirer');

1. moq hexop

1.1 解析 YAML

使用:https://github.com/nodeca/js-yaml

1
npm install js-yaml

1.2 编写 tools.js

新建 tools.js,内容如下:

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
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
const fs = require("fs"),
stat = fs.stat,
path = require("path");

/*
* 复制目录中的所有文件包括子目录
* @param{ String } 需要复制的目录
* @param{ String } 复制到指定的目录
*/
let copy = function (src, dst) {
// 读取目录中的所有文件/目录
fs.readdir(src, function (err, paths) {
if (err) {
throw err;
}

paths.forEach(function (path) {
var _src = src + "/" + path,
_dst = dst + "/" + path,
readable,
writable;

stat(_src, function (err, st) {
if (err) {
throw err;
}

// 判断是否为文件
if (st.isFile()) {
// 创建读取流
readable = fs.createReadStream(_src);
// 创建写入流
writable = fs.createWriteStream(_dst);
// 通过管道来传输流
readable.pipe(writable);
}
// 如果是目录则递归调用自身
else if (st.isDirectory()) {
exists(_src, _dst, copy);
}
});
});
});
};

// 在复制目录前需要判断该目录是否存在,不存在需要先创建目录
let exists = function (src, dst, callback) {
fs.exists(dst, function (exists) {
// 已存在
if (exists) {
callback(src, dst);
}
// 不存在
else {
fs.mkdir(dst, function () {
callback(src, dst);
});
}
});
};

let deleteFile = function deleteFile(path) {
var files = [];
if (fs.existsSync(path)) {
files = fs.readdirSync(path);
files.forEach(function (file, index) {
var curPath = path + "/" + file;
if (fs.statSync(curPath).isDirectory()) {
deleteFile(curPath);
} else {
fs.unlinkSync(curPath);
}
});
fs.rmdirSync(path);
}
};

let mapDir = function mapDir(dir, callback, finish) {
fs.readdir(dir, function (err, files) {
if (err) {
console.error(err);
return;
}
// .md 文件数
let fileNum = 0;
files.forEach((filename, index) => {
let pathname = path.join(dir, filename);
fs.stat(pathname, (err, stats) => {
// 读取文件信息
if (err) {
console.log("获取文件stats失败");
return;
}
if (stats.isDirectory()) {
// 不递归文件夹
//mapDir(pathname, callback, finish)
} else if (stats.isFile()) {
if ([".md"].includes(path.extname(pathname))) {
// 只要 .md 文件

fs.readFile(pathname, (err, data) => {
if (err) {
console.error(err);
return;
}
callback && callback(data, filename, pathname);
});

fileNum++;
if (index === files.length - 1) {
finish && finish(fileNum);
}
}
}
});
});
});
};

let getFileNameWithoutExt = function (filename) {
let endIndex = filename.lastIndexOf(".");
if (endIndex != -1) {
return filename.substring(0, endIndex);
}
return filename;
};

module.exports = { copy, exists, deleteFile, mapDir, getFileNameWithoutExt };

1.3 编写 index.js

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
#!/usr/bin/env node

const { program } = require("commander");
const inquirer = require("inquirer");
const fs = require("fs");
const yaml = require("js-yaml");
const tools = require("./tools");

program
.command("hexop <noteRoot> <blogRoot>")
.description(
"将 Hexo笔记中 标记为public的文章(source/_posts) 复制到 Hexo Blog 中,以供发布"
)
.action((noteRoot, blogRoot) => {
// 1. 先清空 <blogRoot>/source/_posts, 注意:_posts 文件夹也会被删除
tools.deleteFile(`${blogRoot}/source/_posts`);
console.log(`清空 '${blogRoot}/source/_posts' 成功`);
fs.mkdirSync(`${blogRoot}/source/_posts`);
// 提取 markdown 中的 front-matter
let re = /---(.*?)---/s;
const defaultPublic = true;
let publicNum = 0;
let totalNum = 0;
tools.mapDir(
noteRoot + "/source/_posts",
function (data, filename, pathname) {
let s = re.exec(data)[1];
let doc = yaml.load(s);
if (doc.public == undefined) {
doc.public = defaultPublic;
}
if (doc.public) {
publicNum++;
// 2. 复制公开文章文件及对应媒体文件夹 到 <blogRoot>/source/_posts
let temp = `${blogRoot}/source/_posts/${filename}`;
fs.copyFileSync(pathname, temp);
const src = tools.getFileNameWithoutExt(pathname);
const dst = tools.getFileNameWithoutExt(temp);
if(fs.existsSync(src)) {
tools.exists(
src,
dst,
tools.copy
);
}

console.log(`${publicNum}: ${tools.getFileNameWithoutExt(filename)}`);
if(publicNum == totalNum) {
console.log(`复制完毕: ${publicNum}/${totalNum} 公开/总共`);
}
}
},
function (fileNum) {
totalNum = fileNum;
}
);

});

// 解析来自process.argv上的数据,commander会自动帮助我们添加一个 -h 的解析
program.parse(process.argv);

1.4 测试

moq 项目下执行

1
npm install -g

notebook 项目下执行

1
moq hexop './' '../yiyungent.github.io'

1.5 创建 note-to-blog.ps1

在 用于笔记本 的 Hexo 根目录:notebook 创建 note-to-blog.ps1 文件

内容如下:

1
2
3
4
5
6
moq hexop './' '../yiyungent.github.io'
cd ../yiyungent.github.io
git add source/_posts/*
git commit -m 'feat(posts): note-to-blog'
git push
cd ../notebook

注意:

yiyungent.github.io 为本人博客项目文件夹,与 notebook 处于同一级,所以才使用 ../yiyungent.github.io

./ 表示当前路径

最后 cd ../notebook 又切回来,方便以后操作,当然也可以不要

发布到 npm

1
npm publish --registry https://registry.npmjs.org

Q&A

补充

CLI简介

举例:vue-cli: vue create app

1
command [subCommand] [options] [arguments]

command:命令,比如 vue [subCommand]:子命令,比如 vue create [options]:选项,配置,同一个命令不同选项会有不一样的操作结果,比如 vue -h,vue -v [arguments]:参数,某些命令需要使用的值,比如 vue create myApp 选项与参数的区别:选项是命令内置实现,用户进行选择,参数一般是用户决定传入的值

选项一般会有全拼与简写形式(具体看使用的命令帮助),比如 --version = -v 全拼:以 -- 开头 / 简写:以 - 开头 选项也可以接受值,值写在选项之后,通过空格分隔 多个简写的选项可以连写,开头使用一个 - 即可,需要注意的是,如果有接受值的选项需要放在最后,比如: vue create -d -r <-r的值> myApp vue create -dr <-r的值> myApp

执行 PowerShell(xxx.ps1)文件

1
./note-to-blog.ps1

参考

感谢帮助!