UNPKG

13.5 kBJavaScriptView Raw
1import ask from 'ask-nicely';
2import { readFileSync } from 'fs';
3import os from 'os';
4import path from 'path';
5import { fileURLToPath } from 'url';
6
7import addCoreModulesToApp from './addCoreModulesToApp.js';
8import addModulesToApp from './addModulesToApp.js';
9import AssertableWritableStream from './AssertableWritableStream.js';
10import ConfigManager from './ConfigManager.js';
11import createConfigFileInHomedir from './createConfigFileInHomedir.js';
12import enrichRequestObject from './enrichRequestObject.js';
13import FdtCommand from './FdtCommand.js';
14import getParentDirectoryContainingFileSync from './getParentDirectoryContainingFileSync.js';
15import ModuleRegistrationApi from './ModuleRegistrationApi.js';
16import FdtResponse from './response/FdtResponse.js';
17
18const __filename = fileURLToPath(import.meta.url);
19const __dirname = path.dirname(__filename);
20
21let packageJson = {};
22try {
23 packageJson = JSON.parse(
24 readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf8')
25 );
26} catch (_err) {
27 // TODO: Handle this error properly.
28 // Ignore
29}
30
31const DEFAULT_OPTIONS = {
32 appName: 'fdt',
33 appVersion: packageJson.version,
34 catchErrors: true,
35 commandClass: FdtCommand,
36 configFilename: '.fdtrc',
37 configHomedirPath: os.homedir(),
38 configPath: path.join(__dirname, '..'),
39 hideStacktraceOnErrors: !process.env.FDT_STACK_TRACE_ON_ERROR,
40};
41
42function enableTestMode(options) {
43 options.catchErrors = false;
44 options.useTestOutput = true;
45
46 if (!options.configFilename) {
47 options.configFilename = '.fdttestrc';
48 }
49
50 /* istanbul ignore else */
51 if (options.skipCreateConfigFileInHomedir === undefined) {
52 options.skipCreateConfigFileInHomedir = true;
53 }
54}
55
56function enableTestOutput(app, options) {
57 if (options.stdout === undefined && options.silent === undefined) {
58 app.testOutput = new AssertableWritableStream({ stripAnsi: true });
59 options.stdout = app.testOutput;
60 }
61}
62
63export default class FontoXMLDevelopmentToolsApp {
64 /**
65 * Initializes this instance of the FontoXMLDevelopmentToolsApp.
66 *
67 * @param {object} [options]
68 * An optional options object.
69 * @param {string} [options.appName]
70 * The name to use for the app. Default: 'fdt'.
71 * @param {string} [options.appVersion]
72 * The current version of the app. Default: from package.json.
73 * @param {boolean} [options.catchErrors]
74 * When set to false, run errors are thrown instead of being pretty outputted. Default: true.
75 * @param {function(): FdtCommand} [options.commandClass]
76 * The Command class constructor to use for all commands, must inherit from fdt.FdtCommand.
77 * @param {boolean} [options.configFilename]
78 * The filename to use for finding, loading, and saving configuration files. Default: '.fdtrc'.
79 * @param {boolean} [options.configHomedirPath]
80 * The homedir to use for storing configuration. Default: os.homedir().
81 * @param {boolean} [options.configPath]
82 * The path to the build in configuration file. Default: <the root directory of this package>.
83 * @param {boolean} [options.hideStacktraceOnErrors]
84 * When set to true, the stacktrace is not outputted when errors are outputted. Default: !process.env.FDT_STACK_TRACE_ON_ERROR.
85 * @param {boolean} [options.silent]
86 * Disables outputting to options.stdout.
87 * @param {boolean} [options.skipAddModules]
88 * When set to true, skip loading of the built-in Fonto related modules.
89 * @param {boolean} [options.skipCreateConfigFileInHomedir]
90 * When set to true, skip creating a configuration file in the home directory on start if it does not exist.
91 * @param {boolean} [options.skipEnrichRequestObject]
92 * When set to true, skip adding the fdt property to the request object.
93 * @param {WritableStream} [options.stdout]
94 * Stream to use for output, instead of stdout.
95 * @param {boolean} [options.testMode]
96 * When set to true, enable test mode. This is meant for unit tests and will do the following:
97 * - Set configFilename to '.fdttestrc' (if no explicit value was set).
98 * - Set skipCreateConfigFileInHomedir to true (if no explicit value was set).
99 * - Set useTestOutput to true.
100 * @param {boolean} [options.useTestOutput]
101 * When set to true, enable test output. This is meant for unit tests and will do the following:
102 * - Output everything to .testOutput stream which has some helper methods for unit testing.
103 */
104 async init(options = {}) {
105 // const appObject = new FontoXMLDevelopmentToolsApp();
106 /* istanbul ignore else: All tests should be run in testmode to prevent configuration conflicts */
107 if (options.testMode) {
108 enableTestMode(options);
109 }
110
111 /* istanbul ignore else: All tests should be run using test output to prevent output conflicts */
112 if (options.useTestOutput) {
113 enableTestOutput(this, options);
114 }
115
116 // Default options, but AFTER test mode options have been determined.
117 options = { ...DEFAULT_OPTIONS, ...options };
118
119 this.name = options.appName;
120 this.version = options.appVersion;
121
122 this.catchErrors = options.catchErrors;
123 this.hideStacktraceOnErrors = !!options.hideStacktraceOnErrors;
124 this.processPath = process.cwd();
125
126 let configLocation = getParentDirectoryContainingFileSync(
127 this.processPath,
128 options.configFilename
129 );
130
131 if (!configLocation) {
132 // Instantiate with a configuration file from either the FDT directory or your home directory.
133 /* istanbul ignore if: All tests should skip creating the config file in the homedir */
134 if (!options.skipCreateConfigFileInHomedir) {
135 createConfigFileInHomedir(options);
136 }
137
138 configLocation = options.configHomedirPath;
139 }
140
141 this.config = new ConfigManager(
142 configLocation,
143 options.configPath,
144 options.configFilename
145 );
146
147 // TODO: Refactor to also integrate AskNicely.
148 const colorConfig = null; // TODO: Windows color config.
149 /* istanbul ignore next */
150 this.logger = new FdtResponse(colorConfig, {
151 indentation: ' ',
152 stdout: options.silent
153 ? { write: () => {} }
154 : options.stdout || process.stdout,
155 });
156
157 const CommandClass = options.commandClass;
158 if (
159 CommandClass !== FdtCommand &&
160 !(CommandClass.prototype instanceof FdtCommand)
161 ) {
162 throw new Error(
163 'Optional option commandClass does not inherit from FdtCommand.'
164 );
165 }
166 this.cli = new CommandClass(this.name);
167 Object.assign(this.cli, ask);
168
169 this.modules = [];
170 this.builtInModules = [];
171 await addCoreModulesToApp(this, options);
172
173 this.request = Object.create(null);
174 if (!options.skipEnrichRequestObject) {
175 await enrichRequestObject(this);
176 }
177
178 if (!options.skipAddModules) {
179 await addModulesToApp(this);
180 }
181 return this;
182 }
183
184 /**
185 * Retrieve the path to a registered module by its name.
186 *
187 * @param {string} moduleName The name of the module (see its package.json).
188 *
189 * @return {(string|undefined)}
190 */
191 getPathToModule(moduleName) {
192 for (const module of this.modules) {
193 const moduleInfo = module.getInfo();
194 if (moduleInfo.name === moduleName) {
195 return moduleInfo.path;
196 }
197 }
198 return undefined;
199 }
200
201 /**
202 * Built in modules are not saved to the config files. These modules can be added at runtime.
203 * This is useful when creating a tools bundle powered by fdt.
204 *
205 * @param {string} modulePath The absolute path to the module to enable.
206 * @param {...*} [extra] Extra parameters which can be used by the module, only used for built-in modules.
207 *
208 * @return {(ModuleRegistrationApi|null)}
209 */
210 async enableBuiltInModule(modulePath, ...extra) {
211 const enabledModule = await this.enableModule(modulePath, ...extra);
212
213 if (enabledModule) {
214 this.builtInModules.push(enabledModule);
215 }
216
217 return enabledModule;
218 }
219
220 /**
221 * Enable a module, which can be saved to the config file.
222 *
223 * @param {string} modulePath The absolute path to the module to enable.
224 * @param {...*} [extra] Extra parameters which can be used by the module, only used for built-in modules.
225 *
226 * @return {(ModuleRegistrationApi|null)}
227 */
228 async silentEnableModule(modulePath, ...extra) {
229 const enabledModule = new ModuleRegistrationApi(this, modulePath);
230 const modInfo = enabledModule.getInfo();
231
232 const modulesWithSameName = this.modules.filter(
233 (mod) => mod.getInfo().name === modInfo.name
234 );
235 if (modulesWithSameName.length) {
236 return null;
237 }
238
239 await enabledModule.load(...extra);
240
241 this.modules.push(enabledModule);
242
243 return enabledModule;
244 }
245
246 /**
247 * Enable a module, which can be saved to the config file. Outputs notice when the module is a duplicate.
248 *
249 * @param {string} modulePath The absolute path to the module to enable.
250 * @param {...*} [extra] Extra parameters which can be used by the module, only used for built-in modules.
251 *
252 * @return {(ModuleRegistrationApi|null)}
253 */
254 async enableModule(modulePath, ...extra) {
255 let enabledModule = await this.silentEnableModule(modulePath, ...extra);
256
257 if (!enabledModule) {
258 enabledModule = new ModuleRegistrationApi(this, modulePath);
259 const modInfo = enabledModule.getInfo();
260 const moduleWithSameName = this.modules
261 .filter((mod) => mod.getInfo().name === modInfo.name)
262 .map((mod) => {
263 const info = mod.getInfo();
264 return info.path + (info.builtIn ? ' (built-in)' : '');
265 });
266 this.logger.break();
267 this.logger.notice(
268 `Not loading module with name "${modInfo.path}", a module with the same name is already loaded.`
269 );
270 this.logger.list(moduleWithSameName, '-');
271 this.logger.debug(
272 `You can check your modules with \`${
273 this.getInfo().name
274 } module --list --verbose\` and remove the conflicting module(s) with \`${
275 this.getInfo().name
276 } module --remove <modulePath>\`.`
277 );
278 return null;
279 }
280
281 return enabledModule;
282 }
283
284 /**
285 * Returns an object with information that a module could use to reason about which fdt instance it is used for.
286 *
287 * @return {{name: *, version: ?string}}
288 */
289 getInfo() {
290 return {
291 name: this.name,
292 version: this.version,
293 };
294 }
295
296 /**
297 * Run a (sub) command based on args. It uses this.request as request by default.
298 *
299 * @param {Array<string>} args The arguments, specifying which (sub) command to run with what options and parameters.
300 * @param {Object} request Optionally a request object, should not be specified. Default: this.request.
301 *
302 * @return {Promise.<TResult>}
303 */
304 run(args, request) {
305 const executedRequest = this.cli.execute(
306 Object.assign([], args),
307 { ...(request || this.request) },
308 this.logger
309 );
310
311 if (!this.catchErrors) {
312 return executedRequest;
313 }
314
315 return executedRequest
316 .catch((error) => {
317 this.error(undefined, error, {
318 cwd: this.processPath,
319 node: `Version ${process.version} running on ${process.platform} ${process.arch}.`,
320 fdt: `Version ${this.version}.`,
321 args: [this.name]
322 .concat(
323 args.map((arg) =>
324 arg.indexOf(' ') >= 0 ? `"${arg}"` : arg
325 )
326 )
327 .join(' '),
328 mods: this.modules
329 .map(
330 (mod) =>
331 `${mod.getInfo().name} (${
332 mod.getInfo().version
333 })`
334 )
335 .join(os.EOL),
336 });
337
338 // Do not hard exit program, but rather exit with error code once the program is closing itself
339 /* istanbul ignore next: Process will not exit untill unit tests are done. */
340 process.on('exit', function () {
341 process.exit(1);
342 });
343 })
344 .then(() => {
345 /* istanbul ignore next: We might be on either os when running the unit tests. */
346 if (os.platform() !== 'win32') {
347 this.logger.break();
348 }
349 return this;
350 });
351 }
352
353 /**
354 * @param {string} caption
355 * @param {Error|ErrorWithInnerError|ErrorWithSolution|InputError} error
356 * @param {Object} [debugVariables]
357 */
358 error(caption, error, debugVariables) {
359 this.logger.destroyAllSpinners();
360
361 if (
362 error instanceof this.logger.ErrorWithInnerError ||
363 error instanceof this.logger.ErrorWithSolution
364 ) {
365 this.logger.caption('Error');
366 this.logger.error(error.message);
367 if (error.solution) {
368 this.logger.break();
369 this.logger.notice(error.solution);
370 }
371 if (!this.hideStacktraceOnErrors) {
372 if (error.innerError) {
373 this.logger.indent();
374 this.logger.caption('Inner error');
375 this.logger.debug(
376 error.innerError.stack || error.innerError
377 );
378 this.logger.outdent();
379 }
380 if (debugVariables) {
381 this.logger.properties(debugVariables);
382 }
383 }
384 return;
385 }
386 if (
387 error instanceof this.cli.InputError ||
388 error instanceof this.logger.InputError
389 ) {
390 this.logger.caption('Input error');
391 this.logger.error(error.message);
392 this.logger.break();
393 this.logger.notice(
394 'You might be able to fix this, use the "--help" flag for usage info.'
395 );
396 if (error.solution) {
397 this.logger.log(error.solution);
398 }
399 if (!this.hideStacktraceOnErrors && debugVariables) {
400 this.logger.properties(debugVariables);
401 }
402 return;
403 }
404
405 this.logger.caption(caption || 'Error');
406
407 if (error) {
408 this.logger.error(error.message || error.stack || error);
409 }
410
411 if (error.solution) {
412 this.logger.break();
413 this.logger.notice(error.solution);
414 }
415
416 if (!this.hideStacktraceOnErrors) {
417 if (error && error.stack) {
418 this.logger.indent();
419 this.logger.debug(error.stack);
420 this.logger.outdent();
421 }
422
423 if (debugVariables) {
424 this.logger.properties(debugVariables);
425 }
426 }
427 }
428}