开发笔记之cli工具开发

220 阅读5分钟

前端Cli工具开发

什么是Cli工具

CLI(command-line interface,命令行界面)是指可在用户提示符下键入可执行指令的界面,它通常不支持鼠标,用户通过键盘输入指令,计算机接收到指令后,予以执行。

背景

这次写工具的背景是因为接到一个需求统计组件在所有业务项目中的使用情况,刚开始准备写babel插件读取AST虚拟树进行遍历节点统计,但是这样会牵扯到业务项目的编译配置,所以不予采纳。因为之前有开发cli工具的经验,故便决定使用node来对代码库进行扫描完成统计。

开发准备

  1. 运行环境---node
  2. 入口配置---package.json文件中bin字段配置
  3. 如何处理输入的命令以及参数解析并提供对应的问询---minimist inquirer
  4. 如何做文件目录解析以及文件内容读取并修改--node模块中的fs child_process
  5. 如何展现命令运行的进度,异常以及结果---chalk cli-table-zh progress可视化
  6. 打包工具以及npm上传配置-- rollup package.json

运行环境

因为cli工具运行在node环境,并且会用到大量的node模块不同版本的node,node的api支持情况各有不一,所以要慎重选择node版本。而且工具使用要最好跟现有的业务开发的环境吻合,不然就算你开发出来使用起来也比较麻烦。本次工具开发使用的是v12.22.3

package.json文件配置

1 name: 工具名称,因为要上传到npm 所以最好是命名空间+工具名 例如: @fnt/node-tool 2 bin: 命令入口文件bin:{"fnt": "bin/fnt.js"} 3 files: 配置上传npm的文件夹或者文件 4 scripts: 测试以及打包命令

注意: 在开发工具的时候要特别注意dependencies和devDependencies,一些编译规范的插件尽量放在dev依赖中。

pageage.json

{
  "name": "@fnt/node-tool",
  "version": "1.2.4",
  "description": "这是一个命令行工具cli",
  "main": "index.js",
  "author": "windCatcher",
  "license": "MIT",
  "bin": {
    "fnt": "bin/fnt.js"
  },
  "files": [
    "src",
    "dist",
    "bin",
    "README"
  ],
  "scripts": {
    "dev": "rollup -c -w",
    "build": "rollup -c",
    "clean": "rimraf dist"
  },
  "dependencies": {
    "cli-table-zh": "^0.3.1",
    "commander": "^8.3.0",
    "inquirer": "^8.2.0",
    "minimist": "^1.2.5",
    "ora": "^6.0.1",
    "progress": "^2.0.3"
  },
  "devDependencies": {
    "@types/cli-table": "^0.3.0",
    "@types/inquirer": "^8.1.3",
    "@types/minimist": "^1.2.2",
    "@types/node": "^17.0.0",
    "rollup": "^2.61.0",
    "rollup-plugin-commonjs": "^10.1.0",
    "rollup-plugin-typescript": "^1.0.1",
    "typescript": "^4.5.2"
  }
}

fnt.js

入口文件首行必须写#!/usr/bin/env node,用来告诉命令运行环境,不然无法运行文件!

#!/usr/bin/env node
const { CLI } = require('../dist/index.js')
const path = require('path')
const chalk = require('chalk')

function printPkgVersion() {
  const version = require(path.resolve(__dirname, '../package.json')).version
  console.log(chalk.green(`\u2714 Fnt v${version}`))
}
printPkgVersion()

new CLI().run()

命令解析

  1. 本文选择是使用minimist获取命令输入process.argv,并根据用户不同的输入命令进行不同的问询
  2. 本文选择的是使用inquirer来跟用户进行交互问询

import addTemplate from './commands/add'
import statTemplate from './commands/stat'
import queryList from './commands/list'
import deleteTemplate from './commands/delete'
import initProject from './commands/init'
const minimist = require('minimist')
const chalk = require('chalk')

export default class CLI {
  constructor () {}
  run () {
    this.parseArgs()
  }
  parseArgs() {
    const args = minimist(process.argv.slice(2), {
      alias: {
        version: ['v'],
        help: ['h']
      },
      boolean: ['version', 'help']
    })
    const _ = args._
    const command = _[0]
    if (command) {
      console.log(chalk.yellowBright(`${command} is coming`))
      switch (command) {
        // 统计组件使用情况
        case 'stat':
          statTemplate()
          break
        // 添加git仓库或者组件
        case 'add':
          addTemplate()
          break
        // 删除组件或者git仓库  
        case 'remove':
          deleteTemplate()
          break
        // 查询组件或者仓库
        case 'list':
          queryList()
          break
        // 根据仓库初始化项目
        case 'init':
          initProject()
          break
        default:
      }
    } else {
      if (args.h) {
        console.log('Usage: fnt <command> [options]')
        console.log()
        console.log('Options:')
        console.log('  -v, --version       output the version number')
        console.log('  -h, --help          output usage information')
        console.log()
        console.log('Commands:')
        console.log('  init   Init a project with default templete')
        console.log('  delete   delete a component db or a git repositry db')
        console.log('  add  add a component db or a git repositry db')
        console.log('  list  get all component db or git repositry db')
        console.log('  stat  Count the number of times components are used in git repositories')
      }
    }
  }
}
// inquirer使用参考
import { prompt } from 'inquirer'
const chalk = require('chalk')

// questions就是我们需要对用户进行的交互问询
const questions = [
	{
		type:'表示提问的类型,包括:input, confirm, list, rawlist, expand, checkbox, password, editor',
		name: '存储当前问题回答的变量;',
		message: '问题的描述;',
		default'默认值;',
		choices: '列表选项,在某些type下可用,并且包含一个分隔符(separator);',
		validate: '对用户的回答进行校验;',
		filter: '对用户的回答进行过滤处理,返回处理后的值;',
		transformer: '对用户回答的显示效果进行处理(如:修改回答的字体或背景颜色),但不会影响最终的答案的内容;',
		when: '根据前面问题的回答,判断当前问题是否需要被回答;',
		pageSize: '修改某些type类型下的渲染行数;',
		prefix: '修改message默认前缀;',
		suffix: '修改message默认后缀'
	}
]

prompt(questions).then(async (anwser) => {
	console.log(chalk.red(JSON.stringify(anwser)))
})

信息存储

本文是属于统计工具,所以设计了一个添加需要统计的组件以及git仓库的命令。这里涉及到一个存储的功能,因为只是一个工具所以不可能会有什么后端数据库之类。所以只能通过使用文件存储。

1 第一种方案: 使用插件nedb做数据的增删查(该方案在进行多次增删查的时候会出现找不到对应的文件,所以最后采用第二种方案)

import datastore from 'nedb'
import { resolve } from 'path'

const db = new datastore({
  filename: resolve(__dirname, './db'),
  autoload: true
})

class DB {
  static find (condition:Object) {
    return new Promise((resolve, reject) => {
      db.find(null, condition, (err: TypeError, docs: Array<any>) => {
        if (err) reject(err)
        resolve(docs)
      })
    })
  }
  static insert (doc: Object) {
    return new Promise((resolve, reject) => {
      db.insert(doc, (err: TypeError, newDoc: Array<any>) => {
        if (err) reject(err)
        resolve(newDoc)
      })
    })
  }
  static remove (condition:Object) {
    return new Promise((resolve, reject) => {
      db.remove(condition, (err: Error, newDoc: any) => {
        if (err) reject(err)
        resolve(newDoc)
      })
    })
  }
}

export default DB

2 第二种方案: 使用node的fs模块做数据的增删查

function fsRead(path): Promise<string> {
  return new Promise(function (resolve, reject) {
    fs.readFile(path, {
      flag: 'r+',
      encoding: 'utf-8'
    }, function (err, data) {
      if (err) {
        console.log(chalk.red(err))
        //失败执行的内容
        reject(err)
      } else {
        //成功执行的内容
        resolve(data)
      }
      // console.log(456)
    })
  })
}


function fsWrite(path, content) {
  return new Promise(function (resolve, reject) {
    fs.writeFile(path, content, {
      encoding: "utf-8"
    }, function (err) {
      if (err) {
        console.log(chalk.red(err))
        //  console.log('写入内容出错')
        reject(err)
      } else {
        // console.log("写入内容成功")
        resolve(err)
      }
    })
  })
}
function initDb(dir) {
  const dirPath = path.resolve(__dirname, `./${dir}`)
  return {
    find (condition:Template) {
      return new Promise((resolve, reject) => {
        fsRead(dirPath).then(data => {
          const content = data ? JSON.parse(data) : []
          const result = content.filter(v => {
            let isTure = true
            Object.keys(condition).map(key => {
               if(condition[key] != v[key]) {
                 isTure = false
               }
            })
            return  isTure
          })
          resolve(result)
        }).catch(err => {
          console.log(chalk.red(err))
        })
      })
    },
    insert (doc: any) {
      return new Promise((resolve, reject) => {
        fsRead(dirPath).then(data => {
          const content = data ? JSON.parse(data) : []
          if (Array.isArray(doc)) {
            content.concat(doc)
          } else {
            content.push({ ...doc })
          }
          fsWrite(dirPath, JSON.stringify(content, null , "\t")).then(() => {
            const result = content.filter(v => v.from == doc.from)
            resolve(result)
          })
        }).catch(err => {
          console.log(chalk.red(err))
        })
      })
    },
    remove (condition:Template) {
      return new Promise((resolve, reject) => {
        fsRead(dirPath).then(data => {
          const content = data ? JSON.parse(data) : []
          // 将不满足搜索条件的过滤掉 剩下的全部覆盖文件内容 即可达到删除满足搜索条件内容的目的
          const result = content.filter(v => {
            let isTure = false
            Object.keys(condition).map(key => {
               if(condition[key] != v[key]) {
                 isTure = true
               }
            })
            return  isTure
          })
          fsWrite(dirPath, JSON.stringify(result, null , "\t")).then(() => {
            // 返回对应类型的数据,如果返回两种类型的数据 listTable因为表格列头不一致 将会导致无法显示表格
            const res = result.filter(v => v.from == condition.from)
            resolve(res)
          })
        })
      })
    }
  }
}

git项目搭建

因为该工具有维护git仓库地址的功能,所以我也就干脆加了一个拉取项目到本地的功能。

1 第一种方案: 使用download-git-repo根据git仓库地址下载项目(这个插件下载貌似公司的git有限制,时灵时不灵的。所以最终选择了第二种方案)

const download = require('download-git-repo')

const doDownload = (from:string, dist:string):Promise<DownloadResult> => {
  console.log(`from: ${from}\n`)
  return new Promise((resolve, reject) => {
    download(from, dist, err => {
      if (err) {
        spinner.fail()
        reject({
          status: 0,
          msg: err
        })
      }
      spinner.stop()
      resolve({
        status: 1,
        msg: `New project has been initialized successfully! \n Locate in \n${dist}`
      })
    })
  })
}

2 第二种方案: 使用node的child_process,直接运行git命令

使用exec跑命令的时候要注意的是目录路径,exce的命令是在当前cmd的目录路径下面跑的,node的path是相对当前文件的路径来做处理的。 所以需要特别注意下。另外因为编辑以及删除文件、项目重命名都是使用fs的api来实现的。

// clone项目
prompt(questions).then(({ tplName, name, author, description, version, delGitDir }) => {
const gitRepository = gitRepositoryList.filter(v => v.name == tplName)[0]
exec(`git clone ${gitRepository.path}`, (err, stdout, stderr) => {
  const gitName = gitRepository.path.slice(gitRepository.path.lastIndexOf('/')+1, gitRepository.path.lastIndexOf('.'))
  const projectpath = path.join(process.cwd(), `./${gitName}`)
  const delCommand = utils.detectPlatform() == 'windows' ? 'rmdir /s/q ' : 'rm -rf '
  if (delGitDir === 'No') {
    toEditProjectDir({projectpath, name, author, description, version, gitName})
  } else {
    exec(`${delCommand} ${path.join(projectpath, '.git')}`, (err, stdout, stderr) => {
      toEditProjectDir({projectpath, name, author, description, version, gitName})
    })
  }
});
})
function toEditProjectDir({projectpath, name, author, description, version, gitName}) {
  // 根据问询答案修改package.json
  const jsonPath = path.join(projectpath, 'package.json')
  utils.fsRead(jsonPath).then(data => {
    const packageJsonData = data ? JSON.parse(data) : {}
    Object.assign(packageJsonData, { 
      name: name || packageJsonData.name || '',
      author: author || packageJsonData.author|| '',
      description: description || packageJsonData.description|| '',
      version: version || packageJsonData.version|| ''
    })
    utils.fsWrite(jsonPath, JSON.stringify(packageJsonData, null , "\t")).then(() => {
      fs.renameSync(path.join(process.cwd(), `./${gitName}`), path.join(process.cwd(), `./${name}`))
      console.log(chalk.green('Project is success!'))
      process.exit()
    })
  })
}

npm打包上传

这一块就比较简单了,先打包下项目登录npm账号,然后publish一下就Ok了!