原文 手把手教你寫一個 Node.js CLI javascript
強大的 Node.js 除了能寫傳統的 Web 應用,其實還有更普遍的用途。微服務、REST API、各類工具……甚至還能開發物聯網和桌面應用。JavaScript 不愧是宇宙第一語言。php
Node.js 在開發命令行工具方面也是至關方便,經過這篇教程咱們能夠來感覺下。咱們先看看跟命令行有關的幾個第三方包,而後從零開始寫一個真實的命令行工具。css
這個 CLI 的用途就是初始化一個 Git 倉庫。固然,底層就是調用了 git init
,可是它的功能不止這麼簡單。它還能從命令行建立一個遠程的 Github 倉庫,容許用戶交互式地建立 .gitignore
文件,最後還能完成提交和推代碼。html
在動手以前,咱們有必要知道爲何選擇 Node.js 開發命令行工具。java
最明顯優點就是——相信你已經猜到了——它是用 JavaScript 寫的。node
另一個緣由是 Node.js 生態系統很是完善,各類用途的 package 應有盡有,其中就有很多是專門爲了開發命令行工具的。git
最後一個緣由是,用npm
管理依賴不用擔憂跨平臺問題,不像 Aptitude、Yum 或者 Homebrew 這些針對特定操做系統的包管理工具,使人頭疼。github
注:這麼說不必定準確,可能命令行只是須要其餘的外部依賴。shell
在這篇教程裏咱們來開發一個叫作 ginit 的命令行工具。咱們能夠把它當作高配版的git init
。什麼意思呢?咱們都知道,git init
命令會在當前目錄初始化一個 git 倉庫。可是,這僅僅是建立或關聯已有項目到 Git 倉庫的其中一步而已。典型的工做流程是這樣的:npm
git init
初始化本地倉庫.gitignore
文件可能還有更多步驟,爲了演示咱們只看關鍵部分。你會發現,這些步驟不少都是機械式、重複性的,爲何不用命令行來完成這些工做呢?好比複製粘貼 git 地址這種事情,能忍受手動操做?
ginit 能夠作到:在當前目錄建立 git 倉庫,同時建立遠程倉庫(咱們這裏用 Github 演示),而後提供一個相似操做嚮導的界面來建立 .gitignore
文件,最後提交文件夾內容並推送到遠程倉庫。可能這也節省不了太多時間,可是它確實給建立新項目帶來了些許便利。
好了,咱們開始吧。
有一點是確定的:說到外觀,控制檯不管如何也不會有圖形界面那麼複雜。儘管如此,也並非說控制檯必定是那種原始的純文本醜陋界面。你會驚訝地發現,原來命令行也能夠那麼好看!咱們會用到一些美化命令行界面的庫: chalk 給輸出內容着色, clui 提供一些可視化組件。還有更好玩的, figlet 能夠生成炫酷的 ASCII 字符圖案, clear 用來清除控制檯。
輸入輸出方面,低端的 Readline Node.js 模塊能夠詢問用戶並接受輸入,簡單場景下夠用了。但咱們會用到一個更高端的工具—— Inquirer。除了詢問用戶的功能,它還提供簡單的輸入控件:單選框和複選框,這但是在命令行控制檯啊,有點意外吧。
咱們還用到 minimist 來解析命令行參數。
如下是完整列表:
還有這些:
建立一個項目文件夾。
mkdir ginit
cd ginit
新建一個package.json
文件:
npm init
根據提示一路往下走:
name: (ginit) version: (1.0.0) description: "git init" on steroids entry point: (index.js) test command: git repository: keywords: Git CLI author: [YOUR NAME] license: (ISC)
安裝依賴:
npm install chalk clear clui figlet inquirer minimist configstore @octokit/rest lodash simple-git touch --save
最終生成的 package.json
文件大概是這樣的:
{
"name": "ginit", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [ "Git", "CLI" ], "author": "", "license": "ISC", "bin": { "ginit": "./index.js" }, "dependencies": { "@octokit/rest": "^14.0.5", "chalk": "^2.3.0", "clear": "0.0.1", "clui": "^0.3.6", "configstore": "^3.1.1", "figlet": "^1.2.0", "inquirer": "^5.0.1", "lodash": "^4.17.4", "minimist": "^1.2.0", "simple-git": "^1.89.0", "touch": "^3.1.0" } }
而後在這個文件夾裏新建一個index.js
文件,加上這段代碼:
const chalk = require('chalk'); const clear = require('clear'); const figlet = require('figlet');
接下來新建一個lib
文件夾,用來存放各類 helper 模塊:
先看 lib/files.js
,這裏須要完成:
.git
的目錄,判斷當前目錄是否已是 Git 倉庫)看上去很簡單直接,但這裏仍是有點坑的。
首先,你可能想用fs
模塊的 realpathSync 方法獲取當前路徑:
path.basename(path.dirname(fs.realpathSync(__filename)));
當咱們在同一路徑下運行應用時(即 node index.js
),這沒問題。可是要知道,咱們要把這個控制檯應用作成全局的,就是說咱們想要的是當前工做目錄的名稱,而不是應用的安裝路徑。所以,最好使用 process.cwd:
path.basename(process.cwd());
其次,檢查文件或目錄是否存在的推薦方法一直在變。目前的方法是用fs.stat
或fs.statSync
。這兩個方法在文件不存在的狀況下會拋異常,因此咱們要用try...catch
。
最後須要注意的是,當你在寫命令行應用的時候,使用這些方法的同步版本就能夠了。
整理下lib/files.js
代碼,一個工具包就出來了:
const fs = require('fs'); const path = require('path'); module.exports = { getCurrentDirectoryBase : () => { return path.basename(process.cwd()); }, directoryExists : (filePath) => { try { return fs.statSync(filePath).isDirectory(); } catch (err) { return false; } } };
回到index.js
文件,引入這個文件:
const files = require('./lib/files');
有了這個,咱們就能夠動手開發應用了。
如今讓咱們來實現控制檯應用的啓動部分。
爲了展現安裝的這些控制檯輸出強化模塊,咱們先清空屏幕,而後展現一個banner:
clear();
console.log( chalk.yellow( figlet.textSync('Ginit', { horizontalLayout: 'full' }) ) );
輸出效果以下圖:
接着運行簡單的檢查,確保當前目錄不是 Git 倉庫。很容易,咱們只要用剛纔建立的工具方法檢查是否存在 .git
文件夾就好了:
if (files.directoryExists('.git')) { console.log(chalk.red('Already a git repository!')); process.exit(); }
提示:咱們用了 chalk 模塊 來展現紅色的消息。
接下來咱們要作的就是寫個函數,提示用戶輸入 Github 登陸憑證。這個能夠用 Inquirer 來實現。這個模塊包含一些支持各類提示類型的方法,語法上跟 HTML 表單控件相似。爲了收集用戶的 Github 用戶名和密碼,咱們分別用了 input
和 password
類型。
首先新建 lib/inquirer.js
文件,加入如下代碼:
const inquirer = require('inquirer'); const files = require('./files'); 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 username or e-mail address.'; } } }, { name: 'password', type: 'password', message: 'Enter your password:', validate: function(value) { if (value.length) { return true; } else { return 'Please enter your password.'; } } } ]; return inquirer.prompt(questions); }, }
如你所見,inquirer.prompt()
向用戶詢問一系列問題,並以數組的形式做爲參數傳入。數組的每一個元素都是一個對象,分別定義了name
、 type
和message
屬性。
用戶提供的輸入信息返回一個 promise 給調用函數。若是成功,咱們會獲得一個對象,包含username
和password
屬性。
能夠在index.js
裏測試下:
const inquirer = require('./lib/inquirer'); const run = async () => { const credentials = await inquirer.askGithubCredentials(); console.log(credentials); } run();
運行 node index.js
:
下一步是建立一個函數,用來獲取 Github API 的OAuth token。咱們其實是用用戶名和密碼換取 token的。
固然了,咱們不能讓用戶每次使用這個工具的時候都須要輸入身份憑證,而是把 OAuth token 存起來給後續請求使用。這個是就要用到 configstore 這個包了。
保存配置信息表面上看起來很是簡單直接:無需第三方庫,直接存取 JSON 文件就行了。可是,configstore 這個包還有幾個關鍵的優點:
用法也很簡單,建立一個實例,傳入應用標識符就好了。例如:
const Configstore = require('configstore'); const conf = new Configstore('ginit');
若是 configstore
文件不存在,它會返回一個空對象並在後臺建立該文件。若是 configstore
文件已經存在,內容會被解析成 JSON,應用程序就可使用它了。 你能夠把 conf
當成簡單的對象,根據須要獲取或設置屬性。剛纔已經說了,你不用擔憂保存的問題,它已經幫你作好了。
提示:macOS/Linux 系統該文件位於 /Users/[YOUR-USERNME]/.config/configstore/ginit.json
讓咱們寫一個庫,處理 GitHub token。新建文件 lib/github.js
並添加如下代碼:
const octokit = require('@octokit/rest')(); const Configstore = require('configstore'); const pkg = require('../package.json'); const _ = require('lodash'); const CLI = require('clui'); const Spinner = CLI.Spinner; const chalk = require('chalk'); const inquirer = require('./inquirer'); const conf = new Configstore(pkg.name);
再添加一個函數,檢查訪問 token 是否已經存在。咱們還添加了一個函數,以便其餘庫能夠訪問到 octokit
(GitHub) 相關函數:
...
module.exports = {
getInstance: () => { return octokit; }, getStoredGithubToken : () => { return conf.get('github.token'); }, setGithubCredentials : async () => { ... }, registerNewToken : async () => { ... } }
若是 conf
對象存在而且有 github.token
屬性,就表示 token 已經存在。在這裏咱們把 token 值返回給調用的函數。咱們稍後會講到它。
若是 token 沒找到,咱們須要獲取它。固然了,獲取 OAuth token 牽涉到網絡請求,對用戶來講有短暫的等待過程。借這個機會咱們能夠看看 clui 這個包,它給控制檯應用提供了強化功能,轉菊花就是其中一個。
建立一個菊花很簡單:
const status = new Spinner('Authenticating you, please wait...'); status.start();
任務完成後就能夠停掉它,它就從屏幕上消失了:
status.stop();
提示:你也能夠用update
方法動態更新文字內容。當你須要展現進度時這會很是有用,好比顯示完成的百分比。
完成 GitHub 認證的代碼在這:
...
setGithubCredentials : async () => { const credentials = await inquirer.askGithubCredentials(); octokit.authenticate( _.extend( { type: 'basic', }, credentials ) ); }, registerNewToken : async () => { const status = new Spinner('Authenticating you, please wait...'); status.start(); try { const response = await octokit.authorization.create({ scopes: ['user', 'public_repo', 'repo', 'repo:status'], note: 'ginits, the command-line tool for initalizing Git repos' }); const token = response.data.token; if(token) { conf.set('github.token', token); return token; } else { throw new Error("Missing Token","GitHub token was not found in the response"); } } catch (err) { throw err; } finally { status.stop(); } },
咱們一步一步來看:
setGithubCredentials
方法提示用戶輸入憑證configstore
你建立的任何 token,不管是經過人工仍是 API,均可以在 這裏看到。在開發過程當中,你可能須要刪除 ginit 的 access token ——能夠經過上面的 note
參數辨認—— 以便從新生成。
提示:若是你的 Github 帳戶啓用了雙重認證,這個過程會稍微複雜點。你須要請求驗證碼(好比經過手機短信),而後經過 X-GitHub-OTP
請求頭提供該驗證碼。更多信息請參閱 Github 開發文檔
更新下index.js
文件裏的run()
函數,看看效果:
const run = async () => { let token = github.getStoredGithubToken(); if(!token) { await github.setGithubCredentials(); token = await github.registerNewToken(); } console.log(token); }
請注意,若是某個地方出錯的話,你會獲得一個 Promise
錯誤,好比輸入的密碼不對。稍後咱們會講處處理這些錯誤的方式。
一旦得到了 OAuth token,咱們就能夠用它來建立遠程 Github 倉庫了。
一樣,咱們能夠用 Inquirer
給用戶提問。咱們須要倉庫名稱、可選的描述信息以及倉庫是公開仍是私有。
咱們用 minimist 從可選的命令行參數中提取名稱和描述的默認值。例如:
ginit my-repo "just a test repository"
這樣就設置了默認名稱爲my-repo
,默認描述爲just a test repository
下面這行代碼把參數放在一個數組裏:
const argv = require('minimist')(process.argv.slice(2)); // { _: [ 'my-repo', 'just a test repository' ] }
提示:這裏只展現了 minimist 功能的一點皮毛而已。你還能夠用它來解析標誌位參數、開關和鍵值對。更多功能請查看它的文檔。
接下來咱們加上解析命令行參數的代碼,並向用戶提出一系列問題。首先更新lib/inquirer.js
文件,在askGithubCredentials
函數後面加上如下代碼:
...
askRepoDetails: () => {
const argv = require('minimist')(process.argv.slice(2)); const questions = [ { type: 'input', name: 'name', message: 'Enter a name for the repository:', default: argv._[0] || files.getCurrentDirectoryBase(), validate: function( value ) { if (value.length) { return true; } else { return 'Please enter a name for the repository.'; } } }, { type: 'input', name: 'description', default: argv._[1] || null, message: 'Optionally enter a description of the repository:' }, { type: 'list', name: 'visibility', message: 'Public or private:', choices: [ 'public', 'private' ], default: 'public' } ]; return inquirer.prompt(questions); },
接着建立lib/repo.js
文件,加上這些代碼:
const _ = require('lodash'); const fs = require('fs'); const git = require('simple-git')(); const CLI = require('clui') const Spinner = CLI.Spinner; const inquirer = require('./inquirer'); const gh = require('./github'); module.exports = { createRemoteRepo: async () => { const github = gh.getInstance(); const answers = await inquirer.askRepoDetails(); const data = { name : answers.name, description : answers.description, private : (answers.visibility === 'private') }; const status = new Spinner('Creating remote repository...'); status.start(); try { const response = await github.repos.create(data); return response.data.ssh_url; } catch(err) { throw err; } finally { status.stop(); } }, }
根據獲取的信息,咱們就能夠利用 Github 包 建立倉庫了,它會返回新建立的倉庫 URL。而後咱們就能夠把這個地址設置爲本地倉庫的 remote。不過仍是先新建一個.gitignore
文件吧。
下一步咱們將要建立一個簡單的「嚮導」命令行,用來生成 .gitignore
文件。若是用戶在已有項目路徑裏運行咱們的應用程序,咱們給用戶列出當前工做目錄的文件和目錄, 以讓他們選擇忽略哪些。
Inquirer 提供的 checkbox
輸入類型就是用來作這個的。
首先咱們須要作的就是掃描當前目錄,忽略.git
文件夾和任何現有的 .gitignore
文件。咱們用 lodash 的 without 方法來作:
const filelist = _.without(fs.readdirSync('.'), '.git', '.gitignore');
若是沒有符合條件的結果,就不必繼續執行了,直接 touch
當前的.gitignore
文件並退出函數。
if (filelist.length) { ... } else { touch('.gitignore'); }
最後,咱們用 Inquirer’s 的 checkbox 列出全部文件。在 lib/inquirer.js
加上以下代碼:
...
askIgnoreFiles: (filelist) => { const questions = [ { type: 'checkbox', name: 'ignore', message: 'Select the files and/or folders you wish to ignore:', choices: filelist, default: ['node_modules', 'bower_components'] } ]; return inquirer.prompt(questions); }, ..
請注意,咱們也能夠提供默認忽略列表。在這裏咱們預先選擇了 node_modules
和 bower_components
目錄,若是存在的話。
有了 Inquirer 的代碼,如今咱們能夠寫 createGitignore()
函數了。在 lib/repo.js
文件裏插入這些代碼:
...
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( '.gitignore' ); } } else { touch('.gitignore'); } }, ...
一旦用戶確認,咱們把選中的文件列表用換行符拼接起來,寫入 .gitignore
文件。 有了.gitignore
文件,能夠初始化 Git 倉庫了。
操做 Git 的方法有不少,最簡單的可能就是使用 simple-git 了。它提供一系列的鏈式方法運行 Git 命令。
咱們用它來自動化的重複性任務有這些:
git init
.gitignore
文件在 lib/repo.js
中插入如下代碼:
...
setupRepo: async (url) => { const status = new Spinner('Initializing local repository and pushing to remote...'); status.start(); try { await git .init() .add('.gitignore') .add('./*') .commit('Initial commit') .addRemote('origin', url) .push('origin', 'master'); return true; } catch(err) { throw err; } finally { status.stop(); } }, ...
首先在 lib/github.js
中寫幾個 helper 函數。一個用來方便地存取 token,一個用來創建 oauth
認證:
...
githubAuth : (token) => { octokit.authenticate({ type : 'oauth', token : token }); }, getStoredGithubToken : () => { return conf.get('github.token'); }, ...
接着在 index.js
裏寫個函數用來處理獲取 token 的邏輯。在run()
函數前加入這些代碼:
const getGithubToken = async () => { //從 config store 獲取 token let token = github.getStoredGithubToken(); if(token) { return token; } // 沒找到 token ,使用憑證訪問 GitHub 帳號 await github.setGithubCredentials(); // 註冊新 token token = await github.registerNewToken(); return token; }
最後,更新run()
函數,加上應用程序主要邏輯處理代碼。
const run = async () => { try { // 獲取並設置認證 Token const token = await getGithubToken(); github.githubAuth(token); // 建立遠程倉庫 const url = await repo.createRemoteRepo(); // 建立 .gitignore 文件 await repo.createGitignore(); // 創建本地倉庫並推送到遠端 const done = await repo.setupRepo(url); if(done) { console.log(chalk.green('All done!')); } } catch(err) { if (err) { switch (err.code) { case 401: console.log(chalk.red('Couldn\'t log you in. Please provide correct credentials/token.')); break; case 422: console.log(chalk.red('There already exists a remote repository with the same name')); break; default: console.log(err); } } } }
如你所見,在順序調用其餘函數(createRemoteRepo()
, createGitignore()
, setupRepo()
)以前,咱們要確保用戶是經過認證的。代碼還處理了異常,並給予了用戶適當的反饋。
剩下的一件事是讓咱們的命令行在全局可用。爲此,咱們須要在index.js
文件頂部加上一行叫 shebang 的代碼:
#!/usr/bin/env node
接着在package.json
文件中新增一個 bin
屬性。它用來綁定命令名稱(ginit
)和對應被執行的文件(路徑相對於 package.json
)。
"bin": { "ginit": "./index.js" }
而後,在全局安裝這個模塊,這樣一個可用的 shell 命令就生成了。
npm install -g
提示:Windows下也是有效的, 由於 npm 會幫你的腳本安裝一個 cmd 外殼程序
咱們已經作出了一個漂亮卻很簡單的命令行應用程序用來初始化 Git 倉庫。可是你還能夠作不少事來進一步增強它。
若是你是 Bitbucket 用戶,你能夠適配該程序去使用 Bitbucket API 來建立倉庫。有個 [Node.js API (https://www.npmjs.com/package/bitbucket-api) 能夠幫你起步。你可能但願增長几個命令行選項,或者讓用戶選擇使用 Github 仍是 Bitbucket(用 Inquirer 再適合不過了),或者直接把 Github 相關的代碼替換成 Bitbucket 對應的代碼。
你還能夠指定.gitgnore
文件默認列表,這方面 preferences
包比較合適,或者能夠提供一些模板—— 多是讓用戶選擇項目類型。還能夠把它集成到 .gitignore.io 。
除此以外,你還能夠添加額外的驗證、提供跳過某些步驟的功能等等。發揮你的想象力,若是還有其餘想法,歡迎留言評論!