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.

582 lines (520 loc) 18.1 kB
/** @packageDocumentation Easily manage configuration files for CLI applications, supports recursion, different root paths and transformation. */ import path from 'path'; import os from 'os'; import appRootPath from 'app-root-path'; import fs, { mkdirSync } from 'fs'; /** * The valid file extensions for configuration files. * * Supports JSON, JavaScript, and TypeScript files. * * TODO: Add support for YAML files. * * @typedef {'json' | 'js' | 'ts'} ValidExtensions */ export type ValidExtensions = 'json' | 'js' | 'ts'; // | 'yaml' | 'yml'; /** * The behaviour to use when handling recursive configuration files. * * @typedef {'no_recursion' | 'prefer_highest' | 'prefer_lowest' | 'merge_highest_first' | 'merge_lowest_first'} ConfigFileRecursionBehaviour * * @example * no_recursion - Only look in the current directory for the configuration file. * prefer_highest - Look in the current directory and all parent directories, and use the configuration file found in the highest directory. * prefer_lowest - Look in the current directory and all parent directories, and use the configuration file found in the lowest directory. * merge_highest_first - Look in the current directory and all parent directories, and merge the configuration files found in the highest directories first. * merge_lowest_first - Look in the current directory and all parent directories, and merge the configuration files found in the lowest directories first. */ export type ConfigFileRecursionBehaviour = | 'no_recursion' | 'prefer_highest' | 'prefer_lowest' | 'merge_highest_first' | 'merge_lowest_first'; /** * The root to use when looking for configuration files. * @typedef {'cwd' | 'home' | 'project_root' | 'workspace_root'} ConfigFileRoot * * @example * cwd - Look in the current working directory for the configuration file. * home - Look in the user's home directory for the configuration file. * project_root - Look up the directory tree for the first package.json file and use that directory as the root. * workspace_root - Look up the directory tree for the first package.json file with workspaces and use that directory as the root. * * Otherwise, you can provide a custom path to start looking for the configuration file. * */ export type ConfigFileRoot = 'cwd' | 'home' | 'project_root' | 'workspace_root'; /** * Where to stop looking for the configuration file. * * @typedef {'project_root' | 'workspace_root'} ConfigFileRoot * * @example * project_root - Look up the directory tree for the first package.json file and use that directory; * workspace_root - Look up the directory tree for the first package.json file with workspaces and use that directory as the root. * * Otherwise, you can provide a custom path to start looking for the configuration file. * */ export type ConfigFileRootTop = 'project_root' | 'workspace_root'; /** * Simple object check. * @param item * @returns {boolean} */ export function isObject(item: any) { return item && typeof item === 'object' && !Array.isArray(item); } /** * Deep merge two objects. * @param target * @param ...sources */ export function mergeDeep(...sources: any[]): any { 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); } /** * The parameters to use when loading a configuration file. * * @interface {Object} ConfigFileParams * * @property {string} filename The base filename to look for, without the extension. * @property {ValidExtensions[]} extensions What file extensions to look for, in order of preference * @property {ConfigFileRecursionBehaviour} [recursion] How to handle recursive config files * @property {ConfigFileRoot} [root] Where to start looking for the config file * @property {string} [path] The path to search for the config file * @property {boolean} [failOnMissing] Whether or not to fail if the file is missing * * @example * ```typescript * { * filename: 'config', * extensions: ['json', 'js', 'ts'], * recursion: 'prefer_highest', * root: 'cwd', * path: '', * failOnMissing: false, * } * ``` */ export type ConfigFileParams< TParams extends Record<string, any> = Record<string, any> > = { filename: string; // THe base filename to look for, without the extension. extensions: ValidExtensions[]; // What file extensions to look for, in order of preference (Default: ['ts', 'js', 'json']) recursion?: ConfigFileRecursionBehaviour; // How to handle recursive config files (Default: 'no_recursion') root?: ConfigFileRoot; // Where to start looking for the config file (Default: 'cwd') top?: ConfigFileRootTop; // Where to stop looking for the config file (Default: 'project_root') path?: string; // The path to search for the config file from the basepath (Default: '') failOnMissing?: boolean; // Whether or not to fail if the file is missing saveTransform?: ( config: TParams | Record<string, TParams> ) => TParams | Record<string, TParams>; // A function to transform the configuration before saving loadTransform?: ( config: TParams | Record<string, TParams>, argv: Partial<TParams> ) => TParams; // A function to transform the configuration after loading // validator?: any; // TODO: ADD ZOD VALIDATOR }; // 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(); * ``` */ export class EasyCLIConfigFile< TParams extends Record<string, any> = Record<string, any> > { private filename: string; private extensions: ValidExtensions[]; private recursion: ConfigFileRecursionBehaviour; private root: ConfigFileRoot; private top: ConfigFileRootTop; private path: string; private failOnMissing: boolean; private saveTransform?: ( config: TParams | Record<string, TParams> ) => TParams | Record<string, TParams>; // A function to transform the configuration before saving loadTransform?: ( config: TParams | Record<string, TParams>, argv: Partial<TParams> ) => TParams; // A function to transform the configuration after loading /** * 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, }: ConfigFileParams<TParams>) { 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; } private findProjectRoot( dir: string, workspace: boolean = false ): string | null { 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, '..'); } } private getTopPath(): string | null { 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 */ private mergeConfigurationSets<TParams extends Record<string, any>>( configs: TParams[] ): TParams { return configs.reduce((acc, config) => { return { ...acc, ...config, }; }, {} as TParams); } /** * 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 */ private processConfigurationFileInDir( dir: string, argv: Partial<TParams> ): TParams | null { for (const extension of this.extensions) { try { const resolvedPath = path.resolve( dir, this.path ?? '', `${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 */ private loadConfigurationTree( dir: string, abortOnFirst: boolean = false, argv: Partial<TParams> ): TParams[] { const configs: TParams[] = []; let currentDir = path.resolve(dir); const topPath = this.getTopPath(); while (true) { const config = this.processConfigurationFileInDir(currentDir, argv); if (config) { configs.push(config as TParams); } // 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 */ private loadConfigurationFromPath( path: string, argv: Partial<TParams> ): TParams { 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 (this.processConfigurationFileInDir(path, argv) ?? {}) as TParams; } // 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 {} as TParams; } switch (recursion) { case 'prefer_lowest': return configs.shift() as TParams; case 'prefer_highest': return configs.pop() as TParams; case 'merge_highest_first': return this.mergeConfigurationSets(configs) as TParams; case 'merge_lowest_first': return this.mergeConfigurationSets(configs.reverse()) as TParams; } return {} as TParams; } /** * Generate the base path to use when looking for configuration files. * * @returns {string} The base path to use when looking for configuration files */ private getBasePath(): string { 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 */ private findConfigurationFile(filePath?: string): string { if (filePath) { return path.resolve(filePath); } return path.resolve( this.getBasePath(), this.path ?? '', `${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 */ private loadSpecificConfiguration( file: string, argv: Partial<TParams> ): TParams { 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 {} as TParams; } /** * 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'); * ``` */ public load(path?: string, argv: Partial<TParams> = {}): TParams { 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); * ``` */ public fileExists(filePath?: string): boolean { 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', * }); * ``` */ public async save(config: TParams, filePath?: string): Promise<void> { 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 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; } } }