244 lines
7.3 KiB
TypeScript
244 lines
7.3 KiB
TypeScript
#!/usr/bin/env tsx
|
|
/**
|
|
* Analyze .env.local file for missing variables, misconfigurations, and security issues
|
|
*/
|
|
|
|
import { readFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
|
|
const envPath = join(process.cwd(), '.env.local');
|
|
|
|
interface EnvAnalysis {
|
|
variable: string;
|
|
status: 'present' | 'missing' | 'empty' | 'commented';
|
|
value?: string;
|
|
required: boolean;
|
|
category: string;
|
|
issues: string[];
|
|
recommendations: string[];
|
|
}
|
|
|
|
const requiredVars = [
|
|
'NODE_ENV',
|
|
'NEXT_PUBLIC_APP_URL',
|
|
'ADMIN_PASSWORD',
|
|
'SESSION_SECRET',
|
|
];
|
|
|
|
const recommendedVars = [
|
|
'DATABASE_URL',
|
|
'RUNTIME_DATABASE_URL',
|
|
'NEXT_PUBLIC_SUPABASE_URL',
|
|
'NEXT_PUBLIC_SUPABASE_ANON_KEY',
|
|
'CSRF_SECRET',
|
|
'RESEND_API_KEY',
|
|
'FROM_EMAIL',
|
|
'REDIS_URL',
|
|
];
|
|
|
|
const optionalVars = [
|
|
'RESEND_WEBHOOK_SECRET',
|
|
'SUPABASE_SERVICE_ROLE_KEY',
|
|
'SHADOW_DATABASE_URL',
|
|
'APPLE_MUSIC_DEVELOPER_TOKEN',
|
|
'NEXT_PUBLIC_SENTRY_DSN',
|
|
'NEXT_PUBLIC_GA_MEASUREMENT_ID',
|
|
'OPENWEATHER_API_KEY',
|
|
'OLLAMA_URL',
|
|
'OLLAMA_MODEL',
|
|
'OLLAMA_TEMPERATURE',
|
|
'SMTP_HOST',
|
|
'SMTP_PORT',
|
|
'SMTP_USER',
|
|
'SMTP_PASS',
|
|
'TWILIO_ACCOUNT_SID',
|
|
'TWILIO_AUTH_TOKEN',
|
|
'TWILIO_PHONE_NUMBER',
|
|
];
|
|
|
|
function analyzeEnvFile(): void {
|
|
let envContent: string;
|
|
try {
|
|
envContent = readFileSync(envPath, 'utf-8');
|
|
} catch (error) {
|
|
console.error('❌ Could not read .env.local file');
|
|
process.exit(1);
|
|
}
|
|
|
|
const lines = envContent.split('\n');
|
|
const envVars: Map<string, { value: string; line: number }> = new Map();
|
|
const commentedVars: Set<string> = new Set();
|
|
|
|
lines.forEach((line, index) => {
|
|
const trimmed = line.trim();
|
|
if (trimmed.startsWith('#') || !trimmed) return;
|
|
|
|
const match = trimmed.match(/^([A-Z_]+)=(.*)$/);
|
|
if (match) {
|
|
const [, key, value] = match;
|
|
envVars.set(key, { value: value.replace(/^["']|["']$/g, ''), line: index + 1 });
|
|
}
|
|
|
|
// Check for commented variables
|
|
const commentedMatch = trimmed.match(/^#\s*([A-Z_]+)=/);
|
|
if (commentedMatch) {
|
|
commentedVars.add(commentedMatch[1]);
|
|
}
|
|
});
|
|
|
|
const analysis: EnvAnalysis[] = [];
|
|
const allVars = [...requiredVars, ...recommendedVars, ...optionalVars];
|
|
|
|
allVars.forEach(varName => {
|
|
const entry = envVars.get(varName);
|
|
const isCommented = commentedVars.has(varName);
|
|
|
|
let status: EnvAnalysis['status'];
|
|
let issues: string[] = [];
|
|
let recommendations: string[] = [];
|
|
|
|
if (isCommented) {
|
|
status = 'commented';
|
|
recommendations.push(`Uncomment ${varName} if you need it`);
|
|
} else if (!entry) {
|
|
status = 'missing';
|
|
if (requiredVars.includes(varName)) {
|
|
issues.push('Required variable is missing');
|
|
} else if (recommendedVars.includes(varName)) {
|
|
recommendations.push(`Consider adding ${varName} for full functionality`);
|
|
}
|
|
} else if (!entry.value || entry.value.trim() === '') {
|
|
status = 'empty';
|
|
if (requiredVars.includes(varName)) {
|
|
issues.push('Required variable is empty');
|
|
} else {
|
|
recommendations.push(`Set ${varName} if you need this feature`);
|
|
}
|
|
} else {
|
|
status = 'present';
|
|
|
|
// Validate specific variables
|
|
if (varName === 'DATABASE_URL' && !entry.value.includes('supabase')) {
|
|
recommendations.push('DATABASE_URL should point to Supabase PostgreSQL');
|
|
}
|
|
if (varName === 'NEXT_PUBLIC_APP_URL' && !entry.value.startsWith('https://')) {
|
|
recommendations.push('Use HTTPS for production URLs');
|
|
}
|
|
if (varName === 'SESSION_SECRET' && entry.value.length < 32) {
|
|
issues.push('SESSION_SECRET should be at least 32 characters');
|
|
}
|
|
if (varName === 'ADMIN_PASSWORD' && entry.value.length < 8) {
|
|
issues.push('ADMIN_PASSWORD should be at least 8 characters');
|
|
}
|
|
if (varName === 'RESEND_API_KEY' && !entry.value.startsWith('re_')) {
|
|
issues.push('RESEND_API_KEY format looks incorrect (should start with "re_")');
|
|
}
|
|
if (varName === 'FROM_EMAIL' && !entry.value.includes('@')) {
|
|
issues.push('FROM_EMAIL should be a valid email address');
|
|
}
|
|
}
|
|
|
|
analysis.push({
|
|
variable: varName,
|
|
status,
|
|
value: entry?.value,
|
|
required: requiredVars.includes(varName),
|
|
category: requiredVars.includes(varName) ? 'Required' :
|
|
recommendedVars.includes(varName) ? 'Recommended' : 'Optional',
|
|
issues,
|
|
recommendations,
|
|
});
|
|
});
|
|
|
|
// Print analysis
|
|
console.log('📋 Environment Variables Analysis\n');
|
|
console.log('='.repeat(80));
|
|
|
|
const categories = ['Required', 'Recommended', 'Optional'];
|
|
categories.forEach(category => {
|
|
const vars = analysis.filter(a => a.category === category);
|
|
if (vars.length === 0) return;
|
|
|
|
console.log(`\n## ${category} Variables\n`);
|
|
|
|
vars.forEach(item => {
|
|
const icon = item.status === 'present' ? '✅' :
|
|
item.status === 'missing' && item.required ? '❌' :
|
|
item.status === 'empty' && item.required ? '⚠️' :
|
|
item.status === 'missing' ? '⚪' : '💬';
|
|
|
|
console.log(`${icon} ${item.variable}`);
|
|
|
|
if (item.status === 'present') {
|
|
const displayValue = item.value && item.value.length > 50
|
|
? item.value.substring(0, 50) + '...'
|
|
: item.value;
|
|
console.log(` Value: ${displayValue || '(empty)'}`);
|
|
} else if (item.status === 'missing') {
|
|
console.log(` Status: Missing`);
|
|
} else if (item.status === 'empty') {
|
|
console.log(` Status: Empty`);
|
|
} else {
|
|
console.log(` Status: Commented out`);
|
|
}
|
|
|
|
if (item.issues.length > 0) {
|
|
item.issues.forEach(issue => {
|
|
console.log(` ⚠️ Issue: ${issue}`);
|
|
});
|
|
}
|
|
|
|
if (item.recommendations.length > 0) {
|
|
item.recommendations.forEach(rec => {
|
|
console.log(` 💡 ${rec}`);
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
// Summary
|
|
console.log('\n' + '='.repeat(80));
|
|
console.log('\n📊 Summary\n');
|
|
|
|
const present = analysis.filter(a => a.status === 'present').length;
|
|
const missing = analysis.filter(a => a.status === 'missing').length;
|
|
const empty = analysis.filter(a => a.status === 'empty').length;
|
|
const commented = analysis.filter(a => a.status === 'commented').length;
|
|
const hasIssues = analysis.filter(a => a.issues.length > 0).length;
|
|
|
|
console.log(`Total variables checked: ${analysis.length}`);
|
|
console.log(`✅ Present: ${present}`);
|
|
console.log(`❌ Missing: ${missing}`);
|
|
console.log(`⚠️ Empty: ${empty}`);
|
|
console.log(`💬 Commented: ${commented}`);
|
|
console.log(`🔴 Has issues: ${hasIssues}`);
|
|
|
|
// Critical missing variables
|
|
const criticalMissing = analysis.filter(a =>
|
|
a.required && (a.status === 'missing' || a.status === 'empty')
|
|
);
|
|
|
|
if (criticalMissing.length > 0) {
|
|
console.log('\n🚨 Critical Issues:\n');
|
|
criticalMissing.forEach(item => {
|
|
console.log(` ❌ ${item.variable} is ${item.status} but required`);
|
|
});
|
|
}
|
|
|
|
// Recommendations
|
|
const missingRecommended = analysis.filter(a =>
|
|
a.category === 'Recommended' && a.status === 'missing'
|
|
);
|
|
|
|
if (missingRecommended.length > 0) {
|
|
console.log('\n💡 Recommendations:\n');
|
|
missingRecommended.forEach(item => {
|
|
console.log(` • Add ${item.variable} for better functionality`);
|
|
});
|
|
}
|
|
|
|
console.log('\n' + '='.repeat(80));
|
|
}
|
|
|
|
analyzeEnvFile();
|