从零开始开发一个node-cli工具

836 阅读5分钟

什么是CLI

命令行界面(英语:command-line interface,缩写:CLI)是在图形用户界面得到普及之前使用最为广泛的用户界面,它通常不支持鼠标,用户通过键盘输入指令,计算机接收到指令后,予以执行。也有人称之为字符用户界面(character user interface, CUI)。

CLI能做什么

我们在项目开发时,经常会用到一些cli工具,比如vue-clinpm init等等,这些CLI工具我们常常用做项目初始化、代码检查、模板创建等交互相对简单,且重复性较多的工作。

准备开发

实现CLI工具开发的方式和语言有很多,本文只介绍基于node的实现方案。

Hello World

依照惯例,我们第一步还是从Hello World开始:

首先,进入工作区,创建并进入项目目录hello-cli,执行npm初始化命令:

mkdir hello-cli
cd hello-cli
npm init

输入或选择一系列项目配置

package name: (hello-cli) 
version: (1.0.0) 
description: hello world
entry point: (index.js) 
test command: test
git repository: 
keywords: cli
author: mulianju
license: (ISC) 

npm会自动创建好项目配置文件package.json

{
  "name": "hello-cli",
  "version": "1.0.0",
  "description": "hello world",
  "main": "index.js",
  "scripts": {
    "test": "test"
  },
  "keywords": [
    "cli"
  ],
  "author": "mulianju",
  "license": "ISC"
}

在项目根目录,创建bin文件夹,并在bin文件夹内创建hello-cli.js文件,文件中写入:

#!/usr/bin/env node
console.log('Hello World!')

注意文件第一行的“注释”,这行“注释”并不是普通的“注释”,他是用来声明此CLI工具的开发语言,所以千万不要删掉。

package.json里添加bin字段,用来创建一个命令,并声明命令指向的执行文件即可:

"bin": {
  "hello-cli": "bin/hello-cli.js"
},

执行本地安装:

npm link

至此,我们的第一个CLI工具就开发完成了。我们新建个终端窗口,执行我们自定义的命令,即可看到效果:

hello-cli

输出结果:

Hello World!

CLI交互

CLI工具最关键的一个点,就是用户交互,简单的交互可以极大扩展我们的CLI的能力,比如以上我们用到的npm init,一些项目信息都需要在我们初始化项目过程中,通过CLI输入或选择。

用来实现CLI交互的,主要依赖以下两个包:

  • commander:完整的node.js命令行解决方案。详细资料
  • inquirer:常见交互式命令行用户界面的集合。详细资料

注意:inquirer9.0.0版本开始,模块化方式改为native esm modules,言下之意,如果你的项目中使用的是CMD模块化方式,则需要限制inquirer的版本低于9.0.0,否则将会抛出以下错误:

internal/modules/cjs/loader.js:1102
      throw new ERR_REQUIRE_ESM(filename, parentPath, packageJsonPath);
      ^

Error [ERR_REQUIRE_ESM]: Must use import to load ES Module: /mnt/d/work/work/2022/08/hello-cli/node_modules/inquirer/lib/inquirer.js
require() of ES modules is not supported.
require() of /mnt/d/work/work/2022/08/hello-cli/node_modules/inquirer/lib/inquirer.js from /mnt/d/work/work/2022/08/hello-cli/bin/hello-cli.js is an ES module file as it is a .js file whose nearest parent package.json contains "type": "module" which defines all .js files in that package scope as ES modules.
Instead rename inquirer.js to end in .cjs, change the requiring code to use import(), or remove "type": "module" from /mnt/d/work/work/2022/08/hello-cli/node_modules/inquirer/package.json.

    at new NodeError (internal/errors.js:322:7)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1102:13)
    at Module.load (internal/modules/cjs/loader.js:950:32)
    at Function.Module._load (internal/modules/cjs/loader.js:790:12)
    at Module.require (internal/modules/cjs/loader.js:974:19)
    at require (internal/modules/cjs/helpers.js:101:18)
    at Object.<anonymous> (/mnt/d/work/work/2022/08/hello-cli/bin/hello-cli.js:3:18)
    at Module._compile (internal/modules/cjs/loader.js:1085:14)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1114:10)
    at Module.load (internal/modules/cjs/loader.js:950:32) {
  code: 'ERR_REQUIRE_ESM'
}

关于以上两个包的更多介绍,基于篇幅原因,我们这里就不详细展开来说了,我们这里只讲一个最简单的应用,其他的功能,待您亲自去尝试~

项目实例

以下应用完成了,通过用户输入的项目信息和一些默认信息,来初始化一个package.json项目配置文件的功能:

#!/usr/bin/env node

const { program } = require("commander")
const inquire = require("inquirer")
const fs = require("fs")

const projectInfo = [
  {
    type: "input",
    message: "请输入项目名称",
    name: "name",
    default: "project",
  },
  {
    type: "input",
    message: "请输入项目描述",
    name: "description",
  },
  {
    type: "input",
    message: "请输入项目作者",
    name: "author",
  },
  {
    type: "input",
    message: "请输入项目git仓库",
    name: "git",
  },
  {
    type: "list",
    message: "请选择开源协议",
    name: "license",
    choices: ["ISC", "BSD", "GPL", "Apache Licence 2.0", "LGPL", "MIT"],
    default: "GPL",
  },
]

const defaultInfo = {
  version: "1.0.0",
  scripts: {},
}

const initAction = () => {
  inquire.prompt(projectInfo).then((answers) => {
    const info = Object.assign({}, defaultInfo, answers)
    fs.writeFile("package.json", JSON.stringify(info), function (err) {
      if (err) {
        res.status(500).send("写入错误")
      } else {
        console.log("项目初始化成功,您的项目信息为:\n", info)
      }
    })
  })
}

switch (process.argv[2]) {
  case "init":
    program
      .command("init")
      .description("初始化项目")
      .action(initAction)
      .parse(process.argv)
    break
  default:
    program
      .usage("<command>")
      .command("init", "初始化项目")
      .parse(process.argv)
    break
}

扩展能力

以上例子,我们实现了自动创建package.json文件,CLI当然不只这点能耐,利用一些npm包,我们还可以实现更丰富的功能。

自动克隆远程git仓库

首先,安装依赖工具:

npm install shelljs --save

编写功能:

const inquire = require("inquirer")
const shell = require("shelljs")

const projectInfo = [
  {
    type: "input",
    message: "请输入项目名称",
    name: "name",
    default: "project",
  }
]

const gitRepository = 'https://github.com/mulianju/hello-cli.git'

const initWithGit = () => {
  inquire.prompt(projectInfo).then((answers) => {
    console.log('项目正在创建...')
    const { name = 'project' } = answers
    shell.exec(`
      rm -rf ./hello-cli
      git clone ${gitRepository}
      rm -rf ./hello-cli/.git
      mv hello-cli ${name}
      cd ${name};
    `)
  })
}

module.exports = {
  initWithGit
}

运行:

hello-cli initWithGit

## CLI交互及输出
? 请输入项目名称 project
项目正在创建...
Cloning into 'hello-cli'...

自动创建模板

首先,安装依赖工具:

npm install art-template chalk --save

注意:chalk5.0.0版本开始,模块化方式也变更了,和inquirer相似,参考

编写功能:

const inquirer = require("inquirer")
const fs = require("fs")
const template = require("art-template")
const chalk = require("chalk")
const path = require('path')
const {
  capitalize,
  camelize,
  mkdirsSync
} = require('./utils')

const rootDir = '../../../..'

const choices = [
  {
    title: "页面(page)",
    value: "page",
  },
  { title: "组件(component)", value: "component" },
]

const promptInfo = [
  {
    type: "list",
    name: "type",
    message: "请选择需要创建的类型?",
    prefix: "[?]",
    choices: choices.map((item) => item.title),
    filter(val) {
      return choices.find((item) => item.title == val).value
    },
  },
  {
    type: "input",
    name: "name",
    message: `请输入名称(支持多级路径, 如:xxx/xxx)?`,
    prefix: "[?]",
    default: "index",
  },
]

const checkTemplatesExistsSync = async () => {
  console.log(__dirname)
  const results = [
    fs.existsSync(path.resolve(__dirname, rootDir, './templates/component.vue.art')),
    fs.existsSync(path.resolve(__dirname, rootDir, './templates/page.vue.art')),
  ]
    .filter((isExist) => !isExist)
    .map((_, index) => choices[index].title)

  if (results.length) {
    console.log(
      `${chalk.green(results.join(","))}${chalk.red(
        "模板不存在,请先创建模板"
      )}`
    )
  } else {
    return true
  }
}

const add = async () => {
  if (await checkTemplatesExistsSync()) {
    inquirer
      .prompt(promptInfo)
      .then(async (answers) => {
        const { type, name: inputName } = answers

        const nameMap = inputName.split('/')
        const name = capitalize(camelize(nameMap.pop()))
        const dirname = path.resolve(__dirname, rootDir, `./${type}s/${nameMap.join('/')}`)
        const templateDir = path.resolve(__dirname, rootDir, `./templates/${type}.vue.art`)

        if (!fs.existsSync(path.resolve(dirname, `./${name}.vue`))) {
          mkdirsSync(dirname)
          fs.writeFileSync(path.resolve(dirname, `./${name}.vue`), template(templateDir, {
            name
          }), 'utf8')
        } else {
          const role = choices.find(item => item.value == type)
          console.log(`${chalk.red(role.title)}: ${chalk.green(name)} ${chalk.red('已经存在,换个名字再试试吧')}`)
        }
        console.log(answers)
      })
  }
}

module.exports = {
  add
}

运行:

## 注意:若使用此功能,请将hello-cli项目放置到项目node_modules文件夹,并执行npm link本地安装
## 并且项目根目录需创建templates文件夹
## 来存放component.vue.art和page.vue.art两个art-template模板文件
hello-cli add

## CLI交互
[?] 请选择需要创建的类型? 组件(component)
[?] 请输入名称(支持多级路径, 如:xxx/xxx)? index/test_component

运行后,会在项目根目录自动创建components/index/TestComponent.vue文件

以上,简单做两个例子,更多功能期待你们探索

结语

本文案例,均存放在开源项目: hello-cli

本文永久地址:从零开始开发一个node-cli工具