UNPKG

@boost/core

Version:

Robust pipeline for creating dev tools that separate logic into routines and tasks.

457 lines (456 loc) 18 kB
"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;