How to Implement a Versatile Config for Your CLI with TypeScript

While working on a CLI project recently, I encountered the need for a robust configuration system. I wanted the ability to store a base configuration in the user’s home directory and allow overrides in the current working directory and any parent directories. Additionally, I needed the flexibility to support both JSON and YAML configuration files without relying on file extensions. This blog post details the solution I implemented, which you can easily adapt for your own CLI projects.

Thank me by sharing on Twitter 🙏

Versatile Config for Your CLI Overview

To achieve this, we’ll cover the following steps:

  1. Setting Up the Environment
  2. Loading and Parsing Configuration Files
  3. Recursively Merging Configurations
  4. Handling Multiple Configuration Formats
  5. Bringing It All Together

Let’s dive in!

1. Setting Up the Environment

Before diving into the code, ensure you have the necessary packages installed. You’ll need commander for CLI command parsing, fs for file system operations, path for handling file paths, os for accessing the user’s home directory, lodash-es for merging objects, and js-yaml for parsing YAML files. Here are the commands to install these packages:

ShellScript
npm install commander fs path os lodash-es js-yaml @types/lodash-es @types/js-yaml

With the dependencies in place, let’s move on to loading and parsing configuration files.

2. Loading and Parsing Configuration Files

The first step is to load configuration files and detect their format. We’ll create a function called loadConfig that reads a file and tries to parse it as JSON first, and if that fails, it attempts to parse it as YAML. Here’s how I implemented this function:

TypeScript
import * as fs from 'fs';
import * as yaml from 'js-yaml';

const loadConfig = (filePath: string): any => {
  if (fs.existsSync(filePath)) {
    const fileContent = fs.readFileSync(filePath, 'utf-8');
    try {
      return JSON.parse(fileContent);
    } catch (jsonError: unknown) {
      if (jsonError instanceof SyntaxError) {
        try {
          return yaml.load(fileContent);
        } catch (yamlError: unknown) {
          if (yamlError instanceof Error) {
            console.error(`Failed to parse config file: ${filePath}`);
            console.error(`YAML error: ${yamlError.message}`);
          }
        }
      } else if (jsonError instanceof Error) {
        console.error(`Failed to parse config file: ${filePath}`);
        console.error(`JSON error: ${jsonError.message}`);
      }
    }
  }
  return {};
};

This function checks if the file exists, reads its content, and then tries to parse it as JSON. If JSON parsing fails due to a SyntaxError, it attempts to parse the content as YAML. If both attempts fail, it logs the errors and returns an empty object.

3. Recursively Merging Configurations

Next, we need to recursively load configuration files from the current working directory up to the root directory. We’ll use a function called loadRecursiveConfig for this purpose. This function starts from a given directory, looks for a .myclirc file, and merges configurations as it traverses up the directory tree.

TypeScript
import * as path from 'path';
import { merge } from 'lodash-es';

const loadRecursiveConfig = (startPath: string): any => {
  let currentDir = startPath;
  let config = {};

  while (currentDir !== path.dirname(currentDir)) {
    const configPath = path.join(currentDir, '.myclirc');
    const currentConfig = loadConfig(configPath);
    config = merge(currentConfig, config);
    currentDir = path.dirname(currentDir);
  }

  return config;
};

The loadRecursiveConfig function initializes an empty configuration object and iteratively looks for .myclirc files in the current directory and each parent directory. The merge function from lodash-es ensures that configurations are combined correctly.

4. Handling Multiple Configuration Formats

To ensure the versatility of our configuration loader, we need to handle multiple formats seamlessly. The loadConfig function already detects and parses JSON and YAML files. Now, we need to incorporate this function into our CLI application, starting with loading the base configuration from the user’s home directory.

Here’s how I did it:

TypeScript
import * as os from 'os';

const homeConfigPath = path.join(os.homedir(), '.myclirc');
let finalConfig = loadConfig(homeConfigPath);

Next, we merge the configuration from the home directory with the recursively loaded configurations from the current working directory and its parents.

TypeScript
const cwd = process.cwd();
const recursiveConfig = loadRecursiveConfig(cwd);
finalConfig = merge(finalConfig, recursiveConfig);

console.log('Final Config:', finalConfig);

This ensures that configurations are merged in the correct order, with the current directory and parent configurations overriding the base configuration from the home directory.

5. Bringing It All Together

Now, let’s integrate everything into a single CLI script using the commander package to handle command parsing.

TypeScript
import { Command } from 'commander';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { merge } from 'lodash-es';
import * as yaml from 'js-yaml';

const program = new Command();

// Define the command and options
program
  .version('1.0.0')
  .description('CLI with recursive config loading');

// Parse the arguments
program.parse(process.argv);

// Function to detect and load config from a given file content
const loadConfig = (filePath: string): any => {
  if (fs.existsSync(filePath)) {
    const fileContent = fs.readFileSync(filePath, 'utf-8');
    try {
      return JSON.parse(fileContent);
    } catch (jsonError: unknown) {
      if (jsonError instanceof SyntaxError) {
        try {
          return yaml.load(fileContent);
        } catch (yamlError: unknown) {
          if (yamlError instanceof Error) {
            console.error(`Failed to parse config file: ${filePath}`);
            console.error(`YAML error: ${yamlError.message}`);
          }
        }
      } else if (jsonError instanceof Error) {
        console.error(`Failed to parse config file: ${filePath}`);
        console.error(`JSON error: ${jsonError.message}`);
      }
    }
  }
  return {};
};

// Function to recursively load config from current directory up to root
const loadRecursiveConfig = (startPath: string): any => {
  let currentDir = startPath;
  let config = {};

  while (currentDir !== path.dirname(currentDir)) {
    const configPath = path.join(currentDir, '.myclirc');
    const currentConfig = loadConfig(configPath);
    config = merge(currentConfig, config);
    currentDir = path.dirname(currentDir);
  }

  return config;
};

// Load the config from the user's home directory
const homeConfigPath = path.join(os.homedir(), '.myclirc');
let finalConfig = loadConfig(homeConfigPath);

// Load and merge the config from the current working directory and upwards
const cwd = process.cwd();
const recursiveConfig = loadRecursiveConfig(cwd);
finalConfig = merge(finalConfig, recursiveConfig);

console.log('Final Config:', finalConfig);

With this setup, the CLI application can now detect and parse configuration files in both JSON and YAML formats, merge configurations from the home directory and recursively from the current working directory and its parents.

Conclusion

Creating a versatile configuration loader for your CLI in TypeScript involves detecting multiple file formats and recursively merging configurations from various directories. By using fs, path, os, lodash-es, and js-yaml, we can build a robust solution that handles configuration files efficiently.

This approach ensures that your CLI applications are flexible and can adapt to different user needs, providing a seamless configuration experience. I hope this guide helps you implement a similar solution in your own projects. Happy coding!

Share this:

Leave a Reply