UNPKG

@fontoxml/fontoxml-development-tools

Version:

Development tools for Fonto.

529 lines (474 loc) 17.8 kB
import fs from 'fs'; import os from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; import semver from 'semver'; import addCoreModulesToApp from './addCoreModulesToApp.js'; import addModulesToApp from './addModulesToApp.js'; import AssertableWritableStream from './AssertableWritableStream.js'; import ConfigManager from './ConfigManager.js'; import configureNetworkAgent from './configureNetworkAgent.js'; import createConfigFile from './createConfigFile.js'; import enrichRequestObject from './enrichRequestObject.js'; import FdtLicense from './FdtLicense.js'; import getParentDirectoryContainingFileSync from './getParentDirectoryContainingFileSync.js'; import ModuleRegistrationApi from './ModuleRegistrationApi.js'; import DeepOption from './request/DeepOption.js'; import DeepParameter from './request/DeepParameter.js'; import FdtCommand from './request/FdtCommand.js'; import FdtRequest from './request/FdtRequest.js'; import InputError from './request/InputError.js'; import IsolatedOption from './request/IsolatedOption.js'; import MultiOption from './request/MultiOption.js'; import Option from './request/Option.js'; import Parameter from './request/Parameter.js'; import FdtResponse from './response/FdtResponse.js'; import Version from './Version.js'; /** @typedef {import('./response/FdtResponse.js').default['ErrorWithInnerError']} ErrorWithInnerError */ /** @typedef {import('./response/FdtResponse.js').default['ErrorWithSolution']} ErrorWithSolution */ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); function enableTestMode(app, options) { options.catchErrors = false; options.useTestOutput = true; if (!options.configPath) { options.configPath = path.resolve(app.binPath, '..', 'test'); } if (options.isLocalVersion === undefined) { options.isLocalVersion = false; } } function enableTestOutput(app, options) { if (options.stdout === undefined && options.silent === undefined) { app.testOutput = new AssertableWritableStream({ stripAnsi: true }); options.stdout = app.testOutput; } } export default class FontoXMLDevelopmentToolsApp { /** * Initializes this instance of the FontoXMLDevelopmentToolsApp. * * @param {object} [options] * An optional options object. * @param {string} [options.appName] * The name to use for the app. Default: 'fdt'. * @param {string} [options.appVersion] * The current version of the app. Default: from package.json. * @param {string} [options.binPath] * The path to the FDT binary. Default `path.join(__dirname, '..', 'bin')` relative from this file. * @param {boolean} [options.isLocalVersion] * Indicates if the `appVersion` is a local version which adds a build specifier to the version and not enforces compatibility. * @param {boolean} [options.catchErrors] * When set to false, run errors are thrown instead of being pretty outputted. Default: true. * @param {function(): FdtCommand} [options.commandClass] * The Command class constructor to use for all commands, must inherit from fdt.FdtCommand. * @param {string} [options.baseConfigFilename] * The filename to use for finding, and loading base configuration. Default: '.fdtrc.base'. * @param {string} [options.baseConfigPath] * The path to the built-in configuration file. Default: <the root directory of this package>. * @param {string} [options.configFilename] * The filename to use for finding, loading, and saving configuration files. Default: '.fdtrc'. * @param {string} [options.configPath] * The path to use for storing configuration if no preexisting config is found in the cwd or its ancestry. Default: os.homedir(). * @param {boolean} [options.hideStacktraceOnErrors] * When set to true, the stacktrace is not outputted when errors are outputted. Default: !process.env.FDT_STACK_TRACE_ON_ERROR. * @param {boolean} [options.showHidden] * When set to true, the hidden modules, commands, and options are shown in the help and context informers output. Default: !!process.env.FDT_SHOW_HIDDEN. * @param {boolean} [options.silent] * Disables outputting to options.stdout. * @param {boolean} [options.skipAddModules] * When set to true, skip loading of the built-in Fonto related modules. * @param {boolean} [options.skipEnrichRequestObject] * When set to true, skip adding the fdt property to the request object. * @param {WritableStream} [options.stdout] * Stream to use for output, instead of stdout. * @param {boolean} [options.testMode] * When set to true, enable test mode. This is meant for unit tests and will do the following: * - Set catchErrors to false. * - Set configPath to FDT's 'test' directory (if no explicit value was set). * - Set useTestOutput to true. * @param {boolean} [options.useTestOutput] * When set to true, enable test output. This is meant for unit tests and will do the following: * - Output everything to .testOutput stream which has some helper methods for unit testing. */ async init(options = {}) { this.binPath = options.binPath || path.join(__dirname, '..', 'bin'); /* istanbul ignore else: All tests should be run in testmode to prevent configuration conflicts */ if (options.testMode) { enableTestMode(this, options); } /* istanbul ignore else: All tests should be run using test output to prevent output conflicts */ if (options.useTestOutput) { enableTestOutput(this, options); } /* @type {string | null} */ let defaultAppVersion = null; /* @type {string | null} */ let requiredEnginesNode = null; try { const packageJson = JSON.parse( fs.readFileSync( path.resolve(this.binPath, '..', 'package.json'), 'utf8', ), ); defaultAppVersion = packageJson.version || null; requiredEnginesNode = packageJson.engines?.node || null; } catch (_error) { // TODO: Handle this error properly. // Ignore } let defaultIsLocalVersion = false; try { if (fs.existsSync(path.resolve(this.binPath, '..', '.fdtlocal'))) { defaultIsLocalVersion = true; } } catch (_error) { // Ignore } const defaultOptions = { appName: 'fdt', appVersion: defaultAppVersion, isLocalVersion: defaultIsLocalVersion, catchErrors: true, commandClass: FdtCommand, baseConfigFilename: '.fdtrc.base', baseConfigPath: path.join(this.binPath, '..'), configFilename: '.fdtrc', configPath: os.homedir(), hideStacktraceOnErrors: !process.env.FDT_STACK_TRACE_ON_ERROR, showHidden: !!process.env.FDT_SHOW_HIDDEN, skipAddModules: false, skipEnrichRequestObject: false, }; // Default options, but AFTER test mode options have been determined. options = { ...defaultOptions, ...options }; this.name = options.appName; this.isLocalVersion = !!options.isLocalVersion; this.version = new Version(options.appVersion); this.catchErrors = !!options.catchErrors; this.hideStacktraceOnErrors = !!options.hideStacktraceOnErrors; this.showHidden = !!options.showHidden; this.processPath = process.cwd(); let configLocation; if (!options.testMode) { configLocation = getParentDirectoryContainingFileSync( this.processPath, options.configFilename ); } if (!configLocation) { createConfigFile(options.configPath, options.configFilename); configLocation = options.configPath; } this.config = new ConfigManager( configLocation, options.baseConfigPath, options.configFilename, options.baseConfigFilename ); const colorConfig = null; // TODO: Windows color config. /* istanbul ignore next */ this.logger = new FdtResponse(colorConfig, { indentation: ' ', stdout: options.silent ? { write: () => true, } : options.stdout || process.stdout, }); configureNetworkAgent(this); const CommandClass = options.commandClass; if ( CommandClass !== FdtCommand && !(CommandClass.prototype instanceof FdtCommand) ) { throw new Error( 'Optional option commandClass does not inherit from FdtCommand.' ); } /** @type {FdtCommand & { DeepOption: DeepOption, DeepParameter: DeepParameter, InputError: InputError, IsolatedOption: IsolatedOption, MultiOption: MultiOption, Option: Option, Parameter: Parameter}} */ this.cli = new CommandClass(this.name); this.cli.DeepOption = DeepOption; this.cli.DeepParameter = DeepParameter; this.cli.InputError = InputError; this.cli.IsolatedOption = IsolatedOption; this.cli.MultiOption = MultiOption; this.cli.Option = Option; this.cli.Parameter = Parameter; /** @type {ModuleRegistrationApi[]} */ this.modules = []; /** @type {ModuleRegistrationApi[]} */ this.builtInModules = []; this.builtInModulesPath = path.join(this.binPath, '..', 'src', 'modules'); await addCoreModulesToApp(this, options); this.license = new FdtLicense(this); this.request = new FdtRequest(); this.cli.preControllers.unshift(async (req, res) => { // TODO: Do this early. But for that we need to inline the ask-nicely code so we can // expose the argument parser code and do that early (and re-use on run later on). if (!options.skipEnrichRequestObject) { await enrichRequestObject(req, res, this); } if (!req.command.isRawOutput(req)) { if (this.isLocalVersion) { res.notice( `Using a locally cloned FDT pretending to be version "${this.version.format()}". Use at your own risk.` ); res.break(); } if (requiredEnginesNode) { try { const range = requiredEnginesNode.replace(/\s+/g, ' || ') || '*'; if (!semver.satisfies(process.version, range)) { res.notice( 'You might be using a Node.js version which is not compatible with this version of FDT.' ); res.break(); } } catch (_error) { // Ignore } } } }); if (!options.skipAddModules) { await addModulesToApp(this); } return this; } /** * Retrieve the path to a registered module by its name. * * @param {string} moduleName The name of the module (see its package.json). * * @return {(string|undefined)} */ getPathToModule(moduleName) { for (const module of this.modules) { const moduleInfo = module.getInfo(); if (moduleInfo.name === moduleName) { return moduleInfo.path; } } return undefined; } /** * Get the specified export from a module with the given name. * * @param {string} moduleName The name of the module (see its package.json). * @param {string} exportName The name of the export to retrieve. * * @return {Promise<unknown | null>} The export value if found, otherwise null. */ async getExportFromModule(moduleName, exportName) { for (const module of this.modules) { const moduleInfo = module.getInfo(); if (moduleInfo.name === moduleName) { return module.getExport(exportName); } } return null; } /** * Built in modules are not saved to the config files. These modules can be added at runtime. * This is useful when creating a tools bundle powered by fdt. * * @param {string} modulePath The absolute path to the module to enable. * @param {...*} [extra] Extra parameters which can be used by the module, only used for built-in modules. * * @return {Promise<ModuleRegistrationApi|null>} */ async enableBuiltInModule(modulePath, ...extra) { const enabledModule = await this.enableModule(modulePath, ...extra); if (enabledModule) { this.builtInModules.push(enabledModule); } return enabledModule; } /** * Enable a module, which can be saved to the config file. * * @param {string} modulePath The absolute path to the module to enable. * @param {...*} [extra] Extra parameters which can be used by the module, only used for built-in modules. * * @return {Promise<ModuleRegistrationApi|null>} */ async silentEnableModule(modulePath, ...extra) { const enabledModule = new ModuleRegistrationApi(this, modulePath); const modInfo = enabledModule.getInfo(); const modulesWithSameName = this.modules.filter( (mod) => mod.getInfo().name === modInfo.name ); if (modulesWithSameName.length) { return null; } await enabledModule.load(...extra); this.modules.push(enabledModule); return enabledModule; } /** * Enable a module, which can be saved to the config file. Outputs notice when the module is a duplicate. * * @param {string} modulePath The absolute path to the module to enable. * @param {...*} [extra] Extra parameters which can be used by the module, only used for built-in modules. * * @return {Promise<ModuleRegistrationApi|null>} */ async enableModule(modulePath, ...extra) { let enabledModule = await this.silentEnableModule(modulePath, ...extra); if (!enabledModule) { enabledModule = new ModuleRegistrationApi(this, modulePath); const modInfo = enabledModule.getInfo(); const moduleWithSameName = this.modules .filter((mod) => mod.getInfo().name === modInfo.name) .map((mod) => { const info = mod.getInfo(); return info.path + (info.builtIn ? ' (built-in)' : ''); }); this.logger.break(); this.logger.notice( `Not loading module with name "${modInfo.path}", a module with the same name is already loaded.` ); this.logger.list(moduleWithSameName, '-'); this.logger.debug( `You can check your modules with \`${ this.getInfo().name } module --list --verbose\` and remove the conflicting module(s) with \`${ this.getInfo().name } module --remove <modulePath>\`.` ); return null; } return enabledModule; } /** * Returns an object with information that a module could use to reason about which fdt instance it is used for. * * @return {{name: string, version?: Version}} */ getInfo() { return { name: this.name, version: this.version, }; } /** * Run a (sub) command based on args. It uses this.request as request by default. * * @param {Array<string>} args The arguments, specifying which (sub) command to run with what options and parameters. * @param {Object} request Optionally a request object, should not be specified. Default: this.request. * * @return {Promise.<TResult>} */ run(args, request) { const executedRequest = this.cli.execute( Object.assign([], args), request || this.request, this.logger ); if (!this.catchErrors) { return executedRequest; } return executedRequest .catch((error) => { this.error(undefined, error, { cwd: this.processPath, node: `Version ${process.version} running on ${process.platform} ${process.arch}.`, fdt: `Version ${this.version.format()}.`, args: [this.name] .concat( args.map((arg) => arg.indexOf(' ') >= 0 ? `"${arg}"` : arg ) ) .join(' '), mods: this.modules .map((mod) => { const modInfo = mod.getInfo(); return `${modInfo.name} (${modInfo.version})`; }) .join(os.EOL), }); // Do not hard exit program, but rather exit with error code once the program is closing itself /* istanbul ignore next: Process will not exit untill unit tests are done. */ process.on('exit', function () { process.exit(1); }); }) .then(() => { /* istanbul ignore next: We might be on either os when running the unit tests. */ if (os.platform() !== 'win32') { this.logger.break(); } return this; }); } /** * @param {string} [caption] * @param {Error|ErrorWithInnerError|ErrorWithSolution|InputError} [error] * @param {Object} [debugVariables] */ error(caption, error, debugVariables) { this.logger.destroyAllSpinners(); // The `ErrorWith*` inherits from `InputError`, but are not handled as a pure input error. const isErrorWithDetails = error instanceof this.logger.ErrorWithInnerError || error instanceof this.logger.ErrorWithSolution; const isInputError = !isErrorWithDetails && ( error instanceof this.logger.InputError || error instanceof this.cli.InputError ); // Output the caption or generic (input) error caption. this.logger.caption(caption || (isInputError ? 'Input error' : 'Error')); if (error) { this.logger.error(error.message || error.stack || error); } // Output solutions, including the generic `InputError` solution. if (isInputError) { this.logger.break(); this.logger.notice( 'You might be able to fix this, use the "--help" flag for usage info.' ); if (error.solution) { this.logger.log(error.solution); } } else if (error?.solution) { this.logger.break(); this.logger.notice(error.solution); } if (this.hideStacktraceOnErrors) { return; } // Output stack trace if the error is not one of the built-in errors types. if (!isInputError && !isErrorWithDetails && error?.stack) { this.logger.indent(); this.logger.caption('Stack trace'); this.logger.debug(error.stack); this.logger.outdent(); } // Output any inner errors stack traces. if (error?.innerError) { let innerError = error.innerError; let innerErrorCount = 0; while (innerError) { innerErrorCount++; this.logger.indent(); this.logger.caption( innerErrorCount === 1 && !innerError.innerError ? 'Inner error' : `Inner error (${innerErrorCount})` ); this.logger.debug(innerError.stack || innerError); innerError = innerError.innerError; } for (let i = 0; i < innerErrorCount; i++) { this.logger.outdent(); } } // Output any debug variables. if (debugVariables) { this.logger.properties(debugVariables); } } }