使用cli管理自己的模板

1,558 阅读6分钟

一. 为什么

我们在开发过程中,特别是在业务代码的开发时,代码结构一般都是比较固定的,所以开发新的业务节点,都会去拷贝之前写的节点,改吧改吧就行了。

或者我们需要写一个自己的项目,需要新建一个工程,一般也会拷贝之前写过的项目,这些项目代码结果和配置自己比较熟悉,用起来比较顺手。

在这个过程中出现了很多问题

  1. 需要手动去拷贝,比较麻烦
  2. 拷过去的带有原来的代码,清除起来比较麻烦
  1. 换了电脑之前的项目都没了

所以需要写一个类似create-react-app的工具,可以直接下载一个初始化的代码块或者项目。

二. 创建项目

1. 初始化npm项目

2. 在package.json中添加bin命令

"bin": {
  "peach": "./bin/peach.js"
},

3. 创建bin/peach.js文件

在文件头添加下面的代码,这里一定要添加,声明在node环境执行,否则后面的执行会出错。

#!/usr/bin/env node --harmony

4. 安装commander

npm install commander

5. 先写一个最简单的查看版本的代码

//bin/peach.js
#!/usr/bin/env node --harmony

var program = require('commander');

program
    .version(require('../package.json').version)
    .parse(process.argv)

其中version默认参数是 --version和-V,也可以自定义参数比如

.version(require('../package.json').version, '-v, -V, --version')

6. 本地测试

通过npm link将命令link到全局,就可以通过peach命令执行了

npm link

测试发现报错了,执行了ps1命令,我们希望是运行node命令

根据提示查了一下,需要以管理员身份运行以下指令,并且选择A。

set-executionpolicy remotesigned
D:\test\peach-cli> peach -v
1.0.0

没有问题,下来直接修改文件就可以,不用每次都link,如果要去掉link,只需要执行unlink命令即可。

npm unlink

7. 添加初始化命令

#!/usr/bin/env node --harmony

var program = require('commander');

program
    .version(require('../package.json').version, '-v, -V, --version')
program
    .command('init')
    .description('初始化')
    .alias('i')
    .action(() => {
        require('../lib/init')()
    })
program.parse(process.argv)

其中command为命令名称,alias为别名,action是执行脚本

创建../lib/init.js文件

module.exports = () => {
    console.log('init');
}

尝试执行peach init或者peach i

D:\test\peach-cli> peach i
init

8. 拷贝模板到当前目录

在init中编写拷贝模板的代码,暂时先写死所有参数,创建一个测试的模板

const fs = require('fs')
const path = require('path')

module.exports = () => {
    copyTempl();
    /**
     * 复制模板文件
     */
    function copyTempl() {
        let desPath = path.join(__dirname, '../template/react-temp')
        copyDir(desPath, 'peach-test')
        console.log('执行完毕');
    }
    /**
     * 复制
     * @param src
     * @param dist
     * @param callback
     */
    function copyDir(src, dist, callback) {
        fs.access(dist, function (err) {
            if (err) {
                // 目录不存在时创建目录
                fs.mkdirSync(dist, {
                    recursive: true
                });
            }
            _copy(null, src, dist);
        });

        const _copy = (err, src, dist) => {
            if (err) {
                callback(err);
            } else {
                let dir = fs.readdirSync(src, 'utf-8');
                for (let j of dir) {
                    var _src = src + '/' + j;
                    var _dist = dist + '/' + j;
                    let stat = fs.statSync(_src);
                    if (stat.isDirectory()) {
                        copyDir(_src, _dist, callback);
                    } else {
                        fs.writeFileSync(_dist, fs.readFileSync(_src, {
                            encoding: 'utf-8'
                        }));
                    }
                }
            }
        }
    }
}

9. 随便找个目录测试一下

 D:\test> peach i
执行完毕

模板拷贝成功。

10. 用户交互

现在我们的中项目名和可选模板还都是写死的,需要让用户可以自己选择,这个时候需要inquirer来进行交互。

const fs = require('fs')
const path = require('path')
const inquirer = require('inquirer')
const tempList = ['react-temp', 'vue-temp']

module.exports = () => {
  	//inquirer.prompt返回的是一个promise对象
    createQuestion().then(res => {
        const {filename, templName} = res;
        copyTempl(filename, templName)
    })

    function createQuestion() {
        const questions = [{
                type: 'input',
                name: 'filename',
                message: '请输入要创建的文件名:',
                validate: function (value) {
                    var pass = value.match(/\w+/);
                    if (pass) {
                        return true;
                    }
                    return '请输入正确的文件名称(数字文字下划线)';
                }
            },
            {
                type: 'rawlist',
                name: 'templName',
                message: '请选择模板?',
                choices: tempList
            }
        ];
        return inquirer.prompt(questions)
    }

    //copyTempl();
    /**
     * 复制模板文件
     */
    function copyTempl(filename, tempName) {
        let desPath = path.join(__dirname, '../template/'+tempName)
        copyDir(desPath, filename)
        console.log('执行完毕');
    }
    /**
     * 复制
     * @param src
     * @param dist
     * @param callback
     */
    function copyDir(src, dist, callback) {
        fs.access(dist, function (err) {
            if (err) {
                // 目录不存在时创建目录
                fs.mkdirSync(dist, {
                    recursive: true
                });
            }
            _copy(null, src, dist);
        });

        const _copy = (err, src, dist) => {
            if (err) {
                callback(err);
            } else {
                let dir = fs.readdirSync(src, 'utf-8');
                for (let j of dir) {
                    var _src = src + '/' + j;
                    var _dist = dist + '/' + j;
                    let stat = fs.statSync(_src);
                    if (stat.isDirectory()) {
                        copyDir(_src, _dist, callback);
                    } else {
                        fs.writeFileSync(_dist, fs.readFileSync(_src, {
                            encoding: 'utf-8'
                        }));
                    }
                }
            }
        }
    }
}

测试一下,没有问题。

11. 优化一下代码结构

将具体的执行方法放在utils里面,init中只调用执行函数,将支持的模板也放在配置文件中。

// ./utils.js

const fs = require('fs')
const path = require('path')
const inquirer = require('inquirer')
const tempList = require('./tempList.json');

module.exports = {
    createQuestion() {
        const questions = [{
                type: 'input',
                name: 'filename',
                message: '请输入要创建的文件名:',
                validate: function (value) {
                    var pass = value.match(/\w+/);
                    if (pass) {
                        return true;
                    }
                    return '请输入正确的文件名称(数字文字下划线)';
                }
            },
            {
                type: 'rawlist',
                name: 'templName',
                message: '请选择模板?',
                choices: tempList
            }
        ];
        return inquirer.prompt(questions)
    },

    /**
     * 复制模板文件
     */
    copyTempl(filename, tempName) {
        let desPath = path.join(__dirname, '../template/' + tempName)
        this.copyDir(desPath, filename)
        console.log('执行完毕');
    },
    /**
     * 复制
     * @param src
     * @param dist
     * @param callback
     */
    copyDir(src, dist, callback) {
        fs.access(dist, function (err) {
            if (err) {
                // 目录不存在时创建目录
                fs.mkdirSync(dist, {
                    recursive: true
                });
            }
            _copy(null, src, dist);
        });

        const _copy = (err, src, dist) => {
            if (err) {
                callback(err);
            } else {
                let dir = fs.readdirSync(src, 'utf-8');
                for (let j of dir) {
                    var _src = src + '/' + j;
                    var _dist = dist + '/' + j;
                    let stat = fs.statSync(_src);
                    if (stat.isDirectory()) {
                        this.copyDir(_src, _dist, callback);
                    } else {
                        fs.writeFileSync(_dist, fs.readFileSync(_src, {
                            encoding: 'utf-8'
                        }));
                    }
                }
            }
        }
    }
}
// init.js
const Utils = require('./utils');
module.exports = () => {
    Utils.createQuestion().then(res => {
        const {
            filename,
            templName
        } = res;
        Utils.copyTempl(filename, templName)
    })
}
// tempList.json
[{        "name": "react",        "value": "react-temp"    },    {        "name": "vue",        "value": "vue-temp"    }]

测试一下也没有问题。

12. 添加list指令,

用户可以查看所有支持的列表。在bin/peach.js中添加

创建lib/list.js文件

const list = require('./tempList.json')
module.exports = () => {
    console.log(
        `现支持模板列表:\n${list.map(item => item.value).join('\n')}`
    )
}

测试没有问题

13. 添加help指令

当用户输入--help或者什么都不熟的时候,提示用户使用方法。

// bin/peach.js
if (!program.args.length) {
    program.help()
}

测试一下, commander会自动根据你的配置,生成出使用说明。

14. 发布

现在基本框架完成了,我们上传npm试试。

1 切换到npm下,不能用淘宝镜像。

2 npm login登录

3 npm publish

上传的时候报错,说是版本问题,增加的版本还是报同样的错,去www.npmjs.com/发现是peach-cli这个包已经被人占用了,只能改名字为gbg-peach-cli再上传。

上传成功到官网去查一下,能查到,说明没有问题。

15. 安装测试

npm unlink当前项目,获取到本地npm下面删除相关的命令脚本和文件。全局安装cli

npm i gbg-peach-cli -g

这样一个最简单的模板下载工具就完成了。

三. 从git下载

上述完成的只能下载预设的模板,如果需要更改模板什么的,就需要重新发布,所以改为从Git clone项目。

1. 添加git地址

修改tempList.json,将原来的下载模板改为git地址。

[{
        "name": "react",
        "value": "git@github.com:guobaogang/wechat-yatzy-server.git"
    },
    {
        "name": "vue",
        "value": "git@github.com:guobaogang/vue-js-tmp.git"
    }
]

2. git clone方法

在utils.js中增加克隆git项目的方法,child_process为nodejs内置方法,不用安装。

// lib/utils.js
const exec = require('child_process').exec
/**
* 克隆git项目
*/
gitClone(filename, gitUrl, branch = 'master') {
  let cmdStr = `git clone ${gitUrl} ${filename} && cd ${filename} && git checkout ${branch}`;
  exec(cmdStr, (error, stdout, stderr) => {
    if (error) {
      console.log(error)
      process.exit();
    }
    console.log('克隆完成');
  })
},

init.js中修改调用方法

// lib/init.js

const Utils = require('./utils');
module.exports = () => {
    Utils.createQuestion().then(res => {
        const {
            filename,
            templName
        } = res;
        Utils.gitClone(filename, templName)
    })
}

测试一下,可以下载成功

D:\test> peach i
? 请输入要创建的文件名: peach-test2
? 请选择模板? react
克隆完成

四. 一些细节处理

1. 删除.git

克隆成功之后代码中有.git,这个是不需要的,得删除

安装rimraf插件,克隆成功后删除.git。

注意:虽然前面有cd到文件夹,但是当前运行环境并没有真的cd到该文件夹,所以路径并不是./.git

// lib/untis.js

const rimraf = require('rimraf')

gitClone(filename, gitUrl, branch = 'master') {
  let cmdStr = `git clone ${gitUrl} ${filename} &&
								cd ${filename} && git checkout ${branch}`;
  exec(cmdStr, (error, stdout, stderr) => {
    if (error) {
      console.log(error)
      process.exit();
    }
    rimraf.sync(`./${filename}/.git`) 
    //注意:虽然前面有cd到文件夹,但是当前运行环境并没有真的cd到该文件夹,所以路径并不是./.git
    console.log('克隆完成');
  })
}

2. 运行提示

由于网络或者Git仓库过大等原因,clone的时候时间会比较长,需要有一个正在运行的提示。

// lib/utils.js
const CLI = require('clui'),
    Spinner = CLI.Spinner;

gitClone(filename, gitUrl, branch = 'master') {
  let cmdStr = `git clone ${gitUrl} ${filename} && cd ${filename} && git checkout ${branch}`;
  let countdown = new Spinner('努力加载中,请稍后...  ', ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷']);
  countdown.start();
  exec(cmdStr, (error, stdout, stderr) => {
    if (error) {
      console.log(error)
      process.exit();
    }
    rimraf.sync(`./${filename}/.git`)
    console.log('克隆完成');
    countdown.stop()
  })
}

测试一下,效果可以。

3. 美化输出

提示文字有点难看,可以使用chalk和figlet插件优化一下

比如讲克隆完成的文字改为红色。

// lib/utils.js
const chalk = require('chalk');

console.log(chalk.red('\n克隆完成'));

比如在使用init命令的时候,输出一个peach-cli的大写文字

//lib/init.js
const Utils = require('./utils');
const chalk = require('chalk');
const figlet = require('figlet');

module.exports = () => {
    console.log(
        chalk.green(
            figlet.textSync('PEACH-CLI', { horizontalLayout: 'full' })
        )
    )

    Utils.createQuestion().then(res => {
        const {
            filename,
            templName
        } = res;
        Utils.gitClone(filename, templName)
    })
}

看一下效果

clui,chalk,figlet的用法还有很多,这块只是最简单的用法,大家可以尝试做出更多的效果。

4. 检查更新版本

有时候我们更新了版本,用户在使用的时候并不知道,为了保持版本最新状态,最好在使用的时候检查一下版本,如果有新版本,可以提示用户更新。

总的来说这个功能可能比较鸡肋,根据自己需求加吧。

这个功能需要用到很多插件。

  1. request: 获取工具信息
  2. co: 异步控制
  1. co-prompt: 等待用户输入
  2. semver: 版本检查和对比

好了,开始写代码,先写一个检查版本的方法。

//lib/checkVersion.js
 const request = require('request')
 const semver = require('semver')
 const co = require('co')
 const prompt = require('co-prompt')
 const chalk = require('chalk')
 const packageConfig = require('../package.json')
 
 module.exports = done => {
     request({
       //由于网络等原因,此处改为https://registry.npm.taobao.org/gbg-peach-cli
       //否则经常超时,查不到版本号
         url: 'https://registry.npmjs.org/gbg-peach-cli',
       //为了用户体验,这里时间不能太长
         timeout: 1000
     }, (err, res, body) => {
         if (!err && res.statusCode === 200) {
             const latestVersion = JSON.parse(body)['dist-tags'].latest
             const localVersion = packageConfig.version
             if (semver.lt(localVersion, latestVersion)) {
                 console.log()
                 console.log(chalk.yellow('  A newer version of peach-cli is available.'))
                 console.log()
                 console.log('  latest:    ' + chalk.green(latestVersion))
                 console.log('  installed: ' + chalk.red(localVersion))
                 console.log()
                 co(function* () {
                     let update = yield prompt('Do you want to update the package ? [Y/N]')
                     if (update.toLowerCase() === 'y' || update.toLowerCase() === 'yes') {
                         console.log('有新版本');
                     } else if (update.toLowerCase() === 'n' || update.toLowerCase() === 'no') {
                         done()
                     }
                 })
             } else {
                 done()
             }
         } else {
             done()
         }
     })
 }
 

在init中调用

// lib/init.js
const check = require('./checkVersion');

module.exports = () => {
    check(() => {
      ...
    })
}

测试一下

没有问题,下来再写一个自动更新的方法就可以了。

5. 自动更新

// lib/update.js
const exec = require('child_process').exec
const co = require('co')
const chalk = require('chalk')
const CLI = require('clui'),
    Spinner = CLI.Spinner;

module.exports = () => {
    return co(function* () {
        let cmdStr = `npm install gbg-peach-cli -g`;
        let countdown = new Spinner('更新中,请稍后...  ', ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷']);
        countdown.start();
        yield(function () {
            return new Promise(function (resolve, reject) {
                exec(cmdStr, (error, stdout, stderr) => {
                    if (error) {
                        console.log(error)
                        process.exit();
                    }
                    console.log(chalk.yellow('更新完成!'))
                    // 处理指定文件夹
                    countdown.stop();
                    resolve()
                })
            })
        })()
    })
}

在checkversion中调用

// lib/checkVersion.js
...
if (flag.toLowerCase() === 'y' || flag.toLowerCase() === 'yes') {
  update().then(function(){
    done()
  })
}
...

测试一下,没有问题

6. 代码地址

github.com/guobaogang/…