DEV Community

Wilson Xu
Wilson Xu

Posted on

The Right Way to Handle Config Files in Node.js CLI Tools

The Right Way to Handle Config Files in Node.js CLI Tools

Every CLI tool eventually needs configuration. Users want defaults they don't have to type every time. Teams want shared settings across projects. CI pipelines want declarative configs checked into version control.

But getting config right is surprisingly tricky. Where should the config file live? What format should it use? How do you merge file config with CLI flags? What about environment variables?

This article shows you how to build a robust config system that handles all of these cases — using patterns borrowed from ESLint, Prettier, and other battle-tested CLI tools.

The Config Hierarchy

Good CLI tools resolve configuration from multiple sources, in priority order:

  1. CLI flags (highest priority) — --threshold 90
  2. Environment variablesMYTOOL_THRESHOLD=90
  3. Local config file.mytoolrc.json in the project
  4. User config file~/.config/mytool/config.json
  5. Built-in defaults (lowest priority)

Each level overrides the one below it. This lets users set sensible defaults while allowing per-project and per-command overrides.

Step 1: Config File Discovery

Follow the pattern used by ESLint and Prettier — walk up the directory tree looking for config files:

// lib/config.js
import { readFile, access } from 'node:fs/promises';
import { join, dirname, parse } from 'node:path';
import { homedir } from 'node:os';

const CONFIG_NAMES = [
  '.mytoolrc',
  '.mytoolrc.json',
  '.mytoolrc.yaml',
  '.mytoolrc.yml',
  'mytool.config.json',
  'mytool.config.js',
];

async function findConfigFile(startDir) {
  let dir = startDir;

  while (true) {
    for (const name of CONFIG_NAMES) {
      const filePath = join(dir, name);
      try {
        await access(filePath);
        return filePath;
      } catch {}
    }

    // Also check package.json for a "mytool" key
    try {
      const pkg = JSON.parse(await readFile(join(dir, 'package.json'), 'utf-8'));
      if (pkg.mytool) {
        return { embedded: true, config: pkg.mytool, source: join(dir, 'package.json') };
      }
    } catch {}

    const parent = dirname(dir);
    if (parent === dir) break; // Reached root
    dir = parent;
  }

  return null;
}
Enter fullscreen mode Exit fullscreen mode

This approach means your tool automatically picks up the right config regardless of which subdirectory the user runs it from — just like git finds .gitignore files.

Step 2: Parse Multiple Formats

Support JSON, YAML, and JavaScript configs:

async function parseConfigFile(filePath) {
  if (typeof filePath === 'object' && filePath.embedded) {
    return { config: filePath.config, source: filePath.source };
  }

  const content = await readFile(filePath, 'utf-8');
  const ext = filePath.split('.').pop();

  let config;

  if (ext === 'json' || filePath.endsWith('.mytoolrc')) {
    try {
      config = JSON.parse(content);
    } catch (e) {
      // Try JSONC (JSON with comments)
      const stripped = content.replace(/\/\/.*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
      config = JSON.parse(stripped);
    }
  } else if (ext === 'yaml' || ext === 'yml') {
    // Minimal YAML parsing for simple configs
    config = parseSimpleYaml(content);
  } else if (ext === 'js' || ext === 'mjs') {
    const module = await import(filePath);
    config = module.default || module;
  }

  return { config, source: filePath };
}

function parseSimpleYaml(content) {
  const result = {};
  for (const line of content.split('\n')) {
    const match = line.match(/^(\w+):\s*(.+)/);
    if (match) {
      let value = match[2].trim();
      if (value === 'true') value = true;
      else if (value === 'false') value = false;
      else if (/^\d+$/.test(value)) value = parseInt(value);
      else if (/^\d+\.\d+$/.test(value)) value = parseFloat(value);
      else value = value.replace(/^['"]|['"]$/g, '');
      result[match[1]] = value;
    }
  }
  return result;
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Environment Variable Mapping

Map environment variables to config keys with a consistent prefix:

function getEnvConfig(prefix = 'MYTOOL') {
  const config = {};

  for (const [key, value] of Object.entries(process.env)) {
    if (!key.startsWith(`${prefix}_`)) continue;

    const configKey = key
      .slice(prefix.length + 1)
      .toLowerCase()
      .replace(/_([a-z])/g, (_, c) => c.toUpperCase());

    // Type coercion
    if (value === 'true') config[configKey] = true;
    else if (value === 'false') config[configKey] = false;
    else if (/^\d+$/.test(value)) config[configKey] = parseInt(value);
    else config[configKey] = value;
  }

  return config;
}
Enter fullscreen mode Exit fullscreen mode

This converts MYTOOL_MAX_ERRORS=5 to { maxErrors: 5 } automatically.

Step 4: Merge Everything Together

const DEFAULTS = {
  threshold: 80,
  maxErrors: 0,
  format: 'text',
  verbose: false,
  timeout: 30000,
};

export async function resolveConfig(cliOptions = {}) {
  // 1. Start with defaults
  let config = { ...DEFAULTS };

  // 2. User-level config (~/.config/mytool/config.json)
  try {
    const userConfigPath = join(homedir(), '.config', 'mytool', 'config.json');
    const userConfig = JSON.parse(await readFile(userConfigPath, 'utf-8'));
    config = { ...config, ...userConfig };
  } catch {}

  // 3. Project-level config (walk up directories)
  const configResult = await findConfigFile(process.cwd());
  if (configResult) {
    const { config: fileConfig, source } = await parseConfigFile(configResult);
    config = { ...config, ...fileConfig };
    config._source = source;
  }

  // 4. Environment variables
  const envConfig = getEnvConfig();
  config = { ...config, ...envConfig };

  // 5. CLI flags (highest priority)
  // Remove undefined values from CLI options
  const cleanCli = Object.fromEntries(
    Object.entries(cliOptions).filter(([_, v]) => v !== undefined)
  );
  config = { ...config, ...cleanCli };

  return config;
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Config Validation

Don't trust user config — validate it:

const SCHEMA = {
  threshold: { type: 'number', min: 0, max: 100 },
  maxErrors: { type: 'number', min: 0 },
  format: { type: 'string', enum: ['text', 'json', 'table'] },
  verbose: { type: 'boolean' },
  timeout: { type: 'number', min: 1000, max: 300000 },
};

export function validateConfig(config) {
  const errors = [];

  for (const [key, rules] of Object.entries(SCHEMA)) {
    const value = config[key];
    if (value === undefined) continue;

    if (typeof value !== rules.type) {
      errors.push(`"${key}" must be a ${rules.type}, got ${typeof value}`);
      continue;
    }

    if (rules.min !== undefined && value < rules.min) {
      errors.push(`"${key}" must be >= ${rules.min}, got ${value}`);
    }
    if (rules.max !== undefined && value > rules.max) {
      errors.push(`"${key}" must be <= ${rules.max}, got ${value}`);
    }
    if (rules.enum && !rules.enum.includes(value)) {
      errors.push(`"${key}" must be one of: ${rules.enum.join(', ')}`);
    }
  }

  return errors;
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Generate Config Files

Help users create config files with an init command:

program
  .command('init')
  .description('Create a config file')
  .option('--format <format>', 'Config format: json, yaml', 'json')
  .action(async (options) => {
    const filename = options.format === 'yaml' ? '.mytoolrc.yaml' : '.mytoolrc.json';
    const content = options.format === 'yaml'
      ? `threshold: 80\nmaxErrors: 0\nformat: text\nverbose: false\n`
      : JSON.stringify(DEFAULTS, null, 2) + '\n';

    await writeFile(filename, content);
    console.log(chalk.green(`Created ${filename}`));
  });
Enter fullscreen mode Exit fullscreen mode

Using the Config System

program
  .command('audit <url>')
  .option('-t, --threshold <n>', 'Minimum score', parseInt)
  .option('--format <fmt>', 'Output format')
  .option('-v, --verbose', 'Verbose output')
  .action(async (url, cliOptions) => {
    const config = await resolveConfig(cliOptions);

    const errors = validateConfig(config);
    if (errors.length > 0) {
      errors.forEach(e => console.error(chalk.red(`  Config error: ${e}`)));
      if (config._source) {
        console.error(chalk.gray(`  Config loaded from: ${config._source}`));
      }
      process.exit(2);
    }

    if (config.verbose) {
      console.error(chalk.gray(`  Config: ${JSON.stringify(config, null, 2)}`));
    }

    // Use config.threshold, config.format, etc.
  });
Enter fullscreen mode Exit fullscreen mode

Conclusion

A well-designed config system makes your CLI tool feel professional. Users can set it and forget it, teams can share configs, and CI pipelines can work declaratively. The hierarchy pattern — defaults → user config → project config → env vars → CLI flags — is the same one used by ESLint, Prettier, and every other tool developers trust.


Wilson Xu builds Node.js CLI tools focused on developer experience. Find his work at dev.to/chengyixu and npm.

Top comments (0)