手把手教你用Node.js建立CLI

Node.js除了能够编写“传统“的Web应用外,还有其余更普遍的用途。微服务、REST API、工具、物联网,甚至桌面应用,它能知足你的任何开发需求。javascript

本文要作的事情就是利用Node.js来构建命令行工具CLI。咱们先来看一些用于建立命令行的第三方npm包,而后,从零开始构建命令行工具。前端

咱们将要实现一个命令行工具,它的做用是初始化Git仓库。固然,它不只仅是在后台运行git init,他还会作一些别的事情。咱们能够经过它来初始化Git仓库,而且容许用户经过交互的方式建立.gitignore文件,最终执行提交并推送代码到远端仓库。java

与以往同样,你们能够在GitHub(https://github.com/sssssssh/ginit)上找到本教程随附的代码。node

1、为何用Node.js来构建命令行工具

在深刻研究以前,咱们有必要了解一下为何咱们选择Node.js来构建命令行工具。git

最明显的好处是,若是你在阅读本文那么大几率是由于你对JavaScript已经很了解。github

另外一个关键优点是,使用Node.js的生态意味着你能够利用成千上万种实现各类目的的npm包。其中有不少是为了构建强大的命令行工具而生的。shell

最后,咱们能够经过npm管理依赖,不须要担忧特定系统的包管理工具带来的兼容问题,例如aptyumhomebrewnpm

2、建立一个命令行工具: ginit

经过这个教程,咱们将构建一个叫ginit的命令行工具。它实现了git init,但又不只仅只有这个功能。json

你可能想知道它究竟是干啥用的。api

众所周知,git init会在当前文件夹初始化git仓库。可是,一般这是将新项目或者已有项目关联到Git上的众多重复步骤中的一步。例如,做为一个经典的工做流程中的一部分,你可能会:

  1. 经过git init初始化本地仓库
  2. 建立远程仓库,这一步一般须要经过浏览器来完成
  3. 添加到远端
  4. 建立.gitignore文件
  5. 添加你本身的项目文件
  6. 提交初始项目文件
  7. 推送到远程仓库

一般会涉及到更多操做,可是,出于教学目的,在本教程中咱们仅仅实现上面的步骤。这些步骤都是重复的,咱们经过命令行工具来实现岂不是比粘贴复制git仓库的连接更好呢?

所以,ginit要作的就是在当前文件夹中建立Git仓库,建立一个远程仓库(这里咱们用git),而后将它添加为远程仓库,而后,它将提供一个简单的交互式向导来建立.gitignore,添加文件并将其推送到远端。他可能不会减小你的时间,可是,会减小一些你的重复劳动。

基于这一点让咱们开始吧!

3、项目依赖

能够确定的一件事:就外观而言,控制台永远不会具备图形用户界面的复杂度。不过,这并不意味着他必须是丑陋的单色文本。你可能会惊讶于在保持功能正常的状况下,命令行工具也能够作的很好看。咱们找到了几个加强界面展现的库:chalk用于在终端中输出彩色的文字;clui用于添加一些UI组件。还有好玩的,咱们会利用figlet建立一个基于ASCII的炫酷横幅,而且利用clear来清空控制台。

在输入和输出方面,Node.js底层的Readline模块用于提示用户输入绰绰有余。可是,咱们将利用一个第三方库Inquirer,它提供了更多复杂的功能。除了实现询问用户的功能外,它在控制台中还提供了单选框和复选框的功能。

咱们还会使用minimist来解析命令行中输入的参数。

这是咱们在开发命令行工具中使用到的完整的npm包列表:

  • chalk: 让咱们的输出变得有色彩;
  • clear: 清空终端屏幕;
  • clui: 绘制命令行中的表格、仪表盘、加载指示器等;
  • figlet: 生成基于ASCII的艺术字;
  • inquirer: 建立交互式的命令行界面;
  • minimist: 解析命令行参数;
  • configstore: 轻松的加载和保存配置信息;

另外,咱们还会使用下面的包:

  • @octokit/rest: 基于Node.js的Github REST API工具;
  • @octokit/auth-basic: Github身份验证策略的一种实现;
  • lodash: JavaScript 工具库;
  • simple-git: 在Node.js中执行Git命令的工具;
  • touch: 实现Unix touch命令的工具;

4、开始你的表演

尽管咱们是从头开始建立这个命令行工具,可是不要忘记你也能够从本文附带的GitHub仓库(https://github.com/sssssssh/ginit)中拷贝一份代码。

为这个项目建立一个新的目录,固然,你能够给他起别的名字,没必要必定叫他ginit

mkdir ginit
cd ginit

建立一个新的package.json文件:

npm init -y

最终将会生成一个这样的package.json文件

{
    "name": "ginit",
    "version": "1.0.0",
    "description": "'git init' on steroids",
    "main": "index.js",
    "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1"
    },
    "keywords": [
        "Git",
        "CLI"
    ],
    "license": "ISC"
}

如今开始安装项目依赖:

npm install chalk clear clui figlet inquirer minimist configstore @octokit/rest @octokit/auth-basic lodash simple-git touch

在项目中建立一个index.js文件,加上以下代码:

// index.js

const chalk = require('chalk');
const clear = require('clear');
const figlet = require('figlet');

5、增长一些有用的方法

在目录中建立一个lib目录,并将咱们的代码分为如下模块:

  • file.js:基础的文件管理
  • inquirer.js:处理命令行中的用户交互;
  • github.js:管用户的git token;
  • repo.js:Git仓库管理;

让咱们开始写lib/file.js中的代码。咱们须要作如下事情:

  1. 获取当前目录(做为当前仓库的默认名称);
  2. 检查目录是否存在(经过检查.git目录是否存在,来判断当前目录是否存在git仓库);

这听起来很简单,可是,有几个问题须要考虑。

首先,你可能会想到用fs模块的realpathSync方法来获取当前目录:

path.basename(path.dirname(fs.realpathSync(__filename)));

当咱们从命令行所在文件的根目录下调用时,这个方法没啥问题。可是,咱们的命令行工具能够在任何目录下调用。这意味着咱们须要得到的是当前工做目录的名称,而不是命令行代码所在目录的名称。因此,你最好使用process.cwd()

path.basename(process.cwd());

第二,检查文件是否存在的最佳方法一直在变化。目前最好的方法是使用existsSync,若是文件存在他会返回true,不然返回false

结合上面所说的,让咱们在lib/files.js中添加以下代码:

// files.js

const fs = require('fs');
const path = require('path');

module.exports = {
    // 获取目录名称
    getCurrentDirectoryBase: () => {
        return path.basename(process.cwd());
    },

    // 判断目录是否存在
    directoryExists: (filePath) => {
        return fs.existsSync(filePath);
    },
};

index.js中,添加下面的代码:

// index.js

const files = require('./lib/files');

有了这个,咱们就能够动手开发咱们的命令行工具了。

6、初始化命令行工具

如今让咱们来实现命令行工具的启动阶段。

为了展现咱们安装的加强控制台输出的模块,咱们先清空屏幕,再展现一个banner,在index.js中添加以下代码:

// index.js

// 清除命令行
clear();

// 输出Logo
console.log(chalk.yellow(figlet.textSync('Ginit', { horizontalLayout: 'full' })));

你能够经过运行node index.js来执行它,输出效果以下:

接下来,让咱们进行一个简单的检查,以确保当前目录不存在git仓库。这很简单,只须要利用咱们建立的方法来检查.git方法是否存在便可,在index.js中添加以下代码:

// 判断是否存在.git文件
if (files.directoryExists('.git')) {
    console.log(chalk.red('已经存在一个本地仓库!'));
    process.exit();
}

7、提示用户输入

接下来,咱们须要建立一个函数来引导用户输入他们的GitHub帐号和密码。

咱们可使用Inquirer来实现,它提供了不少种类型的提示方法。这些方法有些相似于HTML中的控件。为了收集用户的GitHub帐号和密码,咱们须要使用到input和password类型的控件。

首先,在lib/inquirer.js中添加以下代码:

// inquirer.js

const inquirer = require('inquirer');
const files = require('./files');

module.exports = {
    // 询问git帐号信息
    askGithubCredentials: () => {
        const questions = [
            {
                name: 'username',
                type: 'input',
                message: '请输入你的git帐号或邮箱地址:',
                validate: function (value) {
                    if (value.length) {
                        return true;
                    } else {
                        return '请输入你的git帐号或邮箱地址.';
                    }
                },
            },
            {
                name: 'password',
                type: 'password',
                message: '请输入你的密码:',
                validate: function (value) {
                    if (value.length) {
                        return true;
                    } else {
                        return '请输入你的密码.';
                    }
                },
            },
        ];
        return inquirer.prompt(questions);
    }
};

如你所见,经过inquirer.prompt()向用户询问一系列问题,咱们将这些问题以数组的形式传递给函数prompt。每一问题都由一个对象构成,其中,name表示该字段的名称,type表示咱们要使用控件类型,message是咱们要展现给用户的话,validate是校验用户输入字段的函数。

inquirer.prompt()将会返回一个Promise对象,若是校验经过,咱们将会获得一个拥有namepassword2个属性的对象。

将以下代码添加在index.js

// index.js

const inquirer  = require('./lib/inquirer');

const run = async () => {
    const credentials = await inquirer.askGithubCredentials();
    console.log(credentials);
};

run();

运行node index.js结果以下:

提示:当你完成测试后,不要忘了把const inquirer = require('./lib/inquirer');index.js中删除,由于咱们不须要它。

8、处理GitHub受权

下一步是建立一个函数,用于获取GitHub APIOAuth TOKEN。实际上,咱们就是经过帐号和密码来获取token

固然,咱们不但愿用户每次使用咱们的工具时,都须要输入帐号和密码。相反,咱们将保存OAuth令牌用于后续的请求。这就要用到configstore这个包啦。

9、保存配置

保存配置信息表面上看很简单,你能够简单的读写一个JSON文件就行了。可是,configstore这个包有如下优点:

  1. 它会根据你的操做系统和当前用户来决定最佳的文件存储位置;
  2. 不须要读取文件,只须要修改configstore对象便可,后面的事他帮你搞定;

用法很简单,建立一个实例,传入一个标识符便可,例如:

const Configstore = require('configstore');
const conf = new Configstore('ginit');

若是configstore文件不存在,他会返回一个空对象而且在后台建立一个文件。若是文件存在,你能够直接利用里面的内容。你如今能够根据须要直接修改conf对象的属性。同时,你也不须要担忧怎么去保存它,它本身会处理好的。

提示:在macOS系统上,文件将会保存在/Users/[YOUR-USERNME]/.config/configstore/ginit.json下。在Linux系统上文件保存在/home/[YOUR-USERNME]/.config/configstore/ginit.json

10、与GitHub API通讯

让咱们来建立一个文件来处理GitHub Token。建立lib/github.js并将下列代码拷入:

// github.js

const CLI = require('clui');
const Configstore = require('configstore');
const Spinner = CLI.Spinner;
const { Octokit } = require("@octokit/rest")
const { createBasicAuth } = require('@octokit/auth-basic');

const inquirer = require('./inquirer');
const pkg = require('../package.json');
// 初始化本地的存储配置
const conf = new Configstore(pkg.name);

如今让咱们来建立一个函数来检查咱们是否拥有token。咱们还建立了一个函数,方便其余模块获取到octokit实例。在lib/github.js中增长下列代码:

// github.js

// ...初始化

// 模块内部的单例
let octokit;

module.exports = {
    // 获取octokit实例
    getInstance: () => {
        return octokit;
    },

    // 获取本地token
    getStoredGithubToken: () => {
        return conf.get('github.token');
    }
}

若是conf对象存在且github.token属性也存在,就表示token存在。这里咱们就能够把token返回给调用函数。咱们稍后会讲它。

若是没检查到token,则须要去获取一个。固然,获取OAuth token涉及到网络请求,这意味着用户须要短暂的等待。借此机会,咱们能够看到clui提供控制带UI加强功能,loading效果就是其中一个。

建立一个loading效果很简单:

const status = new Spinner('Authenticating you, please wait...');
status.start();

完成后,只须要中止他,他就会在屏幕上消失:

status.stop();

提示:你也能够用update来动态的设置文字。若是你须要一个进度指示器,例如展现当前的进度的百分比,这可能很是有用。

将下面代码拷贝到lib/github.js中,这是完成GitHub认证的代码:

// github.js

module.exports = {
    // 获取实例
    getInstance: () => { ... },

    // 获取本地token
    getStoredGithubToken: () => { ... },

    // 经过我的帐号信息获取token
    getPersonalAccessToken: async () => {
        const credentials = await inquirer.askGithubCredentials();
        const status = new Spinner('验证身份中,请等待...');

        status.start();

        const auth = createBasicAuth({
            username: credentials.username,
            password: credentials.password,
            async on2Fa() {
                // 等待实现
            },
            token: {
                scopes: ['user', 'public_repo', 'repo', 'repo:status'],
                note: 'ginit, the command-line tool for initalizing Git repos',
            },
        });

        try {
            const res = await auth();

            if (res.token) {
                conf.set('github.token', res.token);
                return res.token;
            } else {
                throw new Error('获取GitHub token失败');
            }
        } finally {
            status.stop();
        }
    }
};

让咱们来逐步完成:

  1. 用以前咱们定义的函数askGithubCredentials来询问用户的帐号和密码;
  2. 咱们使用createBasicAuth来建立一个auth函数,方便后面调用。须要给这个函数传递用户的用户名和密码,同时还须要传递一个token对象,它拥有下面2个属性:

    1. note:记录获取token的用途;
    2. scopes:一个受权信息使用范围的列表,你能够在GitHub上了解更多信息;
  3. 咱们将会try catch中利用await语法等待函数的返回结果;
  4. 若是受权成功,咱们将会获取到token,能够把它放到configstore中,方便下次直接使用;
  5. 若是由于某些缘由致使受权失败,咱们将在捕捉到它,根据状态码处理异常的状况;

您建立的任何token(不管是手动建立的仍是经过API生成的)均可以在此处看到。 在开发过程当中,你可能须要删除ginittoken(能够经过上面提供的note参数识别),以便从新生成它。

更新index.js中的代码:

// index.js

const github = require('./lib/github');

...

const run = async () => {
    // 从本地获取token记录
    let token = github.getStoredGithubToken();
    if(!token) {
        // 经过帐号、密码获取token
        token = await github.getPersonalAccessToken();
    }
    console.log(token);
};

第一次运行时,你须要输入你的用户名和密码。咱们将会在github上建立一个token并把它保存起来。下次运行时,咱们将直接使用保存起来的token作身份认证。

11、处理双重认证

但愿你注意到上面代码中的on2Fa函数。当用户的帐号使用双重认证时,将会调用到这个函数。让咱们在lib/inquirer.js中插入以下代码:

// inquirer.js

const inquirer = require('inquirer');

module.exports = {
    // 询问git帐号信息
    askGithubCredentials: () => { ... },

    // 询问双重认证码
    getTwoFactorAuthenticationCode: () => {
        return inquirer.prompt({
            name: 'twoFactorAuthenticationCode',
            type: 'input',
            message: '请输入你的双重认证验证码:',
            validate: function (value) {
                if (value.length) {
                    return true;
                } else {
                    return '请输入你的双重认证验证码:.';
                }
            },
        });
    }
}

修改lib/github.js中的on2Fa函数:

// github.js

async on2Fa() {
    status.stop();
    const res = await inquirer.getTwoFactorAuthenticationCode();
    status.start();
    return res.twoFactorAuthenticationCode;
}

如今咱们的程序能够处理GitHub双重认证。

12、建立仓库

获取Oauth令牌以后,咱们就能够利用它来建立远程仓库。

一样,咱们能够利用Inquirer来问一系列问题。咱们须要获取一个仓库名字,咱们能够要求用户选填一个描述,还须要询问仓库是共有仍是私有。

咱们能够利用minimist来从命令行参数中获取仓库名称和描述。

ginit my-repo "just a test repository"

下面的代码将会解析出一个数组:

const argv = require('minimist')(process.argv.slice(2));
// { _: [ 'my-repo', 'just a test repository' ] }

咱们将经过代码来实现上面所说的提问。首先将下列代码拷贝到lib/inquirer.js中:

// inquirer.js

const inquirer = require('inquirer');
const files = require('./files');

module.exports = {
    // 询问git帐号信息
    askGithubCredentials: () => { ... },

    // 询问双重认证码
    getTwoFactorAuthenticationCode: () => { ... },

    // 询问仓库详细信息
    askRepoDetails: () => {
        const argv = require('minimist')(process.argv.slice(2));

        const questions = [
            {
                type: 'input',
                name: 'name',
                message: '请输入git仓库名称:',
                default: argv._[0] || files.getCurrentDirectoryBase(),
                validate: function (value) {
                    if (value.length) {
                        return true;
                    } else {
                        return '请输入git仓库名称.';
                    }
                },
            },
            {
                type: 'input',
                name: 'description',
                default: argv._[1] || null,
                message: '请输入仓库描述(选填):',
            },
            {
                type: 'list',
                name: 'visibility',
                message: '共有仓库 或 私有仓库:',
                choices: ['public', 'private'],
                default: 'public',
            },
        ];
        return inquirer.prompt(questions);
    }
};

建立lib/repo.js文件,并添加以下代码:

// repo.js

const CLI = require('clui');
const fs = require('fs');
const git = require('simple-git/promise')();
const Spinner = CLI.Spinner;
const touch = require('touch');
const _ = require('lodash');

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('建立远程仓库中...');
        status.start();

        try {
            const response = await github.repos.createForAuthenticatedUser(data);
            return response.data.ssh_url;
        } finally {
            status.stop();
        }
    }
}

获取以上信息后,我就能够建立Git仓库了。咱们这本地将生成好的仓库设置成咱们的远程仓库。可是,在这以前让咱们以交互的方式来建立一个.gitignore文件吧。

十3、建立 .gitignore

下一步,咱们将要建立一个简单的命令行“向导”来生成.gitignore文件。若是用户在现有项目目录中执行咱们的命令行工具,请向他们展现当前目录已经存在的文件和目录,并容许他们选择须要忽略的文件和文件夹。

inquirer提供了一个复选框给咱们使用。

咱们须要扫描当前目录中.git.gitignore之外的文件。

const filelist = _.without(fs.readdirSync('.'), '.git', '.gitignore');

若是没有文件须要添加到'.gitignore'中,那么直接建立一个.gitignore文件便可

if (filelist.length) {
  ...
} else {
  touch('.gitignore');
}

让咱们在lib/inquirer.js中添加以下代码:

// inquirer.js

// 选择须要忽略的文件
askIgnoreFiles: (fileList) => {
    const questions = [
        {
            type: 'checkbox',
            name: 'ignore',
            message: '请选择你想要忽略的文件:',
            choices: fileList,
            default: ['node_modules', 'bower_components'],
        },
    ];
    return inquirer.prompt(questions);
},

注意:咱们能够提供一些默认选项,若是node_modulesbower_components存在的话,咱们将提早选中。

lib/repo.js中,咱们添加以下代码:

// repo.js

// 建立git ignore
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仓库吧。

十4、在命令行中与git交互

有不少实现和git交互的方法,可是,最简单的方法多是simple-git。这个库提供了一批能够链式调用的异步函数,在后台执行git命令。

这是咱们须要作的重复任务:

  1. 运行git init
  2. 添加.gitignore
  3. 添加目录中的其他文件
  4. 完成初次提交
  5. 添加新建立的远程仓库
  6. 将代码推送到远端

lib/repo.js中添加以下代码:

// repo.js

// 设置
setupRepo: async (url) => {
    const status = new Spinner('初始化本地仓库并推送到远端仓库中...');
    status.start();

    try {
        await git.init();
        await git.add('.gitignore');
        await git.add('./*');
        await git.commit('Initial commit')
        await git.addRemote('origin', url);
        await git.push('origin', 'master');
    } finally {
        status.stop();
    }
},

十5、把代码串起来

首先,咱们须要在lib/github.js文件中增长一个函数,该函数的做用是创建一个oauth认证:

// github.js

// 经过token登录
githubAuth: (token) => {
    octokit = new Octokit({
        auth: token,
    });
},

而后,咱们须要建立一个函数来控制获取token的逻辑。在run函数前,增长以下代码:

// index.js

// 获取github token
const getGithubToken = async () => {
    // 从本地获取token记录
    let token = github.getStoredGithubToken();
    if (token) {
        return token;
    }

    // 经过帐号、密码获取token
    token = await github.getPersonalAccessToken();
    return token;
};

最后,咱们用下面的代码来更新咱们的run函数:

// index.js

const run = async () => {
    try {
        // 获取token
        const token = await getGithubToken();
        github.githubAuth(token);

        // 建立远程仓库
        const url = await repo.createRemoteRepo();

        // 建立 .gitignore
        await repo.createGitignore();

        // 初始化本地仓库并推送到远端
        await repo.setupRepo(url);

        console.log(chalk.green('All done!'));
    } catch (err) {
        if (err) {
            switch (err.status) {
                case 401:
                    console.log(chalk.red("登录失败,请提供正确的登录信息"));
                    break;
                case 422:
                    console.log(chalk.red('远端已存在同名仓库'));
                    break;
                default:
                    console.log(chalk.red(err));
            }
        }
    }
};

如你所见,在调用咱们其余的函数以前(createRemoteRepo(), createGitignore(), setupRepo()),咱们确保用户已经经过了身份验证。并且,还处理任何错误而且给用户适当的反馈。

你能够在git仓库中找到完整的代码。

如今,你就拥有了一个能够运行的命令行工具了。运行一下,看看他是否是按照你的预期工做。

十6、让ginit命令全局可用

还有一件须要作的事就是让咱们的命令行全局可用。为了实现这个事,咱们须要在index.js文件头部加上shebang

#!/usr/bin/env node

而后,咱们须要在package.json中增长一个bin属性。用于绑定命令名称ginit和被执行的文件。

"bin": {
  "ginit": "./index.js"
}

而后全局安装模块,命令行工具就能够用了。

npm install -g

若是你想确认安装是否生效,你能够把本机上全局安装的node模块列出来看看:

npm ls -g --depth=0

十7、展望

咱们已经建立了一个漂亮且简洁的初始化Git仓库的命令行工具。并且你还能够作不少事情去提高它。

若是你是一个Bitbucket用户,你能够利用Bitbucket API给这个命令行增长一个建立Bitbucket仓库的功能。这个node包 bitbucket-api对你会有帮助。你能够增长另一个命令行选项或者询问用户是否要使用Bitbucket,或者直接把如今处理GitHub的代码替换成Bitbucket

你能够提供一个.gitignore默认的文件集合,而不是硬编码。preferences这个包很适合这个场景,或者你能够提供一个模版,提示用户输入对应的模版类型便可。也能够把它集成到.gitignore.io中。

除此以外,你还能够增长其余验证,提供跳过某些步骤的功能等等。

这是一篇老文章了,不过,今年2月份做者又更新了一部份内容,剔除了其中失效的依赖。同时,在阅读的过程当中,我也优化了一下示例代码。

关于我

我是一个莫得感情的代码搬运工,每周会更新1至2篇前端相关的文章,有兴趣的老铁能够扫描下面的二维码关注或者直接微信搜索前端补习班关注。

精通前端很难,让咱们来一块儿补补课吧!

好啦,翻译完毕啦,原文连接在此 Build a JavaScript Command Line Interface (CLI) with Node.js

相关文章
相关标签/搜索