原文 手把手教你写一个 Node.js CLI javascript
强大的 Node.js 除了能写传统的 Web 应用,其实还有更普遍的用途。微服务、REST API、各类工具……甚至还能开发物联网和桌面应用。JavaScript 不愧是宇宙第一语言。php
Node.js 在开发命令行工具方面也是至关方便,经过这篇教程咱们能够来感觉下。咱们先看看跟命令行有关的几个第三方包,而后从零开始写一个真实的命令行工具。css
这个 CLI 的用途就是初始化一个 Git 仓库。固然,底层就是调用了 git init
,可是它的功能不止这么简单。它还能从命令行建立一个远程的 Github 仓库,容许用户交互式地建立 .gitignore
文件,最后还能完成提交和推代码。html
在动手以前,咱们有必要知道为何选择 Node.js 开发命令行工具。java
最明显优点就是——相信你已经猜到了——它是用 JavaScript 写的。node
另一个缘由是 Node.js 生态系统很是完善,各类用途的 package 应有尽有,其中就有很多是专门为了开发命令行工具的。git
最后一个缘由是,用npm
管理依赖不用担忧跨平台问题,不像 Aptitude、Yum 或者 Homebrew 这些针对特定操做系统的包管理工具,使人头疼。github
注:这么说不必定准确,可能命令行只是须要其余的外部依赖。shell
在这篇教程里咱们来开发一个叫作 ginit 的命令行工具。咱们能够把它当作高配版的git init
。什么意思呢?咱们都知道,git init
命令会在当前目录初始化一个 git 仓库。可是,这仅仅是建立或关联已有项目到 Git 仓库的其中一步而已。典型的工做流程是这样的:npm
git init
初始化本地仓库.gitignore
文件可能还有更多步骤,为了演示咱们只看关键部分。你会发现,这些步骤不少都是机械式、重复性的,为何不用命令行来完成这些工做呢?好比复制粘贴 git 地址这种事情,能忍受手动操做?
ginit 能够作到:在当前目录建立 git 仓库,同时建立远程仓库(咱们这里用 Github 演示),而后提供一个相似操做向导的界面来建立 .gitignore
文件,最后提交文件夹内容并推送到远程仓库。可能这也节省不了太多时间,可是它确实给建立新项目带来了些许便利。
好了,咱们开始吧。
有一点是确定的:说到外观,控制台不管如何也不会有图形界面那么复杂。尽管如此,也并非说控制台必定是那种原始的纯文本丑陋界面。你会惊讶地发现,原来命令行也能够那么好看!咱们会用到一些美化命令行界面的库: chalk 给输出内容着色, clui 提供一些可视化组件。还有更好玩的, figlet 能够生成炫酷的 ASCII 字符图案, clear 用来清除控制台。
输入输出方面,低端的 Readline Node.js 模块能够询问用户并接受输入,简单场景下够用了。但咱们会用到一个更高端的工具—— Inquirer。除了询问用户的功能,它还提供简单的输入控件:单选框和复选框,这但是在命令行控制台啊,有点意外吧。
咱们还用到 minimist 来解析命令行参数。
如下是完整列表:
还有这些:
建立一个项目文件夹。
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');
接下来新建一个lib
文件夹,用来存放各类 helper 模块:
先看 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');
有了这个,咱们就能够动手开发应用了。
如今让咱们来实现控制台应用的启动部分。
为了展现安装的这些控制台输出强化模块,咱们先清空屏幕,而后展现一个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 API 的OAuth token。咱们其实是用用户名和密码换取 token的。
固然了,咱们不能让用户每次使用这个工具的时候都须要输入身份凭证,而是把 OAuth token 存起来给后续请求使用。这个是就要用到 configstore 这个包了。
保存配置信息表面上看起来很是简单直接:无需第三方库,直接存取 JSON 文件就行了。可是,configstore 这个包还有几个关键的优点:
用法也很简单,建立一个实例,传入应用标识符就好了。例如:
const Configstore = require('configstore'); const conf = new Configstore('ginit');
若是 configstore
文件不存在,它会返回一个空对象并在后台建立该文件。若是 configstore
文件已经存在,内容会被解析成 JSON,应用程序就可使用它了。 你能够把 conf
当成简单的对象,根据须要获取或设置属性。刚才已经说了,你不用担忧保存的问题,它已经帮你作好了。
提示:macOS/Linux 系统该文件位于 /Users/[YOUR-USERNME]/.config/configstore/ginit.json
让咱们写一个库,处理 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
方法提示用户输入凭证configstore
你建立的任何 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
文件。若是用户在已有项目路径里运行咱们的应用程序,咱们给用户列出当前工做目录的文件和目录, 以让他们选择忽略哪些。
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 的方法有不少,最简单的可能就是使用 simple-git 了。它提供一系列的链式方法运行 Git 命令。
咱们用它来自动化的重复性任务有这些:
git init
.gitignore
文件在 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()
)以前,咱们要确保用户是经过认证的。代码还处理了异常,并给予了用户适当的反馈。
剩下的一件事是让咱们的命令行在全局可用。为此,咱们须要在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 。
除此以外,你还能够添加额外的验证、提供跳过某些步骤的功能等等。发挥你的想象力,若是还有其余想法,欢迎留言评论!