强大的 Node.js 除了能写传统的 Web 应用,其实还有更广泛的用途。微服务、REST API、各种工具……甚至还能开发物联网和桌面应用。JavaScript 不愧是宇宙第一语言。
Node.js 在开发命令行工具方面也是相当方便,通过这篇教程我们可以来感受下。我们先看看跟命令行有关的几个第三方包,然后从零开始写一个真实的命令行工具。
这个 CLI 的用途就是初始化一个 Git 仓库。当然,底层就是调用了 git init
,但是它的功能不止这么简单。它还能从命令行创建一个远程的 Github 仓库,允许用户交互式地创建 .gitignore
文件,最后还能完成提交和推代码。
![](http://upload-images.jianshu.io/upload_images/1618526-4f40d20b011b2e65.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/751/format/webp)
为什么要用 Node.js 写命令行工具
在动手之前,我们有必要知道为什么选择 Node.js 开发命令行工具。
最明显优势就是——相信你已经猜到了——它是用 JavaScript 写的。
另外一个原因是 Node.js 生态系统非常完善,各种用途的 package 应有尽有,其中就有不少是专门为了开发命令行工具的。
最后一个原因是,用npm
管理依赖不用担心跨平台问题,不像 Aptitude、Yum 或者 Homebrew 这些针对特定操作系统的包管理工具,令人头疼。
注:这么说不一定准确,可能命令行只是需要其他的外部依赖。
动手写一个命令行工具: ginit
![](http://upload-images.jianshu.io/upload_images/1618526-e327844d86964d07.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/652/format/webp)
在这篇教程里我们来开发一个叫做 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' }) ) );
输出效果如下图:
![](http://upload-images.jianshu.io/upload_images/1618526-c6b6784df6a2f39c.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/652/format/webp)
接着运行简单的检查,确保当前目录不是 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
:
![](http://upload-images.jianshu.io/upload_images/1618526-551395fecacf7b41.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/652/format/webp)
处理 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
输入类型就是用来做这个的。
![](http://upload-images.jianshu.io/upload_images/1618526-2e12d56a10340bdc.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/652/format/webp)
首先我们需要做的就是扫描当前目录,忽略.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 。
除此之外,你还可以添加额外的验证、提供跳过某些步骤的功能等等。发挥你的想象力,如果还有其他想法,欢迎留言评论!
作者:空引
链接:https://www.jianshu.com/p/1c5d086c68fa
来源:简书
简书著作权归作者所有,任何形式的转载都请联系作者获得授权并注明出处。