美文网首页Web project 让前端飞程序员
一步一步完成一个node-cli

一步一步完成一个node-cli

作者: 兴国886邓小春 | 来源:发表于2018-02-05 17:36 被阅读572次

    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进行处理。

    官网: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,感谢鼓励!

    相关文章

      网友评论

      本文标题:一步一步完成一个node-cli

      本文链接:https://www.haomeiwen.com/subject/mjsizxtx.html