[译]手把手教你用Node.js创建CLI

5,507 阅读18分钟

Node.js除了可以编写“传统“的Web应用外,还有其他更广泛的用途。微服务、REST API、工具、物联网,甚至桌面应用,它能满足你的任何开发需求。

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

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

与以往一样,大家可以在GitHub(https://github.com/sssssssh/ginit)上找到本教程随附的代码。

一、为什么用Node.js来构建命令行工具

在深入研究之前,我们有必要了解一下为什么我们选择Node.js来构建命令行工具。

最明显的好处是,如果你在阅读本文那么大概率是因为你对JavaScript已经很了解。

另一个关键优势是,使用Node.js的生态意味着你可以利用成千上万种实现各种目的的npm包。其中有很多是为了构建强大的命令行工具而生的。

最后,我们可以通过npm管理依赖,不需要担心特定系统的包管理工具带来的兼容问题,例如aptyumhomebrew

二、创建一个命令行工具: ginit

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

你可能想知道它到底是干啥用的。

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

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

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

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

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

三、项目依赖

可以肯定的一件事:就外观而言,控制台永远不会具有图形用户界面的复杂度。不过,这并不意味着他必须是丑陋的单色文本。你可能会惊讶于在保持功能正常的情况下,命令行工具也可以做的很好看。我们找到了几个增强界面展示的库: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命令的工具;

四、开始你的表演

尽管我们是从头开始创建这个命令行工具,但是不要忘记你也可以从本文附带的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');

五、增加一些有用的方法

在目录中创建一个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');

有了这个,我们就可以动手开发我们的命令行工具了。

六、初始化命令行工具

现在让我们来实现命令行工具的启动阶段。

为了展示我们安装的增强控制台输出的模块,我们先清空屏幕,再展示一个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();
}

七、提示用户输入

接下来,我们需要创建一个函数来引导用户输入他们的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中删除,因为我们不需要它。

八、处理GitHub授权

下一步是创建一个函数,用于获取GitHub APIOAuth TOKEN。实际上,我们就是通过账号和密码来获取token

当然,我们不希望用户每次使用我们的工具时,都需要输入账号和密码。相反,我们将保存OAuth令牌用于后续的请求。这就要用到configstore这个包啦。

九、保存配置

保存配置信息表面上看很简单,你可以简单的读写一个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

十、与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做身份认证。

十一、处理双重认证

希望你注意到上面代码中的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双重认证。

十二、创建仓库

获取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文件吧。

十三、创建 .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仓库吧。

十四、在命令行中与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();
    }
},

十五、把代码串起来

首先,我们需要在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仓库中找到完整的代码。

现在,你就拥有了一个可以运行的命令行工具了。运行一下,看看他是不是按照你的预期工作。

十六、让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

十七、展望

我们已经创建了一个漂亮且简洁的初始化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