手记

Build a JavaScript Command Line Interface(CLI) with Node.js

前言

在创建新项目的时候,总是会觉得很麻烦,因此在看到这篇文章之后,就也动手做了一个。
已发布至npm
github

首先回顾一下,手动操作的话流程如下:

  1. 通过git init命令在当前目录初始化本地git仓库
  2. 创建remote仓库
  3. 将本地仓库关联到remote仓库
  4. 创建.gitignore文件
  5. 提交本地文件并推送到remote仓库

接下来将会按照这个流程来一步步实现CLI。

依赖项

会用到以下这些依赖性:

  • axios: Promise based HTTP client(http客户端)
  • chalk: colorizes the output(让命令行输出有颜色)
  • clui: draws command-line tables, gauges and spinners(在命令行画菊花 :D)
  • configstore: easily loads and saves config without you having to think about where and how. (读取和写入配置文件的客户端)
  • figlet: creates ASCII art from text(以艺术字的形式展示文字)
  • inquirer: creates interactive command-line user interface(创建交互式命令行界面)
  • lodash
  • minimist: parses argument options(转换命令行参数)
  • request: Http request client
  • simple-git: a tool for running Git commands in a Node.js application(在nodejs中快速执行git命令)

初始化项目

先创建一个名为Yginit的目录,在该目录下执行npm init,按照提示一步一步生成package.json文件,然后安装相关依赖性。

mkdir Yginit
cd Yginit

npm install --save axios chalk clui configstore figlet inquirer lodash minimist request simple-git

添加一些帮助类

在根目录添加lib文件夹,并添加以下四个帮助类:

  • files.js: 文件相关的管理方法
  • inquirer.js: 命令行交互方法
  • git.js: git操作相关方法
  • repo.js: repo操作相关方法

files.js

接下来,让我们从lib\files.js开始,
这个帮助类需要提供两个方法:

  • 获取当前文件夹的名称(用于repository的默认名称)
  • 判断目录是否存在(通过检查当前文件夹下是否存在.git文件夹来判断当前文件夹是否已经是一个Git Repository了)

首先,你可能会尝试使用fs module的realPathSync方法来获取当前目录:

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

当我们在和这个应用相同文件夹时,使用这个方法是没问题的,(e.g: node index),但是,要牢记我们这个应用是个全局应用,这意味着我们需要获取的是执行当前命令所在的文件夹,而不是这个应用所在的文件夹。因此,更好的选择是使用process.cwd()

其次,使用fs.statSync来判断文件是否存在时,如果文件不存在,会抛出异常,因此在这里要使用try...catch...来包裹。

最后,在一个命令行应用中,选择同步版本的命令是个更好的选择(比如用fs.statSync)
最终,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');

接下来,我们就要开始开发CLI Application的具体内容了。

初始化CLi

为了让用户更好的交互,首先让我们清理下命令行的输出,并显示一个Banner。

// 清除屏幕
clear();

// 打印Banner
console.log(
  chalk.yellow(
    figlet.textSync('Welcome To Use YgInit', {
      // font: 'Ghost',
      horizontalLayout: 'full',
      verticalLayout: 'default'
    })
    )
  );

备注:这里用到3个第三方库,clear顾名思义用来清理console的output,chalk用来在console输出有颜色的文字,figlet用来生成艺术字。

接下来,使用上面的files.js中提供的方法来判断是否包含.git文件夹,如果包含,则结束。

// 判断是否存在.git
if (files.directoryExists('.git')) {
  console.log(chalk.red('Already a git repository!'));
  process.exit();
} else {
  console.log(chalk.blue('let\'s begin!'));
}

和用户交互

接下来就要采用QA的方式来和用户进行交互,收集所需的信息了。
创建lib\inquirer.js,这个文件用来处理和用户的交互
inquirer.prompt会问用户一系列问题,拿到的结果就是用户输入的信息。

 // 仓库信息交互
  askRepoDetails (groupList) {
    const argv = require('minimist')(process.argv.slice(2), {
      string: ['name', 'desc'],
      alias: {
        name: 'n',
        desc: 'd'
      }
    });

    const questions = [
      {
        name: 'group',
        type: 'list',
        message: 'Choise Group:',
        pageSize: 20,
        choices: groupList.map(item => {
          return {
            name: item.path + '(' + item.description + ')',
            value: item.id
          }
        }),
        validate (val) {
          if (val) {
            return true;
          } else {
            return 'Please Choice a Group';
          }
        }
      },
      {
        type: 'input',
        name: 'reponame',
        message: 'Enter a name for the repository:',
        default: argv.name || files.getCurrentDirectoryBase(),
        validate (val) {
          if (val) {
            return true;
          } else {
            return 'Please Enter Repo Name';
          }
        }
      },

      {
        type: 'input',
        name: 'repodesc',
        message: 'Optionally  enter desc for the repository:',
        default: argv.desc || null
      },

      {
        type: 'list',
        name: 'repotype',
        message: 'Public or private',
        choices: ['public', 'private'],
        default: 'private'
      }
    ];
    return inquirer.prompt(questions);
  }

关于inquirer的详细用法可以看官方文档

一般问题有以下属性:

  • name: 必填项,返回答案的key值
  • type: 这里只用到了input、list(单选)、checkbox(多选)、password
  • message: 问题
  • choice: 如果是list、checkbox等的话的候选项
  • default: 默认值
  • validate: 验证

上面还用到了minimist这个lib,主要是用来转换参数,还可以给参数起别名。

处理和Git的交互

和gitlab的交互都在这个文件夹中,主要有通过用户名密码获取private_token,获取群组信息。

module.exports = {
  registerNewToken (userInfo, url) {
    // 调用api,获取private_token,并存入configstore中
  },

  getStoredToken () {
    // 从configstore中获取private_token
  },

  registerNewGitUrl (url) {
    // 将git_url存入configstore中
  },

  getStoredGitUrl () {
    // 从configstore中获取git_url
  },

  getGroup (url) {
    // 获取当前用户有权限看到的群组列表
  }
};

config store

通过以下方法获取configstore的实例:

const ConfigStore = require('configstore');
const pkg         = require('../package.json');
const conf        = new ConfigStore(pkg.name);

然后很简单的使用getset就可以从configstore中读取或者写入配置项。这样下次如果判断之前有保存,就可以重新直接读取了。

创建远程仓库

在拿到gitlab的private_token之后,就可以创建remote仓库了。
首先让用户选择项目所属群组,然后输入项目的基本信息,再调用api即可创建远程仓库


module.exports = {
  // create remote repo
  createRemoteRepo (url) {
    return new Promise(async (resolve, reject) => {
      const baseUrl = `http://${url}/api/v3`;
      // 获取群组列表
      const groupList = await gitlab.getGroup(baseUrl);
      // 获取选择的群组
      const answers   = await inquirer.askRepoDetails(groupList);

      const postData = {
        name: answers.reponame,
        path: answers.reponame,
        namespace_id: answers.group,
        description: answers.repodesc,
        public: answers.repotype === 'public'
      }

      const status = new Spinner('Creating remote repository...');
      status.start();
      // 创建remote repository
      axios.post(`${baseUrl}/projects/?private_token=${conf.get(pkg.name)}`, postData)
      .then(res => {
        status.stop();
        console.log(`repo created...url:${res.data.web_url}`);
        resolve(res.data.http_url_to_repo);
      })
      .catch(err => {
        console.log(err.toString());
        status.stop();
        reject();
      });
    });
  }
}

创建.gitignore文件

根据用户选择的项目类型(e.g:node、visual stuiod、maven、java),从github/gitignore下载对应的.gitignore文件,保存在当前文件夹。
目前还很简单,也只支持4中项目类型。

 // 创建.gitignore文件
  async createGitIgnore () {

    // 获取项目类型:比如node,java,maven,visual studio等
    const ignoreInfo = await inquirer.askIgnoreFiles();
    const protype = ignoreInfo.protype;
    // 根据项目类型获取.gitignore文件
    let downloadUrl = '';
    switch (protype) {
      case 'visual studio':
      downloadUrl = 'https://raw.githubusercontent.com/github/gitignore/master/VisualStudio.gitignore';
      break;
      case 'node':
      downloadUrl = 'https://raw.githubusercontent.com/github/gitignore/master/Node.gitignore';
      break;
      case 'maven':
      downloadUrl = 'https://raw.githubusercontent.com/github/gitignore/master/Maven.gitignore';
      break;
      case 'java':
      downloadUrl = 'https://raw.githubusercontent.com/github/gitignore/master/Java.gitignore';
      break;
    }

    // 下载并生成.gitignore文件
    return new Promise((resolve, reject) => {
      const ws = fs.createWriteStream('.gitignore');
      request(downloadUrl).pipe(ws);
      ws.on('finish', () => {
        resolve();
      });
      ws.on('error', () => {
        reject();
      })
    });
  }

设置本地Git

接下来使用simple-git来完成以下几步操作:

  1. 运行git init
  2. 添加.gitignore
  3. 添加其他所有文件
  4. 运行commit
  5. 将新创建的git增加到remote repository
  6. git push
async setupRepo (url) {
    const status = new Spinner('Initializing local repository and pushing to remote...');

    status.start();

    try {
      // 利用simple-git链式编程依次执行
      // git init
      // git add .gitignore
      // git add ./*
      // git commit -m "Initial commit"
      // git remote add remote-url
      // git push origin master
      await git
            .init()
            .add('.gitignore')
            .add('./*')
            .commit('Initial commit')
            .addRemote('origin', url)
            .push('origin', 'master');
      return true;
    } catch (err) {
      console.log(err.toString());
      status.stop();
      return false;
    } finally {
      status.stop();
    }
  }

最终效果

将上面这些操作合在一起之后,index.js的伪代码如下:

// 1.0 清除屏幕

// 2.0 打印Banner

// 3.0 判断当前文件夹是否有.git文件夹,如果已存在则提示当前已经存在一个git repository了

// 4.0 创建仓库

// 4.1 交互式方式获取gitlab地址

// 4.2 交互式方式获取用户密码,登陆gitlab换取private_token

// 4.3 创建remote仓库

// 4.4 创建.gitignore文件

// 4.5 初始化仓库

// 5.0 完成

让命令可以在全局执行

上面的开发完成之后,还要最后两步,才能让命令在全局运行。

  1. index.js的最上面,加入#!/usr/bin/env node,这个称之为Shebang

  2. package.json中加入以下节点:

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

发布到npm

使用npm login登录之后,再使用npm publish即可发布了。

至此,一个CLI就开发完成了。

1人推荐
随时随地看视频
慕课网APP