UNPKG

easy-cli-framework

Version:

A framework for building CLI applications that are robust and easy to maintain. Supports theming, configuration files, interactive prompts, and more.

380 lines (376 loc) 13.6 kB
'use strict'; var path = require('path'); var os = require('os'); var appRootPath = require('app-root-path'); var fs = require('fs'); /** @packageDocumentation Easily manage configuration files for CLI applications, supports recursion, different root paths and transformation. */ /** * Simple object check. * @param item * @returns {boolean} */ function isObject(item) { return item && typeof item === 'object' && !Array.isArray(item); } /** * Deep merge two objects. * @param target * @param ...sources */ function mergeDeep(...sources) { const target = sources.shift(); if (!sources.length) return target; const source = sources.shift(); if (isObject(target) && isObject(source)) { for (const key in source) { if (isObject(source[key])) { if (!target[key]) Object.assign(target, { [key]: {} }); mergeDeep(target[key], source[key]); } else { Object.assign(target, { [key]: source[key] }); } } } return mergeDeep(target, ...sources); } // TODO: Build out Defined ConfigFileTypes that can be used to validate the configuration files. ie. No recursion with a home dir root. /** * @class EasyCLIConfigFile * * A class to make it easier to load configuration files in a consistent way, with support for different file extensions, recursion, and root directories. * * @template TParams A type representing the configuration object that will be managed by the configuration file. * * @example * ```typescript * const config = new EasyCLIConfigFile({ * filename: 'config', * extensions: ['json', 'js', 'ts'], * recursion: 'prefer_highest', * root: 'cwd', * }); * * const configuration = config.load(); * ``` */ class EasyCLIConfigFile { /** * Create a new configuration file handler instance. * * @param {ConfigFileParams} params The parameters to use when loading the configuration file * * @returns {EasyCLIConfigFile} The EasyCLIConfigFile instance */ constructor({ filename, extensions = ['ts', 'js', 'json'], recursion = 'no_recursion', root = 'cwd', top = 'project_root', path = '', failOnMissing = false, saveTransform, loadTransform, }) { this.filename = filename; this.extensions = extensions; this.recursion = recursion; this.root = root; this.path = path; this.failOnMissing = failOnMissing; this.top = top; this.saveTransform = saveTransform; this.loadTransform = loadTransform; } findProjectRoot(dir, workspace = false) { let currentDir = path.resolve(dir); while (true) { if (fs.existsSync(path.resolve(currentDir, 'package.json'))) { if (!workspace) return currentDir; const packageJson = require(path.resolve(currentDir, 'package.json')); if (packageJson.workspaces) return currentDir; } if (currentDir === path.resolve(currentDir, '..')) { return null; } currentDir = path.resolve(currentDir, '..'); } } getTopPath() { switch (this.top) { case 'project_root': return this.findProjectRoot(process.cwd(), false); case 'workspace_root': return this.findProjectRoot(process.cwd(), true); default: return null; } } /** * Merge a list of configuration objects into a single object. * * @param {TParams[]} configs A sorted list of configuration objects to merge * * @returns {TParams} The merged configuration object */ mergeConfigurationSets(configs) { return configs.reduce((acc, config) => { return { ...acc, ...config, }; }, {}); } /** * Check the current directory for a configuration file with the given filename and extensions. * * @param dir The directory to check for the configuration file * * @returns {TParams | null} The configuration object if found, or null if not found */ processConfigurationFileInDir(dir, argv) { var _a; for (const extension of this.extensions) { try { const resolvedPath = path.resolve(dir, (_a = this.path) !== null && _a !== void 0 ? _a : '', `${this.filename}.${extension}`); if (!fs.existsSync(resolvedPath)) continue; switch (extension) { case 'json': case 'js': case 'ts': const data = require(resolvedPath); return this.loadTransform ? this.loadTransform(data, argv) : data; // TODO: Add support for yaml and yml default: throw new Error('Unsupported file extension'); } } catch (e) { continue; } } return null; } /** * Loads all configuration objects found in the given directory and all parent directories depending on the recursion behaviour. * * @param {string} dir The directory to start looking in * @param {boolean} abortOnFirst Only return the first configuration found * * @returns {TParams[]} A list of configuration objects found in the directories */ loadConfigurationTree(dir, abortOnFirst = false, argv) { const configs = []; let currentDir = path.resolve(dir); const topPath = this.getTopPath(); while (true) { const config = this.processConfigurationFileInDir(currentDir, argv); if (config) { configs.push(config); } // If we are aborting on the first config, once we've found one, we can stop. if (abortOnFirst && configs.length) { break; } // If we have a top path, we should stop once we reach it. if (currentDir === topPath) { break; } if (currentDir === os.homedir()) { break; } if (currentDir === path.resolve(currentDir, '..')) { break; } currentDir = path.resolve(currentDir, '..'); } return configs; } /** * Load a configuration object from the given path. * * @param path The path to load the configuration from * * @returns {TParams} The loaded configuration object */ loadConfigurationFromPath(path, argv) { var _a; const { filename, recursion = 'prefer_lowest' } = this; if (!filename) throw new Error('No filename provided in config file params'); // When there is no recursion, we only need to look in the current directory. if (recursion === 'no_recursion') { return ((_a = this.processConfigurationFileInDir(path, argv)) !== null && _a !== undefined ? _a : {}); } // If we are recursing, we need to look in the current directory and all parent directories. const configs = this.loadConfigurationTree(path, recursion === 'prefer_lowest', argv); if (!configs.length) { if (this.failOnMissing) throw new Error('No configuration file found'); return {}; } switch (recursion) { case 'prefer_lowest': return configs.shift(); case 'prefer_highest': return configs.pop(); case 'merge_highest_first': return this.mergeConfigurationSets(configs); case 'merge_lowest_first': return this.mergeConfigurationSets(configs.reverse()); } return {}; } /** * Generate the base path to use when looking for configuration files. * * @returns {string} The base path to use when looking for configuration files */ getBasePath() { switch (this.root) { case 'home': return os.homedir(); case 'project_root': return appRootPath.toString(); case 'cwd': default: return process.cwd(); } } /** * Find the configuration file to use. * * @param {string} filePath An optional file path to use instead of the default otherwise it will scan using the given rules. * * @returns {string} The path to the configuration file */ findConfigurationFile(filePath) { var _a; if (filePath) { return path.resolve(filePath); } return path.resolve(this.getBasePath(), (_a = this.path) !== null && _a !== undefined ? _a : '', `${this.filename}.${this.extensions[0]}`); } /** * Load a configuration file from a specific path instead of using the default path. * * @param {string} path A specific path to load the configuration from * @returns {TParams} The configuration object loaded from the path */ loadSpecificConfiguration(file, argv) { const resolvedPath = path.resolve(file); if (fs.existsSync(resolvedPath)) { const data = require(resolvedPath); return this.loadTransform ? this.loadTransform(data, argv) : data; } if (this.failOnMissing) { throw new Error(`Configuration file not found at ${file}`); } return {}; } /** * Load a configuration file from the given rules. Can be overridden by providing a path. * * @param {string} path An optional path override to use when loading the configuration file, otherwise it will use the default path. * @returns {TParams} The loaded configuration object * * @example * ```typescript * const config = new EasyCLIConfigFile({ * filename: 'config', * extensions: ['json', 'js', 'ts'], * recursion: 'prefer_highest', * root: 'cwd', * }); * * // Loads the configuration file from the default path, finding the highest configuration file. * const configuration = config.load(); * * // Loads the configuration file from the given path. Does not use the default path. * const configuration = config.load('path/to/config.json'); * ``` */ load(path, argv = {}) { if (path) return this.loadSpecificConfiguration(path, argv); return this.loadConfigurationFromPath(this.getBasePath(), argv); } /** * Check if a configuration file exists. * * @param {string} filePath An optional file path to use instead of the default otherwise it will scan using the given rules. * @returns {boolean} Whether or not the configuration file exists * * @example * ```typescript * const config = new EasyCLIConfigFile({ * filename: 'config', * extensions: ['json', 'js', 'ts'], * recursion: 'prefer_highest', * root: 'cwd', * }); * * // Check if the configuration file exists * const exists = config.fileExists(); * console.log(exists); * * // Check if a specific configuration file exists * const exists = config.fileExists('path/to/config.json'); * console.log(exists); * ``` */ fileExists(filePath) { const resolvedPath = this.findConfigurationFile(filePath); return fs.existsSync(resolvedPath); } /** * Save a configuration object to a file, using the given rules or an optional file path. * * @param {TParams} config The configuration object to save * @param {string} filePath The file path to save the configuration to * * @returns {Promise<void>} * * @example * ```typescript * const config = new EasyCLIConfigFile({ * filename: 'config', * extensions: ['json', 'js', 'ts'], * recursion: 'prefer_highest', * root: 'cwd', * }); * * // Save the configuration file * await config.save({ * var1: 'value1', * var2: 'value2', * }); * ``` */ async save(config, filePath) { if (!this.filename) throw new Error('No filename provided in config file params'); const extension = this.extensions[0]; if (this.path) { // Ensure the directory exists await fs.mkdirSync(path.resolve(this.getBasePath(), this.path), { recursive: true, }); } const resolvedPath = this.findConfigurationFile(filePath); let existing = {}; if (fs.existsSync(resolvedPath)) { existing = require(resolvedPath); } // Don't override the existing configuration, merge it. const merged = mergeDeep(existing, this.saveTransform ? this.saveTransform(config) : config); switch (extension) { case 'json': await fs.writeFileSync(resolvedPath, JSON.stringify(merged, null, 2)); break; case 'js': case 'ts': await fs.writeFileSync(resolvedPath, `module.exports = ${JSON.stringify(merged, null, 2)};`); break; } } } exports.EasyCLIConfigFile = EasyCLIConfigFile; exports.isObject = isObject; exports.mergeDeep = mergeDeep;