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