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
JavaScript
'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;