手把手教你写一个 Node.js CLI

强大的 Node.js 除了能写传统的 Web 应用,其实还有更广泛的用途。微服务、REST API、各种工具……甚至还能开发物联网和桌面应用。JavaScript 不愧是宇宙第一语言。

Node.js 在开发命令行工具方面也是相当方便,通过这篇教程我们可以来感受下。我们先看看跟命令行有关的几个第三方包,然后从零开始写一个真实的命令行工具。

这个 CLI 的用途就是初始化一个 Git 仓库。当然,底层就是调用了 git init,但是它的功能不止这么简单。它还能从命令行创建一个远程的 Github 仓库,允许用户交互式地创建 .gitignore文件,最后还能完成提交和推代码。

手把手教你写一个 Node.js CLI_第1张图片
写一个 Node CLI 总共分几步?

为什么要用 Node.js 写命令行工具

在动手之前,我们有必要知道为什么选择 Node.js 开发命令行工具。

最明显优势就是——相信你已经猜到了——它是用 JavaScript 写的。

另外一个原因是 Node.js 生态系统非常完善,各种用途的 package 应有尽有,其中就有不少是专门为了开发命令行工具的。

最后一个原因是,用npm 管理依赖不用担心跨平台问题,不像 Aptitude、Yum 或者 Homebrew 这些针对特定操作系统的包管理工具,令人头疼。

注:这么说不一定准确,可能命令行只是需要其他的外部依赖。

动手写一个命令行工具: ginit

手把手教你写一个 Node.js CLI_第2张图片
Ginit, our Node CLI in action

在这篇教程里我们来开发一个叫做 ginit 的命令行工具。我们可以把它当做高配版的git init。什么意思呢?我们都知道,git init 命令会在当前目录初始化一个 git 仓库。但是,这仅仅是创建或关联已有项目到 Git 仓库的其中一步而已。典型的工作流程是这样的:

  1. 运行git init初始化本地仓库
  2. 创建远程仓库(比如在 Github 或 Bitbucket 上)——这一步通常要脱离命令行,打开浏览器来操作
  3. 添加 remote
  4. 创建.gitignore文件
  5. 添加项目文件
  6. commit 本地文件
  7. 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.statfs.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' })
  )
);

输出效果如下图:

手把手教你写一个 Node.js CLI_第3张图片
The welcome banner on our Node CLI, created using Chalk and Figlet

接着运行简单的检查,确保当前目录不是 Git 仓库。很容易,我们只要用刚才创建的工具方法检查是否存在 .git 文件夹就行了:

if (files.directoryExists('.git')) {
  console.log(chalk.red('Already a git repository!'));
  process.exit();
}

提示:我们用了 chalk 模块 来展示红色的消息。

提示用户输入

接下来我们要做的就是写个函数,提示用户输入 Github 登录凭证。这个可以用 Inquirer 来实现。这个模块包含一些支持各种提示类型的方法,语法上跟 HTML 表单控件类似。为了收集用户的 Github 用户名和密码,我们分别用了 inputpassword 类型。

首先新建 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() 向用户询问一系列问题,并以数组的形式作为参数传入。数组的每个元素都是一个对象,分别定义了nametypemessage属性。

用户提供的输入信息返回一个 promise 给调用函数。如果成功,我们会得到一个对象,包含usernamepassword属性。

可以在index.js里测试下:

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

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

run();

运行 node index.js

手把手教你写一个 Node.js CLI_第4张图片
Getting user input with Inquirer

处理 GitHub 身份验证

下一步是创建一个函数,用来获取 Github API 的OAuth token。我们实际上是用用户名和密码换取 token的。

当然了,我们不能让用户每次使用这个工具的时候都需要输入身份凭证,而是把 OAuth token 存起来给后续请求使用。这个是就要用到 configstore 这个包了。

保存配置信息

保存配置信息表面上看起来非常简单直接:无需第三方库,直接存取 JSON 文件就好了。但是,configstore 这个包还有几个关键的优势:

  1. 它会根据你的操作系统和当前用户来决定最佳的文件存储位置。
  2. 不需要直接读写文件,只要修改 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();
    }
  },

我们一步一步来看:

  1. 用之前定义的 setGithubCredentials 方法提示用户输入凭证
  2. 试图获取 OAuth token之前采用 basic authentication
  3. 尝试注册新的 token
  4. 如果成功获取了 token,保存到 configstore
  5. 返回 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 输入类型就是用来做这个的。

手把手教你写一个 Node.js CLI_第5张图片
Inquirer’s checkboxes in action

首先我们需要做的就是扫描当前目录,忽略.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_modulesbower_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 命令。

我们用它来自动化的重复性任务有这些:

  1. 运行 git init
  2. 添加 .gitignore 文件
  3. 添加工作目录的其余内容
  4. 执行初次 commit
  5. 添加新创建的远程仓库
  6. 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 。

除此之外,你还可以添加额外的验证、提供跳过某些步骤的功能等等。发挥你的想象力,如果还有其他想法,欢迎留言评论!

手把手教你写一个 Node.js CLI_第6张图片
微信公众号:1024 译站

你可能感兴趣的:(手把手教你写一个 Node.js CLI)