脚手架 | 从零搭建满足权限校验等需求的前端命令行工具
项目背景
开发一款内部使用的脚手架,对工程的管理以及后期框架底层版本的更新迭代,可以节省很多资源。基于此目的,以及命令行脚手架的成长过程需要把控、使用脚手架项目需跟进的情况下,我们提出脚手架一期需满足如下能力:
脚手架更新升级能力
执行开发命令时,自动校验脚手架是否需要升级,保证最新脚手架版本。
脚手架权限校验能力(内部使用)
脚手架仅限内部使用,用户需先获得权限,且项目需要获得证书。
初始化项目模板能力
开发环境/生产环境构建能力
下面从零开始介绍。
一、脚手架是什么
脚手架是为了保证各施工过程顺利而搭设的工作平台,这是百度百科上对脚手架的定义。
前端中,我们耳熟能详的脚手架有 create-react-app、vue-cli、yeoman 等这类通过命令行工具生成的,也有可以直接使用的模板工程,如:html5-boilerplate/react-boilerplate 等。从这些脚手架上看它提供的能力,个人理解为两点:
提供工程的基础架构代码模板能力。
建设稳健的工作流保证工程运作。
二、创建 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、创建可执行脚本文件
新建目录结构
// 创建项目
mkdir crxCli
cd crxCli
npm init // 初始化配置文件
// 新建可执行脚本文件
crxCli/
bin/
crx.js // 可执行脚本文件
配置 package.json 文件
{
...
"bin":{
"crx":"bin/crx.js"
}
...
}
项目与 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、登录及模板初始化流程图:
2、执行开发环境流程图:
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")
const program = require("commander")
const version = 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'
const program = require('commander')
// 命令行交互工具
const inquirer = require('inquirer')
const chalk = 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)
// 判断使用登录网络地址: 外网地址 || 内网
const outer = program.outer || false
inquirer.prompt([
{
type: 'input',
name: 'username',
message: '请输入用户名',
},
{
type: 'password',
name: 'password',
message: '请输入密码',
mask: '*',
},
]).then(answers => {
// 命令行交互获取的用户名/密码
const username = answers.username
const password = answers.password
// axios请求登录接口请求用户信息
login(username, password, outer).then(({user, token}) => {
let auth = 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')
const fs = require('fs-extra')
const confPath = "***"
const key = "****"
const iv = Buffer.from([0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f])
/**
* AES是一种常用的对称加密算法,加解密都用同一个密钥.
* 加密结果通常有两种表示方法:hex和base64
*/
// 加密
function aesEncrypt(data) {
const cipher = createCipher('aes192', key);
let crypted = cipher.update(data, 'utf8', 'hex');
crypted += cipher.final('hex');
return crypted;
}
// 解密
function aesDecrypt(encrypted) {
const decipher = createDecipher('aes192', key);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
// 保存授权信息文件
function saveAuthorized(data) {
fs.ensureDirSync(confPath)
fs.writeFileSync(join(confPath, '.authorized'),
aesEncrypt(JSON.stringify(data)))
}
// 删除授权文件
function deleteAuthorized() {
fs.removeSync(join(confPath, '.authorized'))
}
// 判断授权文件是否存在
function authorizedExists() {
return fs.existsSync(join(confPath, '.authorized'))
}
// 获取授权文件解析信息
function getAuthorized() {
let ret = fs.readFileSync(join(confPath, '.authorized'), 'utf8')
return JSON.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'
const chalk = require('chalk')
const { deleteAuthorized, authorizedExists } = require('../lib/access')
if (authorizedExists()) {
deleteAuthorized()
console.log(chalk.yellow('注销成功'))
} else {
console.log(chalk.red('用户未登录'))
}
6、初始化项目模板
初始化项目命令行:
crx init <projectName>
代码实现:
// bin/crx-init.js
#! /usr/bin/env node
const program = require('commander')
const chalk = require('chalk')
const path = require('path')
const fs = require('fs')
const inquirer = require('inquirer')
const rm = require('rimraf').sync
const config = require('../lib/config')
const generator = 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()
}
let projectName = program.args[0]
let projectPath = path.resolve(projectName)
if (projectName === '[object Object]') {
console.log(chalk.red('name required!'))
program.help()
process.exit(1)
}
const questions = 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)
}
function ask(_questions, cb) {
let choices = _questions[0].choices
if (authorizedExists()) {
// 获取授权项目信息
const auth = getAuthorized()
let templates = {}
// 获取授权项目中可供下载模板类型
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)
}
})
}
下载模板代码示例:
const ora = require('ora')
const execSync = require('execa').shellSync
const path = require('path')
const fs = require('fs-extra')
const chalk = require('chalk')
const downloadGitRepo = require('download-git-repo')
const { saveHistory } = require('./api')
const { authorizedExists, getAuthorized } = require('./access')
function prepare(projectName, destPath, answers, repo) {
generator(projectName, destPath, answers, repo)
}
function download(repo, destPath) {
// eslint-disable-next-line no-undef
return new Promise(function(resolve, reject) {
downloadGitRepo(repo, destPath, function(err) {
if (err) reject(err)
resolve(true)
})
})
}
function writePKG(pkgPath, data) {
let pkg = fs.readJsonSync(pkgPath)
pkg = assign({}, pkg, data)
fs.writeJsonSync(pkgPath, pkg)
}
function generator(projectName, destPath, answers, repo) {
// 创建目录
fs.mkdirsSync(destPath)
let _dest = 'template: ' + answers.template
let spinner = ora(` 下载模板代码: ${_dest} `)
spinner.start()
// 下载模板代码
return download(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) {
const auth = getAuthorized()
// 更新代码授权信息
saveHistory(projectName, auth.id, 'init').catch(()=> {})
}
var completeMsg = `成功创建项目: '${projectName}'`
console.log(chalk.yellow(completeMsg))
}).catch(function(err) {
console.log(chalk.red(`\n 无法下载 ${_dest}`), err)
process.exit(1)
})
}
7、开发环境流程
此处仅展示部分判断流程代码,关于 webpack 开发环境构建相关此处不介绍,后续将整理 webpack 相关文章。
执行命令行
crx dev
代码实现
检测是否需要升级脚手架相关代码:
let axios = require('axios')
let semver = require('semver')
let chalk = require('chalk')
let inquirer = require("inquirer")
let execSync = require('execa').shellSync
let platform = require('os').platform()
let pkgJSON = 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 版本`)
} else if (platform === 'win32') {
console.log(`推荐前往 ${chalk.cyan('https://nodejs.org/')} 下载 nodeJS 稳定版`)
}
process.exit(1)
}
const cliName = "发布npm的脚手架名称"
axios.get(`https://registry.npm.taobao.org/${cliName}`, {timeout: 8000}).then(function(ret) {
if (ret.status === 200) {
let latest = ret.data['dist-tags'].latest
let local = 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()
})
}
证书校验流程相关代码:
function checkAccess(cb, type, outer) {
let task = type === 'dev' ? '启动开发服务' : '打包项目'
let spinner = ora('校验项目...')
spinner.start()
// 判断项目是否受限
const isRestricted = checkRestricted()
if (isRestricted) {
const path = resolve('package.json')
if (fs.existsSync(path)) {
const projectName = fs.readJSONSync(path).name
// 用户是否登录获取授权
if (authorizedExists()) {
let auth = getAuthorized()
// 当前项目是否获取授权
let project = 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
若报错未登陆:npm ERR! code ENEEDAUTH ,则执行登录
npm adduser
若报错仓库地址不对:npm ERR! code ENEEDAUTH,则先执行切换仓库
nrm ls // 查看当前仓库地址
nrm use 仓库名 // 切换仓库
npm publish // 发布
六、总结
至此一款简单的具备权限校验的命令行工具脚手架便完成了。
--END--
作者:梁龙先森 WX:newBlob
原创作品,抄袭必究!
e
版权声明: 本文为 InfoQ 作者【梁龙先森】的原创文章。
原文链接:【http://xie.infoq.cn/article/acc758e45776ba7df2857d372】。文章转载请联系作者。
梁龙先森
脚踏V8引擎的无情写作机器 2018.03.17 加入
还未添加个人简介
评论