脚手架核心流程开发
绘制 imooc-cli 脚手架架构设计图
实现 imooc-cli 脚手架准备过程代码
准备阶段分为以下步骤
- 检测脚手架版本号
- 检测 node 版本号
- 检查 root 账户和降级权限
- 检查用户主目录
- 入参检查及debug模式开发
- 检查环境变量
- 检查新版本及提示更新
检测脚手架版本号
const log = require('@zjw-cli/log');
const pkg = require('../package.json');
function core() {
checkVersion();
}
function checkVersion() {
log.notice('cli', pkg.version);
}
module.exports = core;
检测 node 版本号
node 版本号可以通过 process.version 获取
const semver = require('semver');
const colors = require('colors/safe');
const constant = require('./const');
function core() {
try {
...
checkNodeVersion();
} catch (error) {
log.error(error.message)
}
}
function checkNodeVersion() {
const currentVersion = process.version;
const lowestVersion = constant.LOWEST_NODE_VERSION;
if (!semver.gte(currentVersion, lowestVersion)) {
throw new Error(colors.red(`当前 node 版本:${currentVersion},最低 node 版本:${lowestVersion},请升级 node`))
}
}
检查 root 账户和降级权限
当使用 root 账号创建的文件,别的账号是无法删除修改的,日常开发特别注意这一点。
使用 process.geteuid() 来获取账号权限
- 普通账号(macOS):501
- root账号:0(通过 sudo 来执行)
使用 root-check 来自动完成账号降级
function checkRoot() {
const rootCheck = require('root-check');
rootCheck()
console.log(process.geteuid()) // 501
}
// 经过 root-check 处理后,在使用 sudo 执行,结果就是 501
// 原理:通过 process.setuid 、 process.setgid 来动态修改了用户及其分组的权限
检查用户主目录
function checkUserHome() {
if (!userHome || !pathExists(userHome)) {
throw new Error('当前登录用户主目录不存在')
}
}
入参检查及debug模式开发
function core() {
try {
...
checkInputArgs();
log.verbose('debug', 'test debug modal');
} catch (error) {
log.error(error.message)
}
}
/**
* @description 检查入参
*/
function checkInputArgs() {
const args = require('minimist')(process.argv.slice(2));
checkArgs(args)
}
/**
* @description 更加入参动态修改 log level,开启 debug 模式
* @param {Object} args
*/
function checkArgs(args) {
log.level = args.debug ? 'verbose' : 'info';
}
检查环境变量
可以在环境变量中存储用户信息,配置信息等数据。
- 通过 dotenv 这个库实现,dotenv默认获取的是当前项目根目录中的 .env 文件。可以通过 path 属性来修改。
- .env 文件中存储的数据格式是 key=value 这种格式的,获取到 .env 文件后会将里面的数据挂载到 process.env 上。
function checkEnv() {
const dotenvPath = path.resolve(userHome, '.env')
// 读取 .env 文件中的数据,并配置到环境变量中
require('dotenv').config({ path: dotenvPath });
createDefaultConfig();
log.verbose('环境变量缓存文件路径', process.env.CLI_HOME_PATH)
}
/**
* @description 创建环境变量缓存文件路径
*/
function createDefaultConfig() {
const cliConfig = {};
cliConfig.cliHomePath = process.env.CLI_HOME
? path.join(userHome, process.env.CLI_HOME)
: path.join(userHome, constant.DEFAULT_CLI_HOME);
process.env.CLI_HOME_PATH = cliConfig.cliHomePath;
}
检查新版本
- 获取npm 上所有的版本号
- 比较当前版本和 npm 上最新的版本
- 存在新版本提示更新
// core/cli/lib/index.js
async function checkGlobalUpdate() {
const { getNpmSemverVersion } = require('@zjw-cli/get-npm-info');
const lastVersion = await getNpmSemverVersion(pkg.version, pkg.name);
if (lastVersion && semver.gt(lastVersion, pkg.version)) {
throw new Error(colors.red(`请升级版本,最新版本:${lastVersion},升级命令:npm i ${pkg.name} -g`));
}
}
// utils/get-npm-info/lib/index.js
'use strict';
const axios = require('axios');
const urlJoin = require('url-join');
const semver = require('semver');
// 优先调用淘宝源
function getRegistry(isOriginal = false) {
return isOriginal ? 'https://registry.npmjs.org/' : 'https://registry.npm.taobao.org/';
}
function getNpmInfo(npmName, registry) {
if (!npmName) return null;
const targetRegistry = registry || getRegistry();
const url = urlJoin(targetRegistry, npmName);
return axios.get(url).then(res => {
if (res.status === 200) {
return res.data;
} else {
return {}
}
}).catch(err => Promise.reject(err))
}
async function getVersions(npmName, registry) {
const data = await getNpmInfo(npmName, registry);
return data.versions ? Object.keys(data.versions) : []
}
function getSemverVersions(baseVersion, versions) {
return versions
.filter(version => semver.satisfies(version, `>${baseVersion}`))
.sort((a, b) => semver.gt(b, a) ? 1 : -1);
}
async function getNpmSemverVersion(baseVersion, npmName, registry) {
const versions = await getVersions(npmName, registry);
const newVersions = getSemverVersions(baseVersion, versions);
return newVersions[0];
}
module.exports = {
getNpmInfo,
getVersions,
getSemverVersions,
getNpmSemverVersion
};
通过 commander 框架实现一个脚手架,包含自定义 option 和 command 功能
#!/usr/bin/env node
const commander = require('commander');
const program = new commander.Command();
const pkg = require('../package.json');
// 配置全局属性
program
// 在 Commander 7 以前,选项的值是作为属性存储在 command 对象上的。
// 这种处理方式便于实现,但缺点在于,选项可能会与Command的已有属性相冲突。
// 通过使用.storeOptionsAsProperties(),可以恢复到这种旧的处理方式,并可以不加改动的继续运行遗留代码。
// Commander 7之后所有的属性都要通过 opts() 获取
.storeOptionsAsProperties()
// .name 和 .usage 用来修改帮助信息的首行提示。name 属性也可以从参数自动推导出来。
.name(Object.keys(pkg.bin)[0])
.usage('<command> [option]')
.version(pkg.version)
// 配置 option 并设置默认值
.option('-d, --debug', '开启调试模式', false)
// 配置 option,指定参数
.option('-e, --envName <envName>', '输出环境变量名称');
// command 注册命令,注意:调用 command 方法返回的时命令对象而不是脚手架对象
const clone = program.command('clone <source> [destination]');
clone
.description('clone a repository')
.option('-f, --force', '是否强制克隆')
.action((source, destination, cmdObj) => {
console.log('clone command called', source, destination, cmdObj.force);
});
// addCommand 注册子命令
const service = new commander.Command('service');
service
.command('start [port]')
.description('service start at same port')
.action((port) => {
console.log('service starting at ', port);
});
service
.command('stop')
.description('stop service')
.action(() => {
console.log('service stop');
});
program.addCommand(service);
/**
* @description 独立执行的命令
* 当.command()带有描述参数时,就意味着使用独立的可执行文件作为子命令。
* Commander 将会执行一个新的命令。新的命令为: 脚手架命令-注册的命令,如下例中未配置 executableFile 前执行的是 'zjw-cli-test-install'。
* 通过配置选项 executableFile 可以自定义名字。
* 这种方式可以实现脚手架之间的嵌套调用
*/
program
.command('install [name]', 'install package', {
executableFile: 'zjw-cli', // 由原先的 zjw-cli-test-install 改为执行 zjw-cli
// isDefault: true, // 默认执行该命令,即执行 zjw-cli-test 就是执行 zjw-cli-test install
hidden: true // 在帮助文档中隐藏该命令
})
.alias('i')
// arguments 可以为最顶层命令指定命令参数。如下例:表示配置一个必选命令 username 和一个可选命令 password。
// arguments 指定的命令参数是泛指,只要不是 command 和 addCommand 注册的命令都会被捕获到。
// zjw-cli-test abc => username 就是 abc
// 可以向.description()方法传递第二个参数,从而在帮助中展示命令参数的信息。该参数是一个包含了 “命令参数名称:命令参数描述” 键值对的对象。
program
.arguments('<username> [password]')
.description('test command', {
username: 'user to login',
password: 'password for user, if required'
})
.action((username, password) => {
console.log(username, password);
});
/**
* @description 自定义 help 信息
* 方法一: 步骤一:重写 helpInformation,返回值是啥 help 信息就是啥;。
* 方法二: 步骤一:重写 helpInformation,返回值空字符串; 步骤二: 调用 on 方法监听 --help,在回调函数中输出 help 信息。
* 方法三: 步骤一:重写 helpInformation,返回值空字符串; 步骤二: 调用 addHelpText 方法
*
* 如果你不想清除内建的帮助信息,
* 方法一:不重新 helpInformation,只监听 --help;
* 方法二: 调用 addHelpText 方法(建议这种)
*/
program.helpInformation = function() {
return 'output your help information\n';
}
/**
* @description 实现 debug 模式
* 方法一: 调用 on 方法监听 --debug
* 方法二: 调用 on 方法监听 option:debug
*/
program.on('option:debug', () => {
console.log('debug: ', program.debug)
process.env.LOG_LEVEL = 'verbose'
})
/**
* @description 监听未知的命令
*/
program.on('command:*', (obj) => {
console.log(obj);
console.error('未知的命令:', obj[0]);
// 获取所有已注册的命令
const availableCommands = program.commands.map(cmd => cmd.name());
console.log('可用命令:', availableCommands.join(', '))
})
// 解析参数
program.parse(process.argv);
实现 node 对 ES Module 的支持
- 通过 webpack 来实现对 ES Module 支持,并且通过配置 loader,使用 babel 对 js 进行编译,来兼容低版本的 node。
- 将js文件后缀名为 .mjs,导入 .mjs。node 版本小于 14 需要加上 --experimental-modules 来对 mjs 进行支持。