@boost/core
Version:
Robust pipeline for creating dev tools that separate logic into routines and tasks.
457 lines (456 loc) • 18 kB
JavaScript
"use strict";
/* eslint-disable no-param-reassign, no-console */
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k];
result["default"] = mod;
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
const fs_extra_1 = __importDefault(require("fs-extra"));
const path_1 = __importDefault(require("path"));
const util_1 = __importDefault(require("util"));
const chalk_1 = __importDefault(require("chalk"));
const debug_1 = __importDefault(require("debug"));
const env_ci_1 = __importDefault(require("env-ci"));
const fast_glob_1 = __importDefault(require("fast-glob"));
const pluralize_1 = __importDefault(require("pluralize"));
const mergeWith_1 = __importDefault(require("lodash/mergeWith"));
const optimal_1 = __importStar(require("optimal"));
const yargs_parser_1 = __importDefault(require("yargs-parser"));
const common_1 = require("@boost/common");
const event_1 = require("@boost/event");
const debug_2 = require("@boost/debug");
const log_1 = require("@boost/log");
const translate_1 = require("@boost/translate");
const ConfigLoader_1 = __importDefault(require("./ConfigLoader"));
const Console_1 = __importDefault(require("./Console"));
const Emitter_1 = __importDefault(require("./Emitter"));
const ExitError_1 = __importDefault(require("./ExitError"));
const ModuleLoader_1 = __importDefault(require("./ModuleLoader"));
const Plugin_1 = __importDefault(require("./Plugin"));
const Reporter_1 = __importDefault(require("./Reporter"));
const BoostReporter_1 = __importDefault(require("./reporters/BoostReporter"));
const ErrorReporter_1 = __importDefault(require("./reporters/ErrorReporter"));
const handleMerge_1 = __importDefault(require("./helpers/handleMerge"));
const CIReporter_1 = __importDefault(require("./reporters/CIReporter"));
const constants_1 = require("./constants");
class Tool extends Emitter_1.default {
constructor(options, argv = []) {
super();
this.argv = [];
// @ts-ignore Set after instantiation
this.config = {};
this.onExit = new event_1.Event('exit');
this.onInit = new event_1.Event('init');
this.onLoadPlugin = new event_1.Event('load-plugin');
this.package = { name: '', version: '0.0.0' };
this.initialized = false;
this.plugins = {};
this.pluginTypes = {};
this.argv = argv;
this.options = optimal_1.default(options, {
appName: optimal_1.string()
.required()
.notEmpty()
.match(constants_1.APP_NAME_PATTERN),
appPath: optimal_1.string()
.required()
.notEmpty(),
argOptions: optimal_1.object(),
configBlueprint: optimal_1.object(),
configName: optimal_1.string().custom(value => {
if (value && !value.match(constants_1.CONFIG_NAME_PATTERN)) {
throw new Error('Config file name must be camel case without extension.');
}
}),
footer: optimal_1.string(),
header: optimal_1.string(),
root: optimal_1.string(process.cwd()),
scoped: optimal_1.bool(),
settingsBlueprint: optimal_1.object(),
workspaceRoot: optimal_1.string(),
}, {
name: this.constructor.name,
});
// Set environment variables
process.env.BOOST_DEBUG_GLOBAL_NAMESPACE = this.options.appName;
// Core debugger, logger, and translator for the entire tool
this.debug = debug_2.createDebugger('core');
this.log = log_1.createLogger();
this.msg = translate_1.createTranslator(['app', 'errors'], [
path_1.default.join(__dirname, '../res'),
path_1.default.join(this.options.appPath, 'res'),
// TODO Remove in 2.0
path_1.default.join(this.options.appPath, 'resources'),
], {
// TODO Change to yaml in 2.0
resourceFormat: 'json',
});
// eslint-disable-next-line global-require
this.debug('Using boost v%s', require('../package.json').version);
// Initialize the console first so we can start logging
this.console = new Console_1.default(this);
// Make this available for testing purposes
this.configLoader = new ConfigLoader_1.default(this);
// Define a special type of plugin
this.registerPlugin('reporter', Reporter_1.default, {
beforeBootstrap: reporter => {
reporter.console = this.console;
},
scopes: ['boost'],
});
// istanbul ignore next
if (process.env.NODE_ENV !== 'test') {
// Add a reporter to catch errors during initialization
this.addPlugin('reporter', new ErrorReporter_1.default());
// Cleanup when an exit occurs
process.on('exit', code => {
this.emit('exit', [code]);
this.onExit.emit([code]);
});
}
// TODO Backwards compat, remove in 2.0
// @ts-ignore
this.createDebugger = debug_2.createDebugger;
// @ts-ignore
this.createTranslator = translate_1.createTranslator;
}
/**
* Add a plugin for a specific contract type and bootstrap with the tool.
*/
addPlugin(typeName, plugin) {
const type = this.getRegisteredPlugin(typeName);
if (!common_1.instanceOf(plugin, Plugin_1.default)) {
throw new TypeError(this.msg('errors:pluginNotExtended', { parent: 'Plugin', typeName }));
}
else if (!common_1.instanceOf(plugin, type.contract)) {
throw new TypeError(this.msg('errors:pluginNotExtended', { parent: type.contract.name, typeName }));
}
plugin.tool = this;
if (type.beforeBootstrap) {
type.beforeBootstrap(plugin);
}
plugin.bootstrap();
if (type.afterBootstrap) {
type.afterBootstrap(plugin);
}
this.plugins[typeName].add(plugin);
this.onLoadPlugin.emit([plugin], typeName);
return this;
}
/**
* Create a workspace metadata object composed of absolute file paths.
*/
createWorkspaceMetadata(jsonPath) {
const metadata = {};
metadata.jsonPath = jsonPath;
metadata.packagePath = path_1.default.dirname(jsonPath);
metadata.packageName = path_1.default.basename(metadata.packagePath);
metadata.workspacePath = path_1.default.dirname(metadata.packagePath);
metadata.workspaceName = path_1.default.basename(metadata.workspacePath);
return metadata;
}
/**
* Force exit the application.
*/
exit(message = null, code = 1) {
const error = new ExitError_1.default(this.msg('errors:processTerminated'), code);
if (message) {
if (common_1.instanceOf(message, Error)) {
error.message = message.message;
error.stack = message.stack;
}
else {
error.message = message;
}
}
throw error;
}
/**
* Return a plugin by name and type.
*/
getPlugin(typeName, name) {
const plugin = this.getPlugins(typeName).find(p => common_1.instanceOf(p, Plugin_1.default) && p.name === name);
if (plugin) {
return plugin;
}
throw new Error(this.msg('errors:pluginNotFound', {
name,
typeName,
}));
}
/**
* Return all plugins by type.
*/
getPlugins(typeName) {
// Trigger check
this.getRegisteredPlugin(typeName);
return Array.from(this.plugins[typeName]);
}
/**
* Return a registered plugin type by name.
*/
getRegisteredPlugin(typeName) {
const type = this.pluginTypes[typeName];
if (!type) {
throw new Error(this.msg('errors:pluginContractNotFound', { typeName }));
}
return type;
}
/**
* Return the registered plugin types.
*/
getRegisteredPlugins() {
return this.pluginTypes;
}
/**
* Return all `package.json`s across all workspaces and their packages.
* Once loaded, append workspace path metadata.
*/
getWorkspacePackages(options = {}) {
const root = options.root || this.options.root;
return fast_glob_1.default
.sync(this.getWorkspacePaths(Object.assign({}, options, { relative: true, root })).map(ws => `${ws}/package.json`), {
absolute: true,
cwd: root,
})
.map(filePath => (Object.assign({}, fs_extra_1.default.readJsonSync(filePath), { workspace: this.createWorkspaceMetadata(filePath) })));
}
/**
* Return a list of absolute package folder paths, across all workspaces,
* for the defined root.
*/
getWorkspacePackagePaths(options = {}) {
const root = options.root || this.options.root;
return fast_glob_1.default.sync(this.getWorkspacePaths(Object.assign({}, options, { relative: true, root })), {
absolute: !options.relative,
cwd: root,
onlyDirectories: true,
onlyFiles: false,
});
}
/**
* Return a list of workspace folder paths, with wildstar glob in tact,
* for the defined root.
*/
getWorkspacePaths(options = {}) {
const root = options.root || this.options.root;
const pkgPath = path_1.default.join(root, 'package.json');
const lernaPath = path_1.default.join(root, 'lerna.json');
const workspacePaths = [];
// Yarn
if (fs_extra_1.default.existsSync(pkgPath)) {
const pkg = fs_extra_1.default.readJsonSync(pkgPath);
if (pkg.workspaces) {
if (Array.isArray(pkg.workspaces)) {
workspacePaths.push(...pkg.workspaces);
}
else if (Array.isArray(pkg.workspaces.packages)) {
workspacePaths.push(...pkg.workspaces.packages);
}
}
}
// Lerna
if (workspacePaths.length === 0 && fs_extra_1.default.existsSync(lernaPath)) {
const lerna = fs_extra_1.default.readJsonSync(lernaPath);
if (Array.isArray(lerna.packages)) {
workspacePaths.push(...lerna.packages);
}
}
if (options.relative) {
return workspacePaths;
}
return workspacePaths.map(workspace => path_1.default.join(root, workspace));
}
/**
* Initialize the tool by loading config and plugins.
*/
initialize() {
if (this.initialized) {
return this;
}
const { appName } = this.options;
const pluginNames = Object.keys(this.pluginTypes);
this.debug('Initializing %s application', chalk_1.default.yellow(appName));
this.args = yargs_parser_1.default(this.argv, mergeWith_1.default({
array: [...pluginNames],
boolean: ['debug', 'silent'],
number: ['output'],
string: ['config', 'locale', 'theme', ...pluginNames],
}, this.options.argOptions, handleMerge_1.default));
this.loadConfig();
this.loadPlugins();
this.loadReporters();
this.initialized = true;
this.onInit.emit([]);
return this;
}
/**
* Return true if running in a CI environment.
*/
isCI() {
return env_ci_1.default().isCi;
}
/**
* Return true if a plugin by type has been enabled in the configuration file
* by property name of the same type. The following variants are supported:
*
* - As a string using the plugins name: "foo"
* - As an object with a property by plugin type: { plugin: "foo" }
* - As an instance of the plugin class: new Plugin()
*/
isPluginEnabled(typeName, name) {
const type = this.getRegisteredPlugin(typeName);
const setting = this.config[type.pluralName];
if (!setting || !Array.isArray(setting)) {
return false;
}
return setting.some(value => {
if (typeof value === 'string' && value === name) {
return true;
}
if (typeof value === 'object' &&
value[type.singularName] &&
value[type.singularName] === name) {
return true;
}
if (typeof value === 'object' && value.constructor && value.name === name) {
return true;
}
return false;
});
}
/**
* Load all `package.json`s across all workspaces and their packages.
* Once loaded, append workspace path metadata.
*
* @deprecated
*/
loadWorkspacePackages(options = {}) {
console.warn('`tool.loadWorkspacePackages` is deprecated. Use `tool.getWorkspacePackages` instead.');
return this.getWorkspacePackages(options);
}
/**
* Log a live message to the console to display while a process is running.
*
* @deprecated
*/
logLive(message, ...args) {
console.warn('`tool.logLive` is deprecated. Use `console.log` instead.');
this.console.logLive(util_1.default.format(message, ...args));
return this;
}
/**
* Log an error to the console to display on failure.
*
* @deprecated
*/
logError(message, ...args) {
console.warn('`tool.logError` is deprecated. Use `tool.log.error` or `tool.console.logError` instead.');
this.console.logError(util_1.default.format(message, ...args));
return this;
}
/**
* Register a custom type of plugin, with a defined contract that all instances should extend.
* The type name should be in singular form, as plural variants are generated automatically.
*/
registerPlugin(typeName, contract, options = {}) {
if (this.pluginTypes[typeName]) {
throw new Error(this.msg('errors:pluginContractExists', { typeName }));
}
const name = String(typeName);
const { afterBootstrap = null, beforeBootstrap = null, scopes = [] } = options;
this.debug('Registering new plugin type: %s', chalk_1.default.magenta(name));
this.plugins[typeName] = new Set();
this.pluginTypes[typeName] = {
afterBootstrap,
beforeBootstrap,
contract,
loader: new ModuleLoader_1.default(this, name, contract, scopes),
pluralName: pluralize_1.default(name),
scopes,
singularName: name,
};
return this;
}
/**
* Load the package.json and local configuration files.
*
* Must be called first in the lifecycle.
*/
loadConfig() {
if (this.initialized) {
return this;
}
this.package = this.configLoader.loadPackageJSON();
this.config = this.configLoader.loadConfig(this.args);
// Inherit workspace metadata
this.options.workspaceRoot = this.configLoader.workspaceRoot;
// Enable debugger (a bit late but oh well)
if (this.config.debug) {
debug_1.default.enable(`${this.options.appName}:*`);
}
return this;
}
/**
* Register plugins from the loaded configuration.
*
* Must be called after config has been loaded.
*/
loadPlugins() {
if (this.initialized) {
return this;
}
if (common_1.isEmpty(this.config)) {
throw new Error(this.msg('errors:configNotLoaded', { name: 'plugins' }));
}
Object.keys(this.pluginTypes).forEach(type => {
const typeName = type;
const { loader, pluralName } = this.pluginTypes[typeName];
const plugins = loader.loadModules(this.config[pluralName]);
// Sort plugins by priority
loader.debug('Sorting by priority');
plugins.sort((a, b) => common_1.instanceOf(a, Plugin_1.default) && common_1.instanceOf(b, Plugin_1.default) ? a.priority - b.priority : 0);
// Bootstrap each plugin with the tool
loader.debug('Bootstrapping with tool environment');
plugins.forEach(plugin => {
this.addPlugin(typeName, plugin);
});
});
return this;
}
/**
* Register reporters from the loaded configuration.
*
* Must be called after config has been loaded.
*/
loadReporters() {
if (this.initialized) {
return this;
}
if (common_1.isEmpty(this.config)) {
throw new Error(this.msg('errors:configNotLoaded', { name: 'reporters' }));
}
const reporters = this.plugins.reporter;
const { loader } = this.pluginTypes.reporter;
// Use a special reporter when in a CI
// istanbul ignore next
if (this.isCI() && !process.env.BOOST_ENV) {
loader.debug('CI environment detected, using %s CI reporter', chalk_1.default.yellow('boost'));
this.addPlugin('reporter', new CIReporter_1.default());
// Use default reporter
}
else if (reporters.size === 0 ||
(reporters.size === 1 && common_1.instanceOf(Array.from(reporters)[0], ErrorReporter_1.default))) {
loader.debug('Using default %s reporter', chalk_1.default.yellow('boost'));
this.addPlugin('reporter', new BoostReporter_1.default());
}
return this;
}
}
exports.default = Tool;