@fontoxml/fontoxml-development-tools
Version:
Development tools for Fonto.
529 lines (474 loc) • 17.8 kB
JavaScript
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);
}
}
}