什么是CLI 命令行界面(英语:command-line interface,缩写:CLI)是在图形用户界面得到普及之前使用最为广泛的用户界面,它通常不支持鼠标,用户通过键盘输入指令,计算机接收到指令后,予以执行。也有人称之为字符用户界面(character user interface, CUI)。
CLI能做什么 我们在项目开发时,经常会用到一些cli工具,比如vue-cli
、npm init
等等,这些CLI工具我们常常用做项目初始化、代码检查、模板创建等交互相对简单,且重复性较多的工作。
准备开发 实现CLI工具开发的方式和语言有很多,本文只介绍基于node的实现方案。
Hello World 依照惯例,我们第一步还是从Hello World
开始:
首先,进入工作区,创建并进入项目目录hello-cli
,执行npm
初始化命令:
1 2 3 mkdir hello-clicd hello-clinpm init
输入或选择一系列项目配置
1 2 3 4 5 6 7 8 9 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 { "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
文件,文件中写入:
1 2 #!/usr/bin/env node console .log ('Hello World!' )
注意文件第一行的“注释 ”,这行“注释”并不是普通的“注释”,他是用来声明此CLI工具的开发语言,所以千万不要删掉。
在package.json
里添加bin
字段,用来创建一个命令,并声明命令指向的执行文件即可:
1 2 3 "bin" : { "hello-cli" : "bin/hello-cli.js" } ,
执行本地安装:
至此,我们的第一个CLI工具就开发完成了。我们新建个终端窗口,执行我们自定义的命令,即可看到效果:
输出结果:
CLI交互 CLI工具最关键的一个点,就是用户交互,简单的交互可以极大扩展我们的CLI的能力,比如以上我们用到的npm init
,一些项目信息都需要在我们初始化项目过程中,通过CLI输入或选择。
用来实现CLI交互的,主要依赖以下两个包:
commander
:完整的node.js
命令行解决方案。详细资料
inquirer
:常见交互式命令行用户界面的集合。详细资料
注意: inquirer
在9.0.0
版本开始,模块化方式改为native esm modules
,言下之意,如果你的项目中使用的是CMD
模块化方式,则需要限制inquirer
的版本低于9.0.0
,否则将会抛出以下错误:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 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
项目配置文件的功能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 #!/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仓库 首先,安装依赖工具:
1 npm install shelljs --save
编写功能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 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 }
运行:
1 2 3 4 5 6 7 hello-cli initWithGit ? 请输入项目名称 project 项目正在创建... Cloning into 'hello-cli' ...
自动创建模板 首先,安装依赖工具:
1 npm install art-template chalk --save
注意: chalk
在5.0.0
版本开始,模块化方式也变更了,和inquirer
相似,参考
编写功能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 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 }
运行:
1 2 3 4 5 6 7 8 9 hello-cli add [?] 请选择需要创建的类型? 组件(component) [?] 请输入名称(支持多级路径, 如:xxx/xxx)? index/test_component
运行后,会在项目根目录自动创建components/index/TestComponent.vue
文件
以上,简单做两个例子,更多功能期待你们探索
结语 本文案例,均存放在开源项目: hello-cli
本文永久地址:从零开始开发一个node-cli工具
本文永久链接: https://www.mulianju.com/develop-node-cli/