9

从零搭建满足权限校验等需求的前端命令行工具(脚手架)

 3 years ago
source link: https://xie.infoq.cn/article/acc758e45776ba7df2857d372
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

项目背景

开发一款内部使用的脚手架,对工程的管理以及后期框架底层版本的更新迭代,可以节省很多资源。基于此目的,以及命令行脚手架的成长过程需要把控、使用脚手架项目需跟进的情况下,我们提出脚手架一期需满足如下能力:

  1. 脚手架更新升级能力

执行开发命令时,自动校验脚手架是否需要升级,保证最新脚手架版本。

  1. 脚手架权限校验能力(内部使用)

脚手架仅限内部使用,用户需先获得权限,且项目需要获得证书。

  1. 初始化项目模板能力

  2. 开发环境/生产环境构建能力

下面从零开始介绍。

一、脚手架是什么

脚手架是为了保证各施工过程顺利而搭设的工作平台,这是百度百科上对脚手架的定义。

前端中,我们耳熟能详的脚手架有create-react-app、vue-cli、yeoman等这类通过命令行工具生成的,也有可以直接使用的模板工程,如:html5-boilerplate/react-boilerplate等。从这些脚手架上看它提供的能力,个人理解为两点:

  1. 提供工程的基础架构代码模板能力。

  2. 建设稳健的工作流保证工程运作。

二、创建npm包

1、npm包必备基本项

package.json  // 包的基本信息
READE.md      // 文档
index.js      // 入口文件

2、package.json配置文件详解

{
  "name": "crxcli",     // 包名
  "version": "1.0.0",   // 版本名
  "author": "",         // 作者
  "description": "",    // 描述
  "main": "index.js",   // 入口文件
  "scripts": {          // 声明npm脚本指令:npm run test
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "bin":{},             // 声明被放到PATH中的可执行文件
  "bugs":{},            // 项目提交issues的地址
  "dependencies": {},    // 生产环境依赖,npm install --save
  "devDependencies": {}, // 开发环境依赖,npm install --save-dev
  "engines": {},         // 指定项目工作环境
  "homepage": "",        // 项目官网地址
  "peerDependencies":{}, // 严格约束插件使用的版本
  "private":"",          // 配置私有库,为true,npm不会发布
  "publishConfig": "",   // 在publish-time使用的配置集合 
  "repository": "",      // 代码存放的地址(git)
  "license": "ISC"       // 许可证
  ...
}

3、创建可执行脚本文件

  1. 新建目录结构

// 创建项目
mkdir crxCli
cd crxCli
npm init// 初始化配置文件

// 新建可执行脚本文件
crxCli/
bin/
crx.js// 可执行脚本文件
  1. 配置package.json文件

{
...
  "bin":{
    "crx":"bin/crx.js"
  }
...
}
  1. 项目与npm模块建立连接

cd crxCli && npm link// 全局link
// 或者
cd crxCli && npm link 模块名(package.json中的name)

// 解除link
npm unlink 模块名

建立连接后,可以看到在/npm/node_modules路径下创建了一个软链接,此时便可以使用crx指令进行模块调试。

三、常见开发包

commander // 完整的 node.js 命令行解决方案,
semver    // npm版本解析器
chalk     // 添加终端字符串样式
minimist  // 参数选择解析器
dotenv    // 可将环境变量从.env文件加载到process.env中
dotenv-expand // 在dotenv顶部添加变量扩展
inquirer  // 用于与命令行交互的工具
rimraf    // 删除文件
download-git-repo // 下载git项目
fs-extra  // 文件操作

四、搭建命令行脚手架

现在我们基于项目背景,设计下整个业务流程,如图:

流程图设计

1、登录及模板初始化流程图:

bu2MruB.png!mobile

2、执行开发环境流程图:

73mQJrA.png!mobile

1、命令行设计

// 登录命令行
crx login
// 登出命令
crx loginout
// 初始化项目模板
crx init <project>
// 执行开发环境
crx dev
// 执行生产环境
crx build
// 查看用户信息
crx userinfo

2、目录结构设计

crxCli/
bin/
crx.js       // 命令行脚本
crx-init.js     // 项目初始化脚本
crx-login.js    // 登录脚本
crx-loginout.js // 登出脚本
crx-dev.js      // 开发脚本
crx-build.js    // 生产脚本
lib/ // 工具库
access.js // 权限相关
...
...
package.json // 配置文件
READEME.md

3、命令行脚本实现

// bin/crx.js

#! /usr/bin/env node
// 声明脚本执行的环境

const{ option } =require("commander")
constprogram =require("commander")
constversion =require("../package.json").version

program
// 版本信息
.version(version,'-v, --version')
// 帮助信息的首行提示
.usage('<command> [options]')
// 初始化项目的命令
.command('init [project]','generate project by template')
// 开发环境
.command('dev','start dev server')
// 生产环境
.command('build','build source')
// 登录命令
.command('login','user login')
// 登出命令
.command('logout','user logout')用户
// 查看当前用户信息
.command('userinfo','who am i')
.action(function(cmd){
if(['init','dev','build','login','logout',].indexOf(cmd) ===-1){
console.log('unsupported aid-cli command')
process.exit(1)
}
})

program.parse(process.argv)

4、登录流程

// bin/crx-login.js 文件

#! /usr/bin/env node
process.env.NODE_ENV ='production'

constprogram =require('commander')
// 命令行交互工具
constinquirer =require('inquirer')
constchalk =require('chalk')
const{ merge } =require('lodash')
const{ saveAuthorized } =require('../lib/access')
const{ login, fetchUserProjects } =require('../lib/api')

program.option('--outer','outer network',false)
program.parse(process.argv)
// 判断使用登录网络地址: 外网地址 || 内网
constouter = program.outer ||false

inquirer.prompt([
{
type:'input',
name:'username',
message:'请输入用户名',
},
{
type:'password',
name:'password',
message:'请输入密码',
mask:'*',
},
]).then(answers=>{
// 命令行交互获取的用户名/密码
constusername = answers.username
constpassword = answers.password
// axios请求登录接口请求用户信息
login(username, password, outer).then(({user, token}) =>{
letauth = merge({}, user, { token })
// 获取用户授权项目
fetchUserProjects(auth.username ==='admin', auth, outer).then(projects=>{
// 保存.authorized 用户授权信息
saveAuthorized(merge({}, auth, { projects }))
console.log(chalk.yellow('登录成功'))
})
}).catch(err=>{
console.log(chalk.red(err.response ? err.response.data : err))
})
})
// lib/access.js 文件

const{ resolve, join } =require('path')
constfs =require('fs-extra')

constconfPath ="***"
constkey ="****"
constiv = Buffer.from([0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08,0x09,0x0a,0x0b,0x0c,0x0d,0x0e,0x0f])

/**
* AES是一种常用的对称加密算法,加解密都用同一个密钥.
* 加密结果通常有两种表示方法:hex和base64
*/

// 加密
functionaesEncrypt(data){
constcipher = createCipher('aes192', key);
letcrypted = cipher.update(data,'utf8','hex');
crypted += cipher.final('hex');
returncrypted;
}
// 解密
functionaesDecrypt(encrypted){
constdecipher = createDecipher('aes192', key);
letdecrypted = decipher.update(encrypted,'hex','utf8');
decrypted += decipher.final('utf8');
returndecrypted;
}
// 保存授权信息文件
functionsaveAuthorized(data){
fs.ensureDirSync(confPath)
fs.writeFileSync(join(confPath,'.authorized'),
aesEncrypt(JSON.stringify(data)))
}
// 删除授权文件
functiondeleteAuthorized(){
fs.removeSync(join(confPath,'.authorized'))
}
// 判断授权文件是否存在
functionauthorizedExists(){
returnfs.existsSync(join(confPath,'.authorized'))
}
// 获取授权文件解析信息
functiongetAuthorized(){
letret = fs.readFileSync(join(confPath,'.authorized'),'utf8')
returnJSON.parse(aesDecrypt(ret))
}
const { resolve, join } = require('path')
const fs = require('fs-extra')

5、登出

// bin/crx-loginout.js

#! /usr/bin/env node

process.env.NODE_ENV ='production'

constchalk =require('chalk')
const{ deleteAuthorized, authorizedExists } =require('../lib/access')

if(authorizedExists()) {
deleteAuthorized()
console.log(chalk.yellow('注销成功'))
}else{
console.log(chalk.red('用户未登录'))
}

6、初始化项目模板

  1. 初始化项目命令行:

crx init <projectName>

  1. 代码实现:

// bin/crx-init.js

#! /usr/bin/env node

constprogram =require('commander')
constchalk =require('chalk')
constpath =require('path')
constfs =require('fs')
constinquirer =require('inquirer')
constrm =require('rimraf').sync

constconfig =require('../lib/config')
constgenerator =require('../lib/generator')
const{ authorizedExists, getAuthorized } =require('../lib/access')

program.usage('[project]')
program.option('-r, --repo [value]','choose specified repo')
program.option('--outer','outer network',false)

program.on('--help',function(){
console.log(`
Examples:
${chalk.cyan(' # create a new project by specified template')}
crx init Todo
`)
})

program.parse(process.argv)

// 命令行参数不存在的提示
if(!program.args.length) {
program.help()
}

letprojectName = program.args[0]
letprojectPath = path.resolve(projectName)

if(projectName ==='[object Object]') {
console.log(chalk.red('name required!'))
program.help()
process.exit(1)
}

constquestions = config.questions

// 判断当前目录是否存在同项目名
if(fs.existsSync(projectPath)) {
inquirer
.prompt([
{
type:'confirm',
name:'yes',
message:'current project directory is not empty,continue?'
}
])
.then(function(answer){
if(answer.yes) {
// 删除目录
rm(projectPath)
// 确定模板问题,下载模板代码
ask(questions, generator)
}
})
}else{
ask(questions, generator)
}

functionask(_questions, cb){
letchoices = _questions[0].choices
if(authorizedExists()) {
// 获取授权项目信息
constauth =getAuthorized()
lettemplates= {}
// 获取授权项目中可供下载模板类型
auth.projects.forEach((project) =>{
if(Array.isArray(project.templates)) {
project.templates.forEach((v) =>{
templates[v.name] = v.value
})
}
})
// 初始化命令行交互问题
_questions[0].choices = choices = choices
.map(v=>({
// 获取gitlab对应的模板代码下载地址
name: v.name,value: config.template(v.value, program.outer)
}))
....// 其他代码省略
}
_questions.push({
type:'confirm',
name:'yes',
message:'确定以上问题的答案吗?'
})

inquirer.prompt(_questions).then(function(answers){
if(answers.yes) {
// 根据应答下载模板代码
cb(projectName, projectPath, answers, answers.template)
}else{
ask(cb)
}
})
}

下载模板代码示例:

constora =require('ora')
constexecSync =require('execa').shellSync
constpath =require('path')
constfs =require('fs-extra')
constchalk =require('chalk')
constdownloadGitRepo =require('download-git-repo')
const{ saveHistory } =require('./api')
const{ authorizedExists, getAuthorized } =require('./access')

functionprepare(projectName, destPath, answers, repo){
generator(projectName, destPath, answers, repo)
}

functiondownload(repo, destPath){
// eslint-disable-next-line no-undef
returnnewPromise(function(resolve, reject){
downloadGitRepo(repo, destPath,function(err){
if(err) reject(err)
resolve(true)
})
})
}

functionwritePKG(pkgPath, data){
letpkg = fs.readJsonSync(pkgPath)
pkg = assign({}, pkg, data)
fs.writeJsonSync(pkgPath, pkg)
}

functiongenerator(projectName, destPath, answers, repo){
// 创建目录
fs.mkdirsSync(destPath)
let_dest ='template: '+ answers.template

letspinner = ora(` 下载模板代码:${_dest}`)
spinner.start()
// 下载模板代码
returndownload(repo, destPath).then(function(){
spinner.stop()
// 合并模板代码的package.json与命令行交互工具的初始化
writePKG(path.resolve(projectName,'package.json'), {
name: projectName,
version: answers.version,
description: answers.desc,
author: answers.author,
license: answers.license
})

try{
// 进入项目,安装项目依赖
execSync(`cd${destPath}&& npm install`, {stdio:'inherit'})
}catch(err) {
console.log(err)
process.exit(1)
}
if(authorizedExists) {
constauth =getAuthorized()
// 更新代码授权信息
saveHistory(projectName, auth.id,'init').catch(()=> {})
}
varcompleteMsg =`成功创建项目: '${projectName}'`
console.log(chalk.yellow(completeMsg))
}).catch(function(err){
console.log(chalk.red(`\n 无法下载${_dest}`), err)
process.exit(1)
})
}

7、开发环境流程

此处仅展示部分判断流程代码,关于webpack开发环境构建相关此处不介绍,后续将整理webpack相关文章。

  1. 执行命令行

crx dev

  1. 代码实现

检测是否需要升级脚手架相关代码:

letaxios =require('axios')
letsemver =require('semver')
letchalk =require('chalk')
letinquirer =require("inquirer")
letexecSync =require('execa').shellSync
letplatform =require('os').platform()
letpkgJSON =require('../package.json')

module.exports =function(done, forceUpdate){
if(!semver.satisfies(process.version, pkgJSON.engines.node)) {
console.log(chalk.red(
'您的 nodeJS 版本必须满足: >='+ pkgJSON.engines.node +'.x'
))
if(platform ==='darwin') {
console.log(`推荐使用${chalk.cyan('https://github.com/creationix/nvm')}升级和管理 nodeJS 版本`)
}elseif(platform ==='win32') {
console.log(`推荐前往${chalk.cyan('https://nodejs.org/')}下载 nodeJS 稳定版`)
}
process.exit(1)
}

constcliName ="发布npm的脚手架名称"
axios.get(`https://registry.npm.taobao.org/${cliName}`, {timeout:8000}).then(function(ret){
if(ret.status ===200) {
letlatest = ret.data['dist-tags'].latest
letlocal = pkgJSON.version
if(semver.lt(local, latest)) {
console.log(chalk.yellow('发现可升级的 aid-cli 新版本.'))
console.log('最新: '+ chalk.green(latest))
console.log('当前: '+ chalk.gray(local))
if(forceUpdate) {
try{
execSync(`npm i${cliName}-g`, {stdio:'inherit'})
done()
}catch(err) {
console.error(err)
process.exit(1)
}
}else{
inquirer.prompt([{
type:"confirm",
name:'yes',
message:"是否立刻升级?"
}]).then(function(answer){
if(answer.yes) {
try{
execSync(`npm i${cliName}-g`, {stdio:'inherit'})
done()
}catch(err) {
console.error(err)
process.exit(1)
}
}else{
done()
}
})
}
}else{
done()
}
}else{
done()
}
}).catch(function(){
console.log(chalk.yellow('脚手架更新检测超时'))
done()
})
}

证书校验流程相关代码:

functioncheckAccess(cb, type, outer){
lettask = type ==='dev'?'启动开发服务':'打包项目'
letspinner = ora('校验项目...')
spinner.start()
// 判断项目是否受限
constisRestricted = checkRestricted()
if(isRestricted) {
constpath = resolve('package.json')
if(fs.existsSync(path)) {
constprojectName = fs.readJSONSync(path).name
// 用户是否登录获取授权
if(authorizedExists()) {
letauth =getAuthorized()
// 当前项目是否获取授权
letproject=find(auth.projects, (v) =>v.name===projectName)
if(project) {
// 项目证书是否存在
if(certExists(projectName)) {
// 检查项目证书是否过期
checkCert(projectName, () => {
saveHistory(projectName, auth.id, type, outer)
spinner.succeed(chalk.yellow(`校验完成,开始${task}`))
cb()
}, spinner)
}else{
spinner.stop()
// spinner.fail(chalk.red('受限项目,开发者证书不存在'))
inquirer.prompt([
{
type:'confirm',
name:'yes',
message:'开发者证书不存在, 是否生成证书?'
}
])
.then(function(answer){
if(answer.yes) {
spinner.text = chalk.yellow('安装开发者证书...')
createCert(auth.id, project.id, projectName, os.homedir(), auth, outer).then(ret=>{
saveCert(projectName, ret.data)
saveHistory(projectName, auth.id, type, outer)
spinner.succeed(chalk.yellow(`开发者证书安装完成,开始${task}`))
cb()
}).catch(err=>{
console.log(chalk.red(err.response ? err.response.data : err))
process.exit(1)
})
}else{
console.log(chalk.yellow('bye'))
process.exit(1)
}
})
}
}else{
spinner.fail(chalk.red('用户不属于项目 '+ projectName))
process.exit(1)
}
}else{
spinner.fail(chalk.red('用户未登录'))
process.exit(1)
}
}else{
spinner.fail(chalk.red('当前项目的 package.json 文件不存在'))
process.exit(1)
}
}else{
// console.log(chalk.yellow('非受限项目,无须授权'))
spinner.succeed(chalk.yellow(`校验完成,开始${task}`))
cb()
}
}

五、脚手架的发布

以下仅演示个人账户下发布unscoped包的案例。

1、注册npm账户

注册地址

全名
邮箱
用户名 // 重要,发布scoped包时会用到
密码

2、全局安装nrm

npm i nrm -g

nrm是npm仓库管理的软件,用于npm仓库的快速切换,常见命令:

nrm   // 查看可用命令
nrm ls   // 查看已配置的所有仓库
nrm test  // 测试所有仓库的响应时间
nrm add <registry> <url>  // 新增仓库
nrm use <registry>    // 切换仓库

3、发布

npm publish

  1. 若报错未登陆:npm ERR! code ENEEDAUTH ,则执行登录

npm adduser

  1. 若报错仓库地址不对:npm ERR! code ENEEDAUTH,则先执行切换仓库

nrm ls // 查看当前仓库地址
nrm use 仓库名  // 切换仓库
npm publish  // 发布

六、总结

至此一款简单的具备权限校验的命令行工具脚手架便完成了。

e


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK