一步一步完成一个node-cli
node-cli 即用nodejs与shell交互,完成指定工作的工具。他们通常是长这样的:
sass xx.scss:xx.css
webpack ....
等等,我们实现的这个工具是为了拉取CavinHuang/webpack-multi-skeleton webpack 多页面骨架用于本地快速构建项目的脚手架工具,设想通过以下命令来实现:
webpack-template i # install git端所有的模板列表供选择,选择其中之一后进行本地缓存
webpack-template init # 通过一些选项,初始化整个项目
整个设想大概就是这些,下面就从最简单的开始,来一步一步实现。
实现第一个自己的node命令
我们直接用npm init初始化一个项目出来
npm init node-cli-demo
一路yes即可,进入项目,在package.json中添加如下代码
"bin": {
"hi": "./bin/hi.js"
},
创建bin目录和hi.js,在hi.js中写下如下代码
#!/usr/bin/env node
console.log('Hi Welcome node cli')
用命令行进入当前项目目录,输入
hi
如果提示没有这个命令,输入
npm link
刷新命令即可。
一个最简单的node-cli就完成了。我们来解释下:
-
!/usr/bin/env node在这里有什么作用?
首先我们都知道操作系统中都会有一个 PATH 环境变量,当系统调用一个命令的时候,就会在PATH变量中注册的路径中寻找,如果注册的路径中有就调用,否则就提示命令没找到。我们可以通过process.env获取本机系统中所有的环境变量,所以这句话主要是帮助脚本找到node的脚本解释器,可以理解为调用系统中的node来解析我们的脚本。
处理命令行参数
node process对象一个提供有关当前Node.js进程的信息和控制的全局对象,在node环境下无需通过require()即可调用。
process.argv属性返回一个数组,其中包含启动Node.js进程时传递的命令行参数。第一个元素是process.execPath, 如果需要访问argv[0]的原始值,可以使用process.argv0,第二个元素将是要执行的JavaScript文件的路径, 其余元素将是任何其他命令行参数。
#!/usr/bin/env node
console.log('call %s', process.argv[2]);
然后输入test hello,打印出call hello。
对于命令行参数处理,我们用现成的模块commander来处理,commander提供了用户命令行输入和参数解析强大功能。这里我们就使用轻量级,表达力强大的commander进行处理。
看一个官网的例子
#!/usr/bin/env node
var program = require('commander');
program
.version('0.1.0')
.option('-C, --chdir <path>', 'change the working directory')
.option('-c, --config <path>', 'set config path. defaults to ./deploy.conf')
.option('-T, --no-tests', 'ignore test hook');
program
.command('setup [env]')
.description('run setup commands for all envs')
.option("-s, --setup_mode [mode]", "Which setup mode to use")
.action(function(env, options){
var mode = options.setup_mode || "normal";
env = env || 'all';
console.log('setup for %s env(s) with %s mode', env, mode);
});
program
.command('exec <cmd>')
.alias('ex')
.description('execute the given remote cmd')
.option("-e, --exec_mode <mode>", "Which exec mode to use")
.action(function(cmd, options){
console.log('exec "%s" using %s mode', cmd, options.exec_mode);
}).on('--help', function() {
console.log(' Examples:');
console.log();
console.log(' $ deploy exec sequential');
console.log(' $ deploy exec async');
console.log();
});
program
.command('*')
.action(function(env){
console.log('deploying "%s"', env);
});
program.parse(process.argv);
首先安装commander
yarn add commander # OR npm install commander
看下效果:
hi -V
hi setup
hi exec
commander.js API
- Option() ——> 初始化自定义参数对象,设置“关键字”和“描述”
- Command() ——> 初始化命令行参数对象,直接获得命令行输入,返回一个数组或者string
- Command#command() ——> 定义命令名称
- Command#arguments() ——> 定义初始命令的参数
- Command#parseExpectedArgs() ——> 解析预期参数
- Command#action() ——> 注册命令的回调函数
- Command#option() ——> 定义参数,需要设置“关键字”和“描述”,关键字包括“简写”和“全写”两部分,以”,”,”|”,”空格”做分隔
- Command#allowUnknownOption() ——> 允许命令行未知参数
- Command#parse() ——> 解析process.argv,设置选项和定义时调用命令
- Command#parseOptions() ——> 解析参数
- Command#opts() ——>设置参数
- Command#description() ——> 添加命令描述
- Command#alias() ——> 设置命令别名
- Command#usage() ——> 设置/获取用法
- Command#name()
- Command#outputHelp() ——> 设置展示的help信息
- Command#help()
有了上面的API我们来实现一个用于罗列出当前文件夹下所有文件和文件夹的命令list:
program
.command( 'list' ) //声明hi下有一个命令叫list
.description( 'list files in current working directory' ) //给出list这个命令的描述
.option( '-a, --all', 'Whether to display hidden files' ) //设置list这个命令的参数
.action( function ( options ) { //list命令的实现体
var fs = require( 'fs' );
//获取当前运行目录下的文件信息
fs.readdir( process.cwd(), function ( err, files ) {
var list = files;
if ( !options.all ) { //检查用户是否给了--all或者-a的参数,如果没有,则过滤掉那些以.开头的文件
list = files.filter( function ( file ) {
return file.indexOf( '.' ) !== 0;
} );
}
console.log( list.join( '\n\r' ) ); //控制台将所有文件名打印出来
} );
} );
运行
hi list # hi list -a 或者 --all来查看效果
第一阶段的代码github地址:github传送门,0.0.1分支为第一版本的代码
搭建正式版本的开发环境,使它支持es6语法,支持eslint
yarn add -D babel-cli babel-eslint babel-plugin-transform-es2015-modules-commonjs babel-preset-latest-node
在项目根目录新建.babelrc,内容为:
{
"presets": [
["env", {
"targets": {
"node": "current"
}
}]
],
"plugins": [
"transform-es2015-modules-commonjs"
]
}
新建src目录,用于开发,新建src/command目录和src/utils目录,用于开发使用。
建好后目录结构如下:
├─bin # 脚本启动文件所在目录
├─node_modules # libraray 目录
│ └─commander
│ └─typings
└─src # 开发目录
├─command # 命令实现目录,一个命令对应一个文件
└─utils # 工具目录
接下来我们实现一个入口,把功能转到对应的命令实现文件,来具体实现。新建index.js用于处理入口,再建立src/index.js用于实际的功能转发
index.js 内容如下
// babel解析
require( "babel-register" )
require( "babel-core" )
.transform( "code", {
presets: [ [ require( 'babel-preset-latest-node' ), {
target: 'current'
} ] ]
} );
require( 'babel-polyfill' )
require('./src')
src/index.js 内容如下:
var program = require( 'commander' );
program.parse( process.argv ); //开始解析用户输入的命令
require( './command/' + program.args + '.js' ) // 根据不同的命令转到不同的命令处理文件
解释一下,为什么我想这样做:
- 为了保证文件单一职责,方便维护;
- 方便dev和product加载。
接下来我们建立相应的问价即可,src/command/init.js src/command/install.js 两个命令处理文件,内容如下:
src/command/list.js:
var program = require( 'commander' );
program
.command( 'init' )
.description( 'init project for local' )
.action( function ( options ) { //list命令的实现体
// to do
console.log( 'init command' );
} );
program.parse( process.argv ); //开始解析用户输入的命令
src/command/install.js:
var program = require( 'commander' );
program
.command( 'install' )
.description( 'install github project to local' )
.action( function ( options ) { //list命令的实现体
// to do
console.log( 'install command' );
} );
program.parse( process.argv ); //开始解析用户输入的命令
在命令行输入以下命令来测试:
webpack-template install
webpack-template init
第二版完成代码地址:【第二版github地址,可以clone下来试试】
接下来我们分别实现install功能和init功能。
首先,install步骤设想如下:
- 通过github api拉取仓库里的模板项目
- 通过选择模板进行下载
- 缓存至本地临时目录,供下次直接使用
首先,去github api v3找到所需的api接口,
为了方便单独管理模板项目,我新建了一个organization来管理。所以,我主要是通过
/orgs/:org/repos #获取项目
和
/repos/:owner/:repo #获取版本
项目已经建好,可以通过以下api来查看仓库详情
1、项目列表
url -i https://api.github.com/orgs/cavinHuangORG/repos
2、项目版本
curl -i https://api.github.com/repos/cavinHuangORG/webpack-multipage-template/tags
通过命令行选择选项,效果如下:
inquirer.gif
这里我们用到另外一个命令行交互的库,inquirer.js,主要用来命令行选择和输入;
我们先实现一个简单的在insatll.js完成如下代码:
var inquirer = require( 'inquirer' );
program
.command( 'install' )
.description( 'install github project to local' )
.action( function ( options ) { //list命令的实现体
// to do
console.log( 'install command' );
let choices = [ 'aaa', 'bbb', 'ccc', 'dddd' ];
let questions = [ {
type: 'list',
name: 'repo',
message: 'which repo do you want to install?',
choices
} ];
// 调用问题
inquirer.prompt( questions )
.then( answers => {
console.log( answers ); // 输出最终的答案
} )
} );
program.parse( process.argv ); //开始解析用户输入的命令
最终结果如下:
install-2.gif
到此已经我们要的效果已经差不多完成了。下一步,我希望可以通过用户输入一些特定的参数,来初始化整个项目。
download-git-repo
下面我们要用到一个库,来下载github库的代码,download-git-repo,用法如下:
download(repository, destination, options, callback)
Download a git repository to a destination folder with options, and callback.
将Git存储库下载到带有选项的目标文件夹和回调函数
-
repository github库地址
- GitHub - github:owner/name 或者简写为 owner/name
- GitLab - gitlab:owner/name
- Bitbucket - bitbucket:owner/name
-
destination 目标文件夹
-
options 下载时携带的参数
- clone 默认false
-
callback 完成之后的回调
download-git-repo 用法实例
const downloadGitRepo = require('download-git repo')
// 把目标项目下载到当前目录下的test下
downloadGitRepo('CavinHuang/node-cli-demo', './test', false, err => {
console.log(err ? 'SUCCESS' : "FAIL");
} )
完成git操作类
我们专门分装一个类用来获取git仓库列表、版本信息、下载git代码等操作,主要有以下几个方法,代码就不贴了,代码全在git仓库0.0.3分支
/**
* 获取git仓库列表
*/
async fetchRepoList() {}
/**
* 获取仓库所有的版本
* @param {[string]} repo [仓库名称]
* @return {[type]} [description]
*/
async fetchRepoTagList( repo ) {}
/**
* 获取仓库详细信息
* @param {[string]} repo [仓库名称]
* @return {[type]} [description]
*/
async fetchGitInfo( repo ) {}
/**
* 下载git仓库代码到指定文件夹
* @param {[string]} repo [仓库名称]
* @return {[type]} [description]
*/
async downloadGitRepo( repo ) {}
在install.js里,首先我们要把仓库里的所有的模板拉出来供选择,只要把choices换成我们通过api获取的git长裤列表即可
import gitCtrl from '../utils/gitCtrl'
import config from '../config'
// 初始化git操作类
let git = new gitCtrl.gitCtrl( config.repoType, config.registry )
action里的改成:
// 获取git仓库列表
let choices = await git.fetchRepoList();
下面是根据用户选择仓库下载代码到本地, 我们新建一个config文件夹用来存放一些配置,定义一些常用的变量,如缓存目录,版本等等,新建constant.js
const os = require( 'os' );
import {
name,
version,
engines
} from '../../package.json';
// 系统user文件夹
const home = process.env[ ( process.platform === 'win32' ) ? 'USERPROFILE' : 'HOME' ];
// user agent
export const ua = `${name}-${version}`;
/**
* 文件夹定义
* @type {Object}
*/
export const dirs = {
home,
download: `${home}/.webpack-project`,
rc: `${home}/.webpack-project`,
tmp: os.tmpdir(),
metalsmith: 'metalsmith'
};
/**
* 版本
* @type {Object}
*/
export const versions = {
node: process.version.substr( 1 ),
nodeEngines: engines.node,
[ name ]: version
};
index.js
/**
* 配置文件
*/
export default {
registry: 'cavinHuangORG', // 仓库地址
repoType: 'org', // ['org', 'user']
metalsmith: true
}
有了这些,下边我们就下载代码了:
// 下载库
let result = await git.downloadGitRepo( answers.repo )
console.log( result ? 'SUCCESS' : result )
这时我们运行
webpack-template install
结果如下:
install-2.gif
下面我们添加版本选择,我们把install.js里的代码,稍微修改下,加上版本选择:
// 取出选择的git仓库
const repo = answers.repo;
// 获取选择仓库所有的版本
const tags = await git.fetchRepoTagList( repo );
if ( tags.length === 0 ) {
version = '';
} else {
choices = tags.map( ( {
name
} ) => name );
answers = await inquirer.prompt( [
{
type: 'list',
name: 'version',
message: 'which version do you want to install?',
choices
}
] );
version = answers.version;
}
console.log( answers ); // 输出最终的答案
let result = await git.downloadGitRepo( [ repo, version ].join( '@' ) );
console.log( result ? 'SUCCESS' : result )
install-3.gif
这时我们去看系统的user文件夹下的.webpack-project下,就会找到我们换成的项目了。
到这里,我们install代码已经完成了,github地址
完成init命令
init命令是通过收录一些用户填写的信息来初始化本地项目,其实原理就是把收录的参数进行替换,把下载到缓存目录的项目copy到当前命令行执行目录。
首先我们还是完成最简单的命令行用户输入信息的收入,此处依然使用inquirer来完成:
// 1、选择哪个模板
// 2、当前项目的名字,也是初始化项目的文件夹名字
let questions = [
{
type: 'list',
name: 'template',
message: 'which template do you want to init?',
choices: list
}, {
type: 'input',
name: 'dir',
message: 'project name',
async validate( input ) {
// 下面这行代码用于通知异步任务
const done = this.async();
if ( input.length === 0 ) {
done( 'You must input project name' );
return;
}
const dir = resolve( process.cwd(), input );
if ( await exists( dir ) ) {
done( 'The project name is already existed. Please change another name' );
}
done( null, true );
}
}
];
const answers = await inquirer.prompt( questions )
ncp使用帮助
下面是准备收集更加详细的信息,并且把下载的文件copy一份到临时目录,用于处理,此处copy文件用的是成熟的ncp库,这是一个与linux cp命令接口一致的库。官方网站,基本调用方式: ncp [source] [dest] [--limit=concurrency limit] [--filter=filter] --stopOnErr
实例代码:
var ncp = require('ncp').ncp;
ncp.limit = 16;
ncp(source, destination, function (err) {
if (err) {
return console.error(err);
}
console.log('done!');
});
mkdirp使用帮助
主要作用跟linux mkdir -p 是一样的,只是它运行在node里,也就是递归创建目录。
主要用法:
var mkdirp = require('mkdirp');
mkdirp('/tmp/foo/bar/baz', function (err) {
if (err) console.error(err)
else console.log('pow!')
});
根据这两个库,我们分装一个专门用来copy我们项目的工具函数
import {
ncp
} from 'ncp';
import mkdirp from 'mkdirp'
import {
exists
} from 'mz/fs'
export default function copyFile( src, dest ) {
return new Promise( async ( resolve, reject ) => {
if ( !( await exists( src ) ) ) {
mkdirp.sync( src ); //异步创建
}
ncp( src, dest, ( err ) => {
if ( err ) {
reject( err );
return;
}
resolve();
} );
} );
}
copy到临时文件夹,生成项目是,要经过一个数据填充的过程,这个过程主要用的是一个静态站点生成器(Metalsmith)和swig以及consolidate一个模板引擎合并库
在init.js里添加copy的动作和编译的动作
const answers = await inquirer.prompt( questions )
const metalsmith = config.metalsmith;
if ( metalsmith ) {
const tmp = `${dirs.tmp}/${answers.template}`;
// 复制一份到临时目录,在临时目录编译生成
await copyFile( `${dirs.download}/${answers.template}`, tmp );
await metalsmithACtion( answers.template ); // 根据参数编译
await copyFile( `${tmp}/${dirs.metalsmith}`, answers.dir );
await rmfr( tmp ); // 清除临时文件夹
} else {
await copyFile( `${dirs.download}/${answers.template}`, answers.dir );
}
最后所有的目录结构如下:
│ .babelrc
│ .gitignore
│ index.js
│ package.json
│
├─bin
│ hi.js
│
└─src
│ index.js
│
├─command
│ init.js
│ install.js
│
├─config
│ constant.js
│ index.js
│
└─utils
copyFile.js
gitCtrl.js
initProjectQuestion.js #初始化项目的问题
metalsmithACtion.js #临时文件夹编译动作
render.js #编译模板的插件
到此所有的功能就已经实现了,为了让整个命令用起来更加人性化,更加流程,我们引入ora这个库,项目地址:ora,主要效果如下:
在utils里新建OraLoading.js
import ora from 'ora';
export default function OraLoading( action = 'getting', repo = '' ) {
const l = ora( `${action} ${repo}` );
return l.start();
}
好了,到了这里所有的东西都已经写完了,下面我们来试试效果:
首先是install
install-last.gif
init也是一样的就不演示了
npm 发布
- 到npm.com注册好自己的账户,命令行然后切换到当前目录的文件夹,执行npm login命令,输入自己的账号密码,进行登录即可。
执行
npm publish .
就可以发布自己的npm包了,注意此处一个坑,如果你是用的淘宝源,需要切换回npm源,
npm config set registry http://registry.npmjs.org
否则验证不通过。
最后奉上github完成代码地址: github传送门
请各位老铁不要吝啬自己的start,感谢鼓励!