@feflow/cli
Version:
A front-end flow tool.
502 lines (444 loc) • 15.2 kB
text/typescript
/* eslint-disable no-loop-func */
import fs from 'fs';
import path from 'path';
import osEnv from 'osenv';
import _ from 'lodash';
import Feflow from '..';
import { execPlugin } from '../plugin/load-universal-plugin';
import logger from '../logger';
import { parseYaml, safeDump } from '../../shared/yaml';
import { UNIVERSAL_MODULES, CACHE_FILE, FEFLOW_ROOT } from '../../shared/constant';
import { getPluginsList } from '../plugin/load-plugins';
const internalPlugins = {
devtool: '@feflow/feflow-plugin-devtool',
};
export enum CommandType {
NATIVE_TYPE = 'native',
PLUGIN_TYPE = 'plugin',
INTERNAL_PLUGIN_TYPE = 'devtool',
UNIVERSAL_PLUGIN_TYPE = 'universal',
UNKNOWN_TYPE = 'unknown',
}
export const LOAD_PLUGIN = 1 << 0;
export const LOAD_DEVKIT = 1 << 1;
export const LOAD_UNIVERSAL_PLUGIN = 1 << 2;
export const LOAD_ALL = LOAD_PLUGIN | LOAD_DEVKIT | LOAD_UNIVERSAL_PLUGIN;
interface PluginItem {
commands: Array<{
name: string;
path?: string;
version?: string;
}>;
path?: string;
type: CommandType;
}
interface PluginInfo {
[key: string]: PluginItem;
}
interface PickMap {
[CommandType.PLUGIN_TYPE]?: PluginInfo;
[CommandType.UNIVERSAL_PLUGIN_TYPE]?: PluginInfo;
[CommandType.NATIVE_TYPE]: PluginInfo;
[CommandType.INTERNAL_PLUGIN_TYPE]: PluginInfo;
}
interface Cache {
commandPickerMap?: PickMap;
version: string;
}
interface CmdMap {
commands: Array<{
[key: string]: { name: string; version: string };
}>;
}
class TargetPlugin {
path: string;
type: CommandType;
pkg?: string;
constructor(type: CommandType, path: string, pkg: string) {
this.type = type;
this.path = path;
this.pkg = pkg;
}
}
class NativePlugin extends TargetPlugin {}
class TargetUniversalPlugin {
type: CommandType;
version: string;
pkg: string;
constructor(type: CommandType, version: string, pkg: string) {
this.type = type;
this.version = version;
this.pkg = pkg;
}
}
export class CacheController {
ctx: Feflow;
cache?: Cache;
lastCommand = '';
lastVersion = '';
lastStore: Record<string, { pluginName: string }> = {};
subCommandMap: { [key: string]: string[] } = {};
subCommandMapWithVersion: CmdMap = { commands: [] };
root: string;
cacheFilePath: string;
cacheVersion = '1.0.0';
pickOrder = [
CommandType.PLUGIN_TYPE,
CommandType.UNIVERSAL_PLUGIN_TYPE,
CommandType.NATIVE_TYPE,
CommandType.INTERNAL_PLUGIN_TYPE,
];
constructor(ctx: Feflow) {
this.ctx = ctx;
this.root = path.join(osEnv.home(), FEFLOW_ROOT);
this.cacheFilePath = path.join(this.root, CACHE_FILE);
this.cache = this.getCache();
if (this.cache) {
const isCacheExpired = this.cache.version !== this.cacheVersion;
if (isCacheExpired) {
this.ctx.logger.debug('fef cache is expired, clear invalid cache.');
this.cache = {
version: this.cacheVersion,
};
this.writeCache();
}
} else {
this.ctx.logger.debug('fef cache is empty.');
}
}
registerSubCommand(
type: CommandType,
store: Record<string, any>,
pluginName: string = CommandType.NATIVE_TYPE,
version = 'latest',
) {
const newCommands = _.difference(Object.keys(store), Object.keys(this.lastStore));
if (!!this.lastCommand) {
if (type === CommandType.PLUGIN_TYPE) {
// 命令相同的场景,插件提供方变化后,依然可以探测到是新命令
const commonCommands = Object.keys(store).filter(item => !newCommands.includes(item));
for (const common of commonCommands) {
if (!this.lastStore[common]) continue;
if (store[common].pluginName !== this.lastStore[common].pluginName) {
newCommands.push(common);
}
}
this.subCommandMap[this.lastCommand] = newCommands;
} else {
if (!this.subCommandMapWithVersion[this.lastCommand]) {
this.subCommandMapWithVersion[this.lastCommand] = {
commands: [
{
name: newCommands[0],
version: this.lastVersion,
},
],
};
} else {
this.subCommandMapWithVersion[this.lastCommand].commands.push({
name: newCommands[0],
version: this.lastVersion,
});
}
}
}
this.lastVersion = version;
this.lastCommand = pluginName;
this.lastStore = Object.assign({}, store);
}
initCacheFile() {
this.cache = {
commandPickerMap: this.getAllCommandPickerMap() as PickMap,
version: this.cacheVersion,
};
this.writeCache();
}
writeCache(filePath = this.cacheFilePath) {
safeDump(this.cache as Cache, filePath);
}
updateCache(type: CommandType) {
if (!this.cache?.commandPickerMap) {
this.initCacheFile();
return;
}
if (type === CommandType.PLUGIN_TYPE) {
this.cache.commandPickerMap[type] = this.getPluginMap();
} else if (type === CommandType.UNIVERSAL_PLUGIN_TYPE) {
this.cache.commandPickerMap[type] = this.getUniversalMap();
}
this.writeCache();
this.lastStore = {};
this.lastCommand = '';
}
getAllCommandPickerMap(): Partial<PickMap> {
const commandPickerMap: Partial<PickMap> = {};
commandPickerMap[CommandType.NATIVE_TYPE] = this.getNativeMap();
commandPickerMap[CommandType.PLUGIN_TYPE] = this.getPluginMap();
commandPickerMap[CommandType.INTERNAL_PLUGIN_TYPE] = this.getInternalPluginMap();
commandPickerMap[CommandType.UNIVERSAL_PLUGIN_TYPE] = this.getUniversalMap();
return commandPickerMap;
}
getNativeMap(): PluginInfo {
const nativePath = path.join(__dirname, '../native');
const nativeMap: PluginInfo = {};
fs.readdirSync(nativePath)
.filter(file => file.endsWith('.js'))
.forEach((file) => {
const command = file.split('.')[0];
// 通过缓存路径的方式并不是一个值得主张的方案,例如在我们使用webpack构建单文件时这个机制会成为束缚
// 缓存绝对路径更不提倡,当客户端node切换不同版本时,绝对路径将导致异常
// 此处将其变更为相对路径,暂时解决版本切换的问题
// 另外值得讨论的是,cache逻辑本身不应该阻塞正常业务流程,但目前cache带来的问题反而比主逻辑还多,这是很不健康的现象
nativeMap[command] = {
commands: [
{
name: command,
},
],
path: file,
type: CommandType.NATIVE_TYPE,
};
});
return nativeMap;
}
getInternalPluginMap(): PluginInfo {
const devtool: PluginInfo = {};
for (const command of Object.keys(internalPlugins)) {
devtool[command] = {
path: internalPlugins[command],
type: CommandType.INTERNAL_PLUGIN_TYPE,
commands: [{ name: 'devtool' }],
};
}
return devtool;
}
getPluginMap(): PluginInfo {
const plugin: PluginInfo = {};
const [err, pluginList] = getPluginsList(this.ctx);
const home = path.join(osEnv.home(), FEFLOW_ROOT);
if (!Object.keys(this.subCommandMap).length) {
return plugin;
}
if (!err) {
for (const pluginNpm of pluginList) {
const pluginPath = path.join(home, 'node_modules', pluginNpm);
// TODO
// read plugin command from the key which from its package.json
plugin[pluginNpm] = {
type: CommandType.PLUGIN_TYPE,
commands: this.subCommandMap[pluginNpm]?.map((cmd: string) => ({
name: cmd,
})),
path: pluginPath,
};
}
} else {
this.ctx.logger.debug('picker load plugin failed', err);
}
return plugin;
}
getUniversalMap(): PluginInfo {
const universalPlugin: PluginInfo = {};
for (const pkg of Object.keys(this.subCommandMapWithVersion)) {
if (!pkg) continue;
const plugin = this.subCommandMapWithVersion[pkg];
universalPlugin[pkg] = {
...plugin,
type: CommandType.UNIVERSAL_PLUGIN_TYPE,
};
}
return universalPlugin;
}
getCache() {
const { cacheFilePath } = this;
try {
return parseYaml(cacheFilePath) as Cache;
} catch (error) {
this.ctx.logger.error(`parse ${CACHE_FILE} error: `, error);
}
}
removeCache(name: string) {
if (!this.cache) return;
const { commandPickerMap = {} } = this.cache;
let targetPath = { type: '', plugin: '' };
for (const type of this.pickOrder) {
const pluginsInType = commandPickerMap[type];
if (!pluginsInType) continue;
for (const plugin of Object.keys(pluginsInType as PluginInfo)) {
if (name === plugin) {
targetPath = {
type,
plugin,
};
}
}
if (targetPath.type) {
break;
}
}
if (targetPath.type && targetPath.plugin && this.cache.commandPickerMap) {
delete this.cache.commandPickerMap[targetPath.type][targetPath.plugin];
this.writeCache();
}
}
// 获取命令的缓存目录
getCommandPath(cmd: string): TargetPlugin | TargetUniversalPlugin {
let target: TargetPlugin | TargetUniversalPlugin = new TargetUniversalPlugin(CommandType.UNKNOWN_TYPE, '', '');
if (!this.cache?.commandPickerMap) return target;
const { commandPickerMap } = this.cache;
let cmdList: Array<TargetPlugin | TargetUniversalPlugin> = [];
for (const type of this.pickOrder) {
const pluginsInType = commandPickerMap[type];
if (!pluginsInType) continue;
for (const plugin of Object.keys(pluginsInType as PluginInfo)) {
const { commands, path, type } = pluginsInType[plugin] as PluginItem;
commands?.forEach(({ name, path: cmdPath, version }) => {
if (cmd === name) {
if (type === CommandType.UNIVERSAL_PLUGIN_TYPE) {
target = new TargetUniversalPlugin(type, version as string, plugin);
} else if (type === CommandType.NATIVE_TYPE) {
target = new NativePlugin(type, (cmdPath || path) as string, CommandType.NATIVE_TYPE);
} else {
target = new TargetPlugin(type, (cmdPath || path) as string, plugin);
}
cmdList.push(_.cloneDeep(target));
}
});
}
}
const { args } = this.ctx;
if (cmdList.length >= 2) {
if (!args.pick) {
this.ctx.logger.debug(`
当前命令(${cmd})出现冲突, 如果如果想要执行其他插件,请使用--pick参数指明
例如: fef doctor --pick native 或者 fef doctor --pick /feflow-plugin-raft`);
} else {
cmdList = cmdList.filter(({ pkg }) => pkg === args.pick);
}
}
return cmdList[0];
}
}
export default class CommandPicker {
root: string;
cmd: string;
ctx: Feflow;
isHelp: boolean;
cacheController: CacheController;
supportType = [
CommandType.NATIVE_TYPE,
CommandType.PLUGIN_TYPE,
CommandType.UNIVERSAL_PLUGIN_TYPE,
CommandType.INTERNAL_PLUGIN_TYPE,
];
homeRunCmd = ['help', 'list'];
constructor(ctx: Feflow, cmd = 'help') {
this.root = ctx.root;
this.ctx = ctx;
this.cmd = cmd;
this.isHelp = cmd === 'help' || ctx.args.h || ctx.args.help;
this.cacheController = new CacheController(ctx);
}
async loadHelp() {
this.cmd = 'help';
await this.pickCommand();
}
isAvailable() {
const targetCommand = this.cacheController.getCommandPath(this.cmd) || {};
const { type } = targetCommand;
if (type === CommandType.UNIVERSAL_PLUGIN_TYPE) {
const { version, pkg } = targetCommand as TargetUniversalPlugin;
const pkgPath = path.join(this.ctx.universalModules, `${pkg}@${version}`);
const pathExists = fs.existsSync(pkgPath);
return !this.isHelp && pathExists && !!version;
}
if (type === CommandType.PLUGIN_TYPE) {
const { path } = targetCommand as TargetPlugin;
const pathExists = fs.existsSync(path);
const isCacheType = this.supportType.includes(type);
return !this.isHelp && pathExists && isCacheType;
}
if (type === CommandType.NATIVE_TYPE) {
if (!this.homeRunCmd.includes(this.cmd)) {
return true;
}
}
return false;
}
async checkCommand() {
const cmdInfo = this.ctx?.commander.get(this.cmd);
if (!cmdInfo) {
await this.loadHelp();
}
}
getCommandSource(path: string): string {
const reg = /node_modules\/(.*)/;
const [, commandSource] = reg.exec(path) || [];
return commandSource;
}
async pickCommand() {
const targetCommand = this.cacheController.getCommandPath(this.cmd) || {};
const { type } = targetCommand;
const pluginLogger = logger({
debug: Boolean(this.ctx.args.debug),
silent: Boolean(this.ctx.args.silent),
name: targetCommand.pkg,
});
this.ctx.logger.debug('pick command type: ', type);
if (!this.supportType.includes(type)) {
return this.ctx.logger.warn(`this kind of command is not supported in command picker: ${type}`);
}
if (type === CommandType.UNIVERSAL_PLUGIN_TYPE) {
const { version, pkg } = targetCommand as TargetUniversalPlugin;
try {
await execPlugin(Object.assign({}, this.ctx, { logger: pluginLogger }), pkg, version);
} catch (error) {
this.ctx.fefError.printError({
error,
});
}
} else {
let commandPath = '';
if (targetCommand instanceof TargetPlugin) {
commandPath = targetCommand.path;
}
// native命令跟node版本挂钩,需要解析到具体node版本下路径
if (type === 'native') {
// 兼容原来的绝对路径形式
if (path.isAbsolute(commandPath)) {
commandPath = path.basename(commandPath);
}
commandPath = path.join(__dirname, '../native', commandPath);
}
const commandSource = this.getCommandSource(commandPath) || CommandType.NATIVE_TYPE;
this.ctx.logger.debug('pick command path: ', commandPath);
this.ctx.logger.debug('pick command source: ', commandSource);
try {
this.ctx?.reporter?.setCommandSource(commandSource);
const commandEntry = await import(commandPath);
await commandEntry.default(Object.assign({}, this.ctx, { logger: pluginLogger }));
} catch (error) {
this.ctx.fefError.printError({
error,
});
}
}
}
getCmdInfo(): { path: string; type: CommandType } {
const targetCommand = this.cacheController.getCommandPath(this.cmd) || {};
const { type } = targetCommand;
const cmdInfo: { path: string; type: CommandType } = {
type,
path: '',
};
if (type === CommandType.PLUGIN_TYPE) {
cmdInfo.path = (targetCommand as TargetPlugin).path;
} else if (type === CommandType.UNIVERSAL_PLUGIN_TYPE) {
const { pkg, version } = targetCommand as TargetUniversalPlugin;
cmdInfo.path = path.join(this.ctx.root, UNIVERSAL_MODULES, `${pkg}@${version}`);
} else {
cmdInfo.path = this.ctx.root;
}
return cmdInfo;
}
}