如何开发一个属于自己的命令行工具

平常经常使用一些npm cli工具,你是否好奇这些工具又是怎么开发出来的呢?接下来这篇文章,就会介绍如何利用Node.js开发一个属于你自己的命令行工具。

创建基础的文件目录

首先,我们需要先创建一个基本的项目结构:

mkdir git-repo-cli
cd git-repo-cli
npm init #初始化项目

接着我们创建所需的文件:

touch index.js
mkdir lib
cd lib
touch files.js
touch github_credentials.js
touch inquirer.js
touch create_a_repo.js

接着我们先来写一个简单的入口程序,在index.js文件中代码如下:

const chalk = require('chalk');
const clear = require('clear');
const figlet = require('figlet');
const commander = require('commander');

commander
    .command('init')
    .description('Hello world')
    .action(() => {
        clear();
        console.log(chalk.magenta(figlet.textSync('Git Repo Cli', {
            hosrizontalLayout: 'full'
        })));
    });

commander.parse(process.argv);

if (!commander.args.length) {
    commander.help();
}

上面的代码中引用了chalk,clear,figletcommander这几个npm库,,其中chalk负责命令行不同颜色文本的显示,便于用户使用。clear库负责清空命令行界面,figlet可以在命令行中以ASCII ART形式显示文本。最后commander库就是用来实现命令行接口最主要的库。

写完代码后,我们可以在命令行中输入如下代码启动:

node index.js init

功能模块

文件模块

把基本的架子搭起来后,我们就可以开始写功能模块的代码了。

lib/files.js文件中,主要要实现以下两个功能::

  • 获取当前文件目录名
  • 检查该路径是否已经是个git仓库
const fs = require('fs');
const path = require('path');

module.exports = {
    getCurrentDirectoryBase: () => path.basename(process.cwd()),

    directoryExists: (filePath) => {
        try {
            return fs.statSync(filePath).isDirectory();
        } catch (err) {
            return false;
        }
    },

    isGitRepository: () => {
        if (files.directoryExists('.git')) {
            console.log(chalk.red("Sorry! Can't create a new git repo because this directory has been existed"))
            process.exit();
        }
    }
};

询问模块

在用户在执行命令行工具的时候,需要收集一些变量信息,因此,可以我们需要利用inquirer这个npm库来实现“询问模块”。

const inquirer = require('inquirer');

module.exports = {
    askGithubCredentials: () => {
        const questions = [
            {
                name: "username",
                type: 'input',
                message: 'Enter your Github username or e-mail address:',
                validate: function(value) {
                    if (value.length) {
                        return true;
                    } else {
                        return 'Please enter your Github username:'
                    }
                }
            },
            {
                name: "password",
                type: 'password',
                message: 'Enter your password:',
                validate: function(value) {
                    if (value.length) {
                        return true;
                    } else {
                        return 'Please enter your Github username:'
                    }
                }
                
            }
        ];
        return inquirer.prompt(questions);
    }
}

github认证

为了实现和github的接口通信,需要获得token认证信息。因此,在lib/github_credentials.js文件中,我们获得token,并借助configstore库写入package.json文件中。

const Configstore = require('configstore');
const pkg = require('../package.json')
const octokit = require('@octokit/rest')();
const _ = require('lodash');

const inquirer = require("./inquirer");

const conf = new Configstore(pkg.name);

module.exports = {
    getInstance: () => {
        return octokit;
    },
    
    githubAuth: (token) => {
        octokit.authenticate({
            type: 'oauth',
            token: token
        });
    },
    
    getStoredGithubToken: () => {
        return conf.get('github_credentials.token');
    },
    
    setGithubCrendeitals: async () => {
        const credentials = await inquirer.askGithubCredentials();
        octokit.authenticate(
            _.extend({
                type: 'basic'
            }, credentials)
        )
    },
    
    registerNewToken: async () => {
        // 该方法可能会被弃用,可以手动在github设置页面设置新的token
        try {
            const response = await octokit.oauthAuthorizations.createAuthorization({
                scope: ['user', 'public_repo', 'repo', 'repo:status'],
                note: 'git-repo-cli: register new token'
            });
            const token = response.data.token;
            if (token) {
                conf.set('github_credentials.token', token);
                return token;
            } else {
                throw new Error('Missing Token', 'Can not retrive token')
            }
        } catch(error) {
            throw error;
        }
    }
}

其中@octokit/rest是node端与github通信主要的库。

接下来就可以写我们的接口了:

// index.js
const github = require('./lib/gitub_credentials');

commander.
    command('check-token')
    .description('Check user Github credentials')
    .action(async () => {
        let token = github.getStoredGithubToken();
        if (!token) {
            await github.setGithubCredentials();
            token = await github.registryNewToken();
        }
        console.log(token);
    });

最后,在命令行中输入如下命令:

node index.js check-token

它会先会在configstore的默认文件夹下~/.config寻找token, 如果没有发现的话,就会提示用户输入用户名和密码后新建一场新的token。

有了token后,就可以执行github的很多的操作,我们以新建仓库为例:

首先,先在inquirer.js中新建askRepositoryDetails用来获取相关的repo信息:

    askRepositoryDetails: () => {
        const args = require('minimist')(process.argv.slice(2));
        const questions = [
            {
                type: 'input',
                name: 'name',
                message: 'Please enter a name for your repository:',
                default: args._[1] || files.getCurrentDirectoryBase(),
                validate: function(value) {
                    if (value.length) {
                        return true;
                    } else {
                        return 'Please enter a unique name for the repository.'
                    }
                }
            },
            {
                type: 'input',
                name: 'description',
                default: args._[2] || null,
                message: 'Now enter description:'
            },
            {
                type: 'input',
                name: 'visiblity',
                message: 'Please choose repo type',
                choices: ['public', 'private'],
                default: 'public'
            }
        ];

        return inquirer.prompt(questions);
    },

    askIgnoreFiles: (filelist) => {
        const questions = [{
            type: 'checkbox',
            choices: filelist,
            message: 'Please choose ignore files'
        }];
        return inquirer.prompt(questions);
    }

接着,实现对应的新建仓库、新建.gitignore文件等操作:

// create_a_repo.js
const _ = require('lodash');
const fs = require('fs');
const git = require('simple-git')();

const inquirer = require('./inquirer');
const gh = require('./github_credentials');

module.exports = {
    createRemoteRepository: async () => {
        const github = gh.getInstance(); // 获取octokit实例
        const answers = await inquirer.askRepositoryDetails();
        const data = {
            name: answers.name,
            descriptions: answers.description,
            private: (answers.visibility === 'private')
        };

        try {
            // 利用octokit 来新建仓库
            const response = await github.repos.createForAuthenticatedUser(data);
            return response.data.ssh_url;
        } catch (error) {
            throw error;
        }
    },

    createGitIgnore: async () => {
        const filelist = _.without(fs.readdirSync('.'), '.git', '.gitignore');
        if (filelist.length) {
            const answers = await inquirer.askIgnoreFiles(filelist);
            if (answers.ignore.length) {
                fs.writeFileSync('.gitignore', answers.ignore.join('\n'));
            } else {
                touch('.gitnore');
            }
        } else {
            touch('.gitignore');
        }
    },

    setupRepo: async (url) => {
        try {
            await git.
                init()
                .add('.gitignore')
                .add('./*')
                .commit('Initial commit')
                .addRemote('origin', url)
                .push('origin', 'master')
            return true;
        } catch (err) {
            throw err;
        }
    }
}

最后,在index.js文件中新建一个create-repo的命令,执行整个流程。

// index.js
commander
    .command('create-repo')
    .description('create a new repo')
    .action(async () => {
        try {
            const token = await github.getStoredGithubToken();
            github.githubAuth(token);
            const url = await repo.createRemoteRepository();
            await repo.createGitIgnore();

            const complete = await repo.setupRepository(url);

            if (complete) {
                console.log(chalk.green('All done!'));
            }
        } catch (error) {
            if (error) {
                switch (error.status) {
                    case 401:
                        console.log('xxx');
                        break;
                }
            }
        }
    })

写完代码后,在命令行中执行如下命令即可:

node index.js create-repo

总结

总的来说,利用node.js来实现命令行工具还是比较简单的。目前有很多比较成熟的工具库,基本上常用的功能都能够实现。如果有需要自己造轮子,大家可以参考本文的实现思路。

参考资料

代码参考https://github.com/Tereflech17/musette

https://github.com/tj/commander.js/blob/HEAD/Readme_zh-CN.md

https://www.lynda.com/Node-js-tutorials/Building-Your-First-CLI-App-Node

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268