强大的 Node.js 除了能写传统的 Web 应用,其实还有更广泛的用途。微服务、REST API、各种工具……甚至还能开发物联网和桌面应用。JavaScript 不愧是宇宙第一语言。
Node.js 在开发命令行工具方面也是相当方便,通过这篇教程我们可以来感受下。我们先看看跟命令行有关的几个第三方包,然后从零开始写一个真实的命令行工具。
这个 CLI 的用途就是初始化一个 Git 仓库。当然,底层就是调用了 git init
,但是它的功能不止这么简单。它还能从命令行创建一个远程的 Github 仓库,允许用户交互式地创建 .gitignore
文件,最后还能完成提交和推代码。
为什么要用 Node.js 写命令行工具
在动手之前,我们有必要知道为什么选择 Node.js 开发命令行工具。
最明显优势就是——相信你已经猜到了——它是用 JavaScript 写的。
另外一个原因是 Node.js 生态系统非常完善,各种用途的 package 应有尽有,其中就有不少是专门为了开发命令行工具的。
最后一个原因是,用npm
管理依赖不用担心跨平台问题,不像 Aptitude、Yum 或者 Homebrew 这些针对特定操作系统的包管理工具,令人头疼。
注:这么说不一定准确,可能命令行只是需要其他的外部依赖。
动手写一个命令行工具: ginit
在这篇教程里我们来开发一个叫做 ginit 的命令行工具。我们可以把它当做高配版的git init
。什么意思呢?我们都知道,git init
命令会在当前目录初始化一个 git 仓库。但是,这仅仅是创建或关联已有项目到 Git 仓库的其中一步而已。典型的工作流程是这样的:
- 运行
git init
初始化本地仓库 - 创建远程仓库(比如在 Github 或 Bitbucket 上)——这一步通常要脱离命令行,打开浏览器来操作
- 添加 remote
- 创建
.gitignore
文件 - 添加项目文件
- commit 本地文件
- push 到远程
可能还有更多步骤,为了演示我们只看关键部分。你会发现,这些步骤很多都是机械式、重复性的,为什么不用命令行来完成这些工作呢?比如复制粘贴 git 地址这种事情,能忍受手动操作?
ginit 可以做到:在当前目录创建 git 仓库,同时创建远程仓库(我们这里用 Github 演示),然后提供一个类似操作向导的界面来创建 .gitignore
文件,最后提交文件夹内容并推送到远程仓库。可能这也节省不了太多时间,但是它确实给创建新项目带来了些许便利。
好了,我们开始吧。
项目依赖
有一点是肯定的:说到外观,控制台无论如何也不会有图形界面那么复杂。尽管如此,也并不是说控制台一定是那种原始的纯文本丑陋界面。你会惊讶地发现,原来命令行也可以那么好看!我们会用到一些美化命令行界面的库: chalk 给输出内容着色, clui 提供一些可视化组件。还有更好玩的, figlet 可以生成炫酷的 ASCII 字符图案, clear 用来清除控制台。
输入输出方面,低端的 Readline Node.js 模块可以询问用户并接受输入,简单场景下够用了。但我们会用到一个更高端的工具—— Inquirer。除了询问用户的功能,它还提供简单的输入控件:单选框和复选框,这可是在命令行控制台啊,有点意外吧。
我们还用到 minimist 来解析命令行参数。
以下是完整列表:
- chalk :彩色输出
- clear : 清空命令行屏幕
- clui :绘制命令行中的表格、仪表盘、加载指示器等。
- figlet :生成字符图案
- inquirer :创建交互式的命令行界面
- minimist :解析参数
- configstore:轻松加载和保存配置
还有这些:
- @octokit/rest:Node.js 里的 GitHub REST API 客户端
- lodash:JavaScript 工具库
- simple-git:在 Node.js 应用程序中运行 Git 命令的工具
- touch:实现 Unix touch 命令的工具
开始
创建一个项目文件夹。
mkdir ginit
cd ginit
新建一个package.json
文件:
npm init
根据提示一路往下走:
name: (ginit)
version: (1.0.0)
description: "git init" on steroids
entry point: (index.js)
test command:
git repository:
keywords: Git CLI
author: [YOUR NAME]
license: (ISC)
安装依赖:
npm install chalk clear clui figlet inquirer minimist configstore @octokit/rest lodash simple-git touch --save
最终生成的 package.json
文件大概是这样的:
{
"name": "ginit",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"Git",
"CLI"
],
"author": "",
"license": "ISC",
"bin": {
"ginit": "./index.js"
},
"dependencies": {
"@octokit/rest": "^14.0.5",
"chalk": "^2.3.0",
"clear": "0.0.1",
"clui": "^0.3.6",
"configstore": "^3.1.1",
"figlet": "^1.2.0",
"inquirer": "^5.0.1",
"lodash": "^4.17.4",
"minimist": "^1.2.0",
"simple-git": "^1.89.0",
"touch": "^3.1.0"
}
}
然后在这个文件夹里新建一个index.js
文件,加上这段代码:
const chalk = require('chalk');
const clear = require('clear');
const figlet = require('figlet');
添加一些 Helper 方法
接下来新建一个lib
文件夹,用来存放各种 helper 模块:
- files.js — 基本的文件管理
- inquirer.js — 命令行用户界面
- github.js — access token 管理
- repo.js — Git 仓库管理
先看 lib/files.js
,这里需要完成:
- 获取当前路径(文件夹名作为默认仓库名)
- 检查路径是否存在(通过查找名为
.git
的目录,判断当前目录是否已经是 Git 仓库)
看上去很简单直接,但这里还是有点坑的。
首先,你可能想用fs
模块的 realpathSync 方法获取当前路径:
path.basename(path.dirname(fs.realpathSync(__filename)));
当我们在同一路径下运行应用时(即 node index.js
),这没问题。但是要知道,我们要把这个控制台应用做成全局的,就是说我们想要的是当前工作目录的名称,而不是应用的安装路径。因此,最好使用 process.cwd:
path.basename(process.cwd());
其次,检查文件或目录是否存在的推荐方法一直在变。目前的方法是用fs.stat
或fs.statSync
。这两个方法在文件不存在的情况下会抛异常,所以我们要用try...catch
。
最后需要注意的是,当你在写命令行应用的时候,使用这些方法的同步版本就可以了。
整理下lib/files.js
代码,一个工具包就出来了:
const fs = require('fs');
const path = require('path');
module.exports = {
getCurrentDirectoryBase : () => {
return path.basename(process.cwd());
},
directoryExists : (filePath) => {
try {
return fs.statSync(filePath).isDirectory();
} catch (err) {
return false;
}
}
};
回到index.js
文件,引入这个文件:
const files = require('./lib/files');
有了这个,我们就可以动手开发应用了。
初始化 Node CLI
现在让我们来实现控制台应用的启动部分。
为了展示安装的这些控制台输出强化模块,我们先清空屏幕,然后展示一个banner:
clear();
console.log(
chalk.yellow(
figlet.textSync('Ginit', { horizontalLayout: 'full' })
)
);
输出效果如下图:
接着运行简单的检查,确保当前目录不是 Git 仓库。很容易,我们只要用刚才创建的工具方法检查是否存在 .git
文件夹就行了:
if (files.directoryExists('.git')) {
console.log(chalk.red('Already a git repository!'));
process.exit();
}
提示:我们用了 chalk 模块 来展示红色的消息。
提示用户输入
接下来我们要做的就是写个函数,提示用户输入 Github 登录凭证。这个可以用 Inquirer 来实现。这个模块包含一些支持各种提示类型的方法,语法上跟 HTML 表单控件类似。为了收集用户的 Github 用户名和密码,我们分别用了 input
和 password
类型。
首先新建 lib/inquirer.js
文件,加入以下代码:
const inquirer = require('inquirer');
const files = require('./files');
module.exports = {
askGithubCredentials: () => {
const questions = [
{
name: 'username',
type: 'input',
message: 'Enter your GitHub username or e-mail address:',
validate: function( value ) {
if (value.length) {
return true;
} else {
return 'Please enter your username or e-mail address.';
}
}
},
{
name: 'password',
type: 'password',
message: 'Enter your password:',
validate: function(value) {
if (value.length) {
return true;
} else {
return 'Please enter your password.';
}
}
}
];
return inquirer.prompt(questions);
},
}
如你所见,inquirer.prompt()
向用户询问一系列问题,并以数组的形式作为参数传入。数组的每个元素都是一个对象,分别定义了name
、 type
和message
属性。
用户提供的输入信息返回一个 promise 给调用函数。如果成功,我们会得到一个对象,包含username
和password
属性。
可以在index.js
里测试下:
const inquirer = require('./lib/inquirer');
const run = async () => {
const credentials = await inquirer.askGithubCredentials();
console.log(credentials);
}
run();
运行 node index.js
:
处理 GitHub 身份验证
下一步是创建一个函数,用来获取 Github API 的OAuth token。我们实际上是用用户名和密码换取 token的。
当然了,我们不能让用户每次使用这个工具的时候都需要输入身份凭证,而是把 OAuth token 存起来给后续请求使用。这个是就要用到 configstore 这个包了。
保存配置信息
保存配置信息表面上看起来非常简单直接:无需第三方库,直接存取 JSON 文件就好了。但是,configstore 这个包还有几个关键的优势:
- 它会根据你的操作系统和当前用户来决定最佳的文件存储位置。
- 不需要直接读写文件,只要修改 configstore 对象,后面的事都帮你搞定了。
用法也很简单,创建一个实例,传入应用标识符就行了。例如:
const Configstore = require('configstore');
const conf = new Configstore('ginit');
如果 configstore
文件不存在,它会返回一个空对象并在后台创建该文件。如果 configstore
文件已经存在,内容会被解析成 JSON,应用程序就可以使用它了。 你可以把 conf
当成简单的对象,根据需要获取或设置属性。刚才已经说了,你不用担心保存的问题,它已经帮你做好了。
提示:macOS/Linux 系统该文件位于 /Users/[YOUR-USERNME]/.config/configstore/ginit.json
与 GitHub API 通信
让我们写一个库,处理 GitHub token。新建文件 lib/github.js
并添加以下代码:
const octokit = require('@octokit/rest')();
const Configstore = require('configstore');
const pkg = require('../package.json');
const _ = require('lodash');
const CLI = require('clui');
const Spinner = CLI.Spinner;
const chalk = require('chalk');
const inquirer = require('./inquirer');
const conf = new Configstore(pkg.name);
再添加一个函数,检查访问 token 是否已经存在。我们还添加了一个函数,以便其他库可以访问到 octokit
(GitHub) 相关函数:
...
module.exports = {
getInstance: () => {
return octokit;
},
getStoredGithubToken : () => {
return conf.get('github.token');
},
setGithubCredentials : async () => {
...
},
registerNewToken : async () => {
...
}
}
如果 conf
对象存在并且有 github.token
属性,就表示 token 已经存在。在这里我们把 token 值返回给调用的函数。我们稍后会讲到它。
如果 token 没找到,我们需要获取它。当然了,获取 OAuth token 牵涉到网络请求,对用户来说有短暂的等待过程。借这个机会我们可以看看 clui 这个包,它给控制台应用提供了强化功能,转菊花就是其中一个。
创建一个菊花很简单:
const status = new Spinner('Authenticating you, please wait...');
status.start();
任务完成后就可以停掉它,它就从屏幕上消失了:
status.stop();
提示:你也可以用update
方法动态更新文字内容。当你需要展示进度时这会非常有用,比如显示完成的百分比。
完成 GitHub 认证的代码在这:
...
setGithubCredentials : async () => {
const credentials = await inquirer.askGithubCredentials();
octokit.authenticate(
_.extend(
{
type: 'basic',
},
credentials
)
);
},
registerNewToken : async () => {
const status = new Spinner('Authenticating you, please wait...');
status.start();
try {
const response = await octokit.authorization.create({
scopes: ['user', 'public_repo', 'repo', 'repo:status'],
note: 'ginits, the command-line tool for initalizing Git repos'
});
const token = response.data.token;
if(token) {
conf.set('github.token', token);
return token;
} else {
throw new Error("Missing Token","GitHub token was not found in the response");
}
} catch (err) {
throw err;
} finally {
status.stop();
}
},
我们一步一步来看:
- 用之前定义的
setGithubCredentials
方法提示用户输入凭证 - 试图获取 OAuth token之前采用 basic authentication
- 尝试注册新的 token
- 如果成功获取了 token,保存到
configstore
- 返回 token
你创建的任何 token,无论是通过人工还是 API,都可以在 这里看到。在开发过程中,你可能需要删除 ginit 的 access token ——可以通过上面的 note
参数辨认—— 以便重新生成。
提示:如果你的 Github 账户启用了双重认证,这个过程会稍微复杂点。你需要请求验证码(比如通过手机短信),然后通过 X-GitHub-OTP
请求头提供该验证码。更多信息请参阅 Github 开发文档
更新下index.js
文件里的run()
函数,看看效果:
const run = async () => {
let token = github.getStoredGithubToken();
if(!token) {
await github.setGithubCredentials();
token = await github.registerNewToken();
}
console.log(token);
}
请注意,如果某个地方出错的话,你会得到一个 Promise
错误,比如输入的密码不对。稍后我们会讲到处理这些错误的方式。
创建仓库
一旦获得了 OAuth token,我们就可以用它来创建远程 Github 仓库了。
同样,我们可以用 Inquirer
给用户提问。我们需要仓库名称、可选的描述信息以及仓库是公开还是私有。
我们用 minimist 从可选的命令行参数中提取名称和描述的默认值。例如:
ginit my-repo "just a test repository"
这样就设置了默认名称为my-repo
,默认描述为just a test repository
下面这行代码把参数放在一个数组里:
const argv = require('minimist')(process.argv.slice(2));
// { _: [ 'my-repo', 'just a test repository' ] }
提示:这里只展示了 minimist 功能的一点皮毛而已。你还可以用它来解析标志位参数、开关和键值对。更多功能请查看它的文档。
接下来我们加上解析命令行参数的代码,并向用户提出一系列问题。首先更新lib/inquirer.js
文件,在askGithubCredentials
函数后面加上以下代码:
...
askRepoDetails: () => {
const argv = require('minimist')(process.argv.slice(2));
const questions = [
{
type: 'input',
name: 'name',
message: 'Enter a name for the repository:',
default: argv._[0] || files.getCurrentDirectoryBase(),
validate: function( value ) {
if (value.length) {
return true;
} else {
return 'Please enter a name for the repository.';
}
}
},
{
type: 'input',
name: 'description',
default: argv._[1] || null,
message: 'Optionally enter a description of the repository:'
},
{
type: 'list',
name: 'visibility',
message: 'Public or private:',
choices: [ 'public', 'private' ],
default: 'public'
}
];
return inquirer.prompt(questions);
},
接着创建lib/repo.js
文件,加上这些代码:
const _ = require('lodash');
const fs = require('fs');
const git = require('simple-git')();
const CLI = require('clui')
const Spinner = CLI.Spinner;
const inquirer = require('./inquirer');
const gh = require('./github');
module.exports = {
createRemoteRepo: async () => {
const github = gh.getInstance();
const answers = await inquirer.askRepoDetails();
const data = {
name : answers.name,
description : answers.description,
private : (answers.visibility === 'private')
};
const status = new Spinner('Creating remote repository...');
status.start();
try {
const response = await github.repos.create(data);
return response.data.ssh_url;
} catch(err) {
throw err;
} finally {
status.stop();
}
},
}
根据获取的信息,我们就可以利用 Github 包 创建仓库了,它会返回新创建的仓库 URL。然后我们就可以把这个地址设置为本地仓库的 remote。不过还是先新建一个.gitignore
文件吧。
创建 .gitignore 文件
下一步我们将要创建一个简单的“向导”命令行,用来生成 .gitignore
文件。如果用户在已有项目路径里运行我们的应用程序,我们给用户列出当前工作目录的文件和目录, 以让他们选择忽略哪些。
Inquirer 提供的 checkbox
输入类型就是用来做这个的。
首先我们需要做的就是扫描当前目录,忽略.git
文件夹和任何现有的 .gitignore
文件。我们用 lodash 的 without 方法来做:
const filelist = _.without(fs.readdirSync('.'), '.git', '.gitignore');
如果没有符合条件的结果,就没必要继续执行了,直接 touch
当前的.gitignore
文件并退出函数。
if (filelist.length) {
...
} else {
touch('.gitignore');
}
最后,我们用 Inquirer’s 的 checkbox 列出所有文件。在 lib/inquirer.js
加上如下代码:
...
askIgnoreFiles: (filelist) => {
const questions = [
{
type: 'checkbox',
name: 'ignore',
message: 'Select the files and/or folders you wish to ignore:',
choices: filelist,
default: ['node_modules', 'bower_components']
}
];
return inquirer.prompt(questions);
},
..
请注意,我们也可以提供默认忽略列表。在这里我们预先选择了 node_modules
和 bower_components
目录,如果存在的话。
有了 Inquirer 的代码,现在我们可以写 createGitignore()
函数了。在 lib/repo.js
文件里插入这些代码:
...
createGitignore: async () => {
const filelist = _.without(fs.readdirSync('.'), '.git', '.gitignore');
if (filelist.length) {
const answers = await inquirer.askIgnoreFiles(filelist);
if (answers.ignore.length) {
fs.writeFileSync( '.gitignore', answers.ignore.join( '\n' ) );
} else {
touch( '.gitignore' );
}
} else {
touch('.gitignore');
}
},
...
一旦用户确认,我们把选中的文件列表用换行符拼接起来,写入 .gitignore
文件。 有了.gitignore
文件,可以初始化 Git 仓库了。
应用程序中的 Git 操作
操作 Git 的方法有很多,最简单的可能就是使用 simple-git 了。它提供一系列的链式方法运行 Git 命令。
我们用它来自动化的重复性任务有这些:
- 运行
git init
- 添加
.gitignore
文件 - 添加工作目录的其余内容
- 执行初次 commit
- 添加新创建的远程仓库
- push 工作目录到远端
在 lib/repo.js
中插入以下代码:
...
setupRepo: async (url) => {
const status = new Spinner('Initializing local repository and pushing to remote...');
status.start();
try {
await git
.init()
.add('.gitignore')
.add('./*')
.commit('Initial commit')
.addRemote('origin', url)
.push('origin', 'master');
return true;
} catch(err) {
throw err;
} finally {
status.stop();
}
},
...
全部串起来
首先在 lib/github.js
中写几个 helper 函数。一个用来方便地存取 token,一个用来建立 oauth
认证:
...
githubAuth : (token) => {
octokit.authenticate({
type : 'oauth',
token : token
});
},
getStoredGithubToken : () => {
return conf.get('github.token');
},
...
接着在 index.js
里写个函数用来处理获取 token 的逻辑。在run()
函数前加入这些代码:
const getGithubToken = async () => {
//从 config store 获取 token
let token = github.getStoredGithubToken();
if(token) {
return token;
}
// 没找到 token ,使用凭证访问 GitHub 账号
await github.setGithubCredentials();
// 注册新 token
token = await github.registerNewToken();
return token;
}
最后,更新run()
函数,加上应用程序主要逻辑处理代码。
const run = async () => {
try {
// 获取并设置认证 Token
const token = await getGithubToken();
github.githubAuth(token);
// 创建远程仓库
const url = await repo.createRemoteRepo();
// 创建 .gitignore 文件
await repo.createGitignore();
// 建立本地仓库并推送到远端
const done = await repo.setupRepo(url);
if(done) {
console.log(chalk.green('All done!'));
}
} catch(err) {
if (err) {
switch (err.code) {
case 401:
console.log(chalk.red('Couldn\'t log you in. Please provide correct credentials/token.'));
break;
case 422:
console.log(chalk.red('There already exists a remote repository with the same name'));
break;
default:
console.log(err);
}
}
}
}
如你所见,在顺序调用其他函数(createRemoteRepo()
, createGitignore()
, setupRepo()
)之前,我们要确保用户是通过认证的。代码还处理了异常,并给予了用户适当的反馈。
让 ginit 命令全局可用
剩下的一件事是让我们的命令行在全局可用。为此,我们需要在index.js
文件顶部加上一行叫 shebang 的代码:
#!/usr/bin/env node
接着在package.json
文件中新增一个 bin
属性。它用来绑定命令名称(ginit
)和对应被执行的文件(路径相对于 package.json
)。
"bin": {
"ginit": "./index.js"
}
然后,在全局安装这个模块,这样一个可用的 shell 命令就生成了。
npm install -g
提示:Windows下也是有效的, 因为 npm 会帮你的脚本安装一个 cmd 外壳程序
更进一步
我们已经做出了一个漂亮却很简单的命令行应用程序用来初始化 Git 仓库。但是你还可以做很多事来进一步加强它。
如果你是 Bitbucket 用户,你可以适配该程序去使用 Bitbucket API 来创建仓库。有个 [Node.js API (https://www.npmjs.com/package/bitbucket-api) 可以帮你起步。你可能希望增加几个命令行选项,或者让用户选择使用 Github 还是 Bitbucket(用 Inquirer 再适合不过了),或者直接把 Github 相关的代码替换成 Bitbucket 对应的代码。
你还可以指定.gitgnore
文件默认列表,这方面 preferences
包比较合适,或者可以提供一些模板—— 可能是让用户选择项目类型。还可以把它集成到 .gitignore.io 。
除此之外,你还可以添加额外的验证、提供跳过某些步骤的功能等等。发挥你的想象力,如果还有其他想法,欢迎留言评论!