UNPKG

envkeeper

Version:

đŸ›Ąī¸ The most human-friendly environment variable validator for Node.js - Beautiful error messages with actionable suggestions that actually help you fix issues

565 lines (484 loc) â€ĸ 17.4 kB
// ANSI color codes for better terminal output const colors = { reset: '\x1b[0m', bright: '\x1b[1m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', cyan: '\x1b[36m', gray: '\x1b[90m', }; // Helper to format colored text const colorize = (text, color) => { // Only colorize if output is a terminal (not being piped) if (process.stdout.isTTY) { return `${color}${text}${colors.reset}`; } return text; }; class EnvKeeperError extends Error { constructor(message, missing = [], invalid = [], suggestions = []) { super(message); this.name = 'EnvKeeperError'; this.missing = missing; this.invalid = invalid; this.suggestions = suggestions; } /** * Get a formatted, human-friendly error message */ toFormattedString() { let output = []; // Header output.push('\n' + colorize('━'.repeat(60), colors.red)); output.push(colorize('❌ Environment Variable Error', colors.bright + colors.red)); output.push(colorize('━'.repeat(60), colors.red) + '\n'); // Main message output.push(colorize(this.message, colors.red)); output.push(''); // Missing variables if (this.missing.length > 0) { output.push(colorize('Missing Variables:', colors.bright + colors.yellow)); this.missing.forEach(varName => { output.push(` ${colorize('â€ĸ', colors.yellow)} ${colorize(varName, colors.bright)}`); }); output.push(''); } // Invalid variables with details if (this.invalid.length > 0) { output.push(colorize('Invalid Variables:', colors.bright + colors.yellow)); this.invalid.forEach(item => { output.push(` ${colorize('â€ĸ', colors.yellow)} ${colorize(item.name, colors.bright)}`); output.push(` ${colorize('Current:', colors.gray)} ${item.value}`); output.push(` ${colorize('Expected:', colors.cyan)} ${item.expected}`); output.push(` ${colorize('Reason:', colors.gray)} ${item.reason}`); }); output.push(''); } // Suggestions if (this.suggestions.length > 0) { output.push(colorize('💡 Suggestions:', colors.bright + colors.cyan)); this.suggestions.forEach(suggestion => { output.push(` ${colorize('→', colors.cyan)} ${suggestion}`); }); output.push(''); } // Footer output.push(colorize('━'.repeat(60), colors.red)); output.push(colorize('â„šī¸ Need help? Check the documentation:', colors.gray)); output.push(colorize(' https://github.com/anandanpm/env-guard#readme', colors.blue)); output.push(colorize('━'.repeat(60), colors.red)); return output.join('\n'); } } class EnvKeeper { constructor() { // Store custom validators this.customValidators = new Map(); } /** * Register a custom validator type * @param {string} typeName - Name of the custom type * @param {Function} validator - Validation function that returns boolean * @param {string} errorMessage - Error message template (use {value} placeholder) * @example * envKeeper.addCustomValidator('phone', * (value) => /^\d{10}$/.test(value), * '"{value}" is not a valid 10-digit phone number. Example: 1234567890' * ); */ addCustomValidator(typeName, validator, errorMessage) { if (typeof typeName !== 'string' || !typeName) { throw new Error('Type name must be a non-empty string'); } if (typeof validator !== 'function') { throw new Error('Validator must be a function'); } this.customValidators.set(typeName.toLowerCase(), { validate: validator, errorMessage: errorMessage || `"{value}" does not match the required format for type "${typeName}"` }); return this; } /** * Register multiple custom validators at once * @param {Object} validators - Object with type names as keys and validator configs as values * @example * envKeeper.addCustomValidators({ * phone: { * validate: (value) => /^\d{10}$/.test(value), * errorMessage: 'Must be 10 digits' * }, * zipcode: { * validate: (value) => /^\d{5}$/.test(value), * errorMessage: 'Must be 5 digits' * } * }); */ addCustomValidators(validators) { if (typeof validators !== 'object' || validators === null) { throw new Error('Validators must be an object'); } for (const [typeName, config] of Object.entries(validators)) { if (typeof config === 'function') { this.addCustomValidator(typeName, config); } else if (typeof config === 'object' && config.validate) { this.addCustomValidator(typeName, config.validate, config.errorMessage); } else { throw new Error(`Invalid validator config for type "${typeName}"`); } } return this; } /** * Remove a custom validator * @param {string} typeName - Name of the custom type to remove */ removeCustomValidator(typeName) { this.customValidators.delete(typeName.toLowerCase()); return this; } /** * Get list of all registered custom validators * @returns {string[]} Array of custom validator names */ getCustomValidators() { return Array.from(this.customValidators.keys()); } /** * Require specific environment variables to be set * @param {string[]} vars - Array of required environment variable names * @throws {EnvKeeperError} If any variables are missing */ require(vars) { if (!Array.isArray(vars)) { const suggestions = [ 'Pass variables as an array: envKeeper.require(["VAR1", "VAR2"])', 'Example: envKeeper.require(["DATABASE_URL", "API_KEY"])' ]; throw new EnvKeeperError( 'Invalid input: Variables must be provided as an array', [], [], suggestions ); } const missing = vars.filter(varName => { const value = process.env[varName]; return value === undefined || value === ''; }); if (missing.length > 0) { const suggestions = this._generateSuggestions(missing); const message = `🚨 Required environment variables are not set!\n\nYour application needs these variables to run properly.`; const error = new EnvKeeperError(message, missing, [], suggestions); // Override toString to show formatted message error.toString = () => error.toFormattedString(); throw error; } return true; } /** * Validate environment variables with type checking * @param {Object} schema - Validation schema * @throws {EnvKeeperError} If validation fails */ validate(schema) { if (typeof schema !== 'object' || schema === null) { const suggestions = [ 'Pass a schema object: envKeeper.validate({ VAR: "type" })', 'Example: envKeeper.validate({ PORT: "port", API_URL: "url" })' ]; throw new EnvKeeperError( 'Invalid input: Schema must be an object', [], [], suggestions ); } const missing = []; const invalid = []; for (const [varName, validation] of Object.entries(schema)) { const value = process.env[varName]; // Check if variable exists if (value === undefined || value === '') { missing.push(varName); continue; } // Normalize validation to object format const rules = typeof validation === 'string' ? { type: validation } : validation; // Validate type if (rules.type) { const isValid = this._validateType(value, rules.type); if (!isValid) { invalid.push({ name: varName, value: this._truncateValue(value), expected: `type "${rules.type}"`, reason: this._getTypeErrorReason(rules.type, value) }); continue; } } // Validate additional rules if (rules.minLength && value.length < rules.minLength) { invalid.push({ name: varName, value: this._truncateValue(value), expected: `minimum length of ${rules.minLength} characters`, reason: `Current length is ${value.length}, but minimum ${rules.minLength} characters required` }); } if (rules.maxLength && value.length > rules.maxLength) { invalid.push({ name: varName, value: this._truncateValue(value), expected: `maximum length of ${rules.maxLength} characters`, reason: `Current length is ${value.length}, but maximum allowed is ${rules.maxLength} characters` }); } if (rules.pattern && !new RegExp(rules.pattern).test(value)) { invalid.push({ name: varName, value: this._truncateValue(value), expected: `pattern: ${rules.pattern}`, reason: `Value does not match the required pattern format` }); } if (rules.enum && !rules.enum.includes(value)) { invalid.push({ name: varName, value: this._truncateValue(value), expected: `one of: ${rules.enum.join(', ')}`, reason: `Must be one of the allowed values: ${rules.enum.join(', ')}` }); } } if (missing.length > 0 || invalid.length > 0) { const suggestions = []; if (missing.length > 0) { suggestions.push(...this._generateSuggestions(missing)); } if (invalid.length > 0) { suggestions.push('Review your .env file and ensure all values match the expected format'); suggestions.push('Check the documentation for the correct format of each variable'); } let message = '🚨 Environment variable validation failed!'; if (missing.length > 0) { message += '\n\nSome required variables are missing.'; } if (invalid.length > 0) { message += '\n\nSome variables have invalid values.'; } const error = new EnvKeeperError(message, missing, invalid, suggestions); error.toString = () => error.toFormattedString(); throw error; } return true; } /** * Get environment variable with default value and optional validation * @param {string} varName - Environment variable name * @param {*} defaultValue - Default value if not set * @param {string|Object} validation - Optional validation rules */ get(varName, defaultValue = null, validation = null) { let value = process.env[varName]; if (value === undefined || value === '') { if (defaultValue === null) { const suggestions = [ `Add ${varName} to your .env file`, 'Or provide a default value: envKeeper.get("' + varName + '", "default_value")', 'Check if the variable name is spelled correctly' ]; const error = new EnvKeeperError( `🚨 Required environment variable "${varName}" is not set!`, [varName], [], suggestions ); error.toString = () => error.toFormattedString(); throw error; } value = String(defaultValue); } if (validation) { try { this.validate({ [varName]: validation }); } catch (error) { // Re-throw with the actual value (including default) process.env[varName] = value; this.validate({ [varName]: validation }); } } return value; } /** * Check if environment variables are set without throwing * @param {string[]} vars - Array of variable names to check * @returns {Object} Status object with missing variables */ check(vars) { const missing = vars.filter(varName => { const value = process.env[varName]; return value === undefined || value === ''; }); return { valid: missing.length === 0, missing, set: vars.filter(varName => !missing.includes(varName)) }; } /** * List all environment variables (useful for debugging) * @param {boolean} hideValues - Whether to hide values for security * @returns {Object} Environment variables */ list(hideValues = true) { const env = {}; for (const [key, value] of Object.entries(process.env)) { env[key] = hideValues ? '[HIDDEN]' : value; } return env; } /** * Generate helpful suggestions for missing variables * @private */ _generateSuggestions(missing) { const suggestions = [ 'Create a .env file in your project root if it doesn\'t exist', `Add the following variables to your .env file:\n ${missing.map(v => `${v}=your_value_here`).join('\n ')}`, ]; // Check for common .env file locations const commonLocations = [ 'Make sure your .env file is in the project root directory', 'Verify that your .env file is not ignored by .gitignore (though it should be!)', ]; suggestions.push(...commonLocations); // Add example based on variable names if (missing.some(v => v.includes('PORT'))) { suggestions.push('Example: PORT=3000'); } if (missing.some(v => v.includes('URL') || v.includes('URI'))) { suggestions.push('Example: DATABASE_URL=postgresql://user:pass@localhost:5432/db'); } if (missing.some(v => v.includes('KEY') || v.includes('SECRET') || v.includes('TOKEN'))) { suggestions.push('Example: API_KEY=your_secret_key_here'); } return suggestions; } /** * Truncate long values for display * @private */ _truncateValue(value, maxLength = 50) { if (value.length <= maxLength) { return value; } return value.substring(0, maxLength) + '... (truncated)'; } /** * Get human-friendly error reason for type validation failures * @private */ _getTypeErrorReason(expectedType, value) { const truncated = this._truncateValue(value); const typeLower = expectedType.toLowerCase(); // Check if it's a custom validator with custom error message if (this.customValidators.has(typeLower)) { const customValidator = this.customValidators.get(typeLower); return customValidator.errorMessage.replace('{value}', truncated); } // Built-in error messages switch (typeLower) { case 'number': return `"${truncated}" is not a valid number. Example: 42 or 3.14`; case 'boolean': return `"${truncated}" is not a valid boolean. Use: true, false, 1, or 0`; case 'url': return `"${truncated}" is not a valid URL. Example: https://example.com`; case 'email': return `"${truncated}" is not a valid email. Example: user@example.com`; case 'json': return `"${truncated}" is not valid JSON. Example: {"key": "value"}`; case 'port': return `"${truncated}" is not a valid port number (1-65535). Example: 3000`; default: return `Value "${truncated}" does not match expected type "${expectedType}"`; } } /** * Validate type of environment variable value * @private */ _validateType(value, expectedType) { const typeLower = expectedType.toLowerCase(); // Check custom validators first if (this.customValidators.has(typeLower)) { const customValidator = this.customValidators.get(typeLower); try { return customValidator.validate(value); } catch (error) { console.error(`Error in custom validator "${expectedType}":`, error); return false; } } // Built-in validators switch (typeLower) { case 'string': return typeof value === 'string'; case 'number': return !isNaN(Number(value)) && isFinite(Number(value)); case 'boolean': return ['true', 'false', '1', '0'].includes(value.toLowerCase()); case 'url': try { new URL(value); return true; } catch { return false; } case 'email': const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(value); case 'json': try { JSON.parse(value); return true; } catch { return false; } case 'port': const port = Number(value); return Number.isInteger(port) && port > 0 && port <= 65535; default: return true; // Unknown types pass through } } /** * Print a formatted summary of environment status (for debugging) */ printStatus(vars) { console.log('\n' + colorize('Environment Variables Status:', colors.bright + colors.cyan)); console.log(colorize('─'.repeat(60), colors.gray)); vars.forEach(varName => { const value = process.env[varName]; const isSet = value !== undefined && value !== ''; if (isSet) { console.log(`${colorize('✓', colors.green)} ${varName}: ${colorize('SET', colors.green)}`); } else { console.log(`${colorize('✗', colors.red)} ${varName}: ${colorize('MISSING', colors.red)}`); } }); console.log(colorize('─'.repeat(60), colors.gray) + '\n'); } } // Create singleton instance const envKeeper = new EnvKeeper(); // Export both the instance and the class module.exports = envKeeper; module.exports.EnvKeeper = EnvKeeper; module.exports.EnvKeeperError = EnvKeeperError;